V2 blocks of Duodeal quotations
🔄 Are these skills current? They are a copy taken from the public repo — there is
no git remote behind them and nothing refreshes them on its own, so an install silently
stays on the version of the day it was made. Read the line Skills Duodeal : mises à jour le … in DUODEAL-CONTEXT.md (project root): absent, or more than 7 days old → offer
the refresh in one sentence before working. Procedure: ../duodeal-onboarding/references/updating-skills.md. Ask once per session;
if the user declines, work with what is installed and drop it.
A V2 quotation is an ordered list of blocks (builderVersion: 2), edited
in the block editor. Contract verified empirically — nothing is in openapi.yaml
(spec predates V2).
Data model
On the quotation: builderVersion: 2, blocks: [...] (+ groupId/versionNumber
for versioning). Each block:
{"id": "<UUID>", "type": "wysiwyg", "version": 1, "visible": true,
"title": "", "showTitle": true, "layout": {"columns": 1, "rows": 1}, "data": {...}}
data per type — the main ones:
| Type |
data |
Notes |
wysiwyg |
{columns: [html]} |
Inline render — V2 equivalent of an HtmlSimple CF |
html |
{code, state} |
Sandboxed iframe, window.DuoDeal JS API injected — must end with DuoDeal.autoResize() |
header |
{cover, noCover, logo, noLogo} |
|
pricing |
{discountEnabled, discount, discountType, columns} |
Lines attach to it via blockId |
customfields |
{fields: [names]} |
|
legalnotice |
{companyName, legalText, other…} |
|
faq |
{items: [{id, question, answer}]} |
RAW text ({{ }} interpolation, no v-html) — HTML is rendered literally |
contacts |
{salesLabel, clientLabel} (older blocks: []) |
Labels over the sender and recipient cards; empty = the app's defaults (« Commercial » / « Client »). The sender card shows the deal owner with their login email (not publicEmail): pick the owner accordingly |
accept |
{} |
Legacy "Accept & sign" button (opens the signature modal). Disappears once signed — always pair it with signstamp. New quotes use signature |
signature |
{heading, text, buttonLabel, mode: "signing", otp, showSummary, consent: {enabled, text}, termsBlockId, fields, signatories: [{id: "c<customerId>", customerId, firstName, lastName, email}], stampFields, stampLines: {thanks, signedOn, signedBy, signature, method, quotation, download}} |
The new Accept & sign block. otp = code sent by email before signing; consent.enabled = a tick the signer must check (its text is the sentence); signatories = who signs, written on the block (a block copied through the API keeps the source's list: rewrite it per deal). Once signed it shows its own stamp, no signstamp needed. stampLines.quotation: false hides the « quote · title · amount » line of that stamp (writable even after signature). Empty heading/text = the app's defaults. The code email and the "signed" email are sent by the platform and show the deal name: name deals the way the client should read them. Verified 2026-09-21 |
signstamp |
{} |
Signature proof (signed date, signer + email, validation CFs). Not signed → renders nothing client-side; signed → visible on both faces |
Known types: header, contacts, wysiwyg, html, pricing, customfields, attachments,
legalnotice, paymentschedule, pdfviewer, youtube, faq, pptx, googleslides, canva,
gallery, accept, signstamp, signature, pagebreak.
REST contract (off-spec) and MCP tools
- Take the inventory before touching anything — every block
id, type and the current
order (connector: get_quotation(id) → blocks[]; there is no block-listing tool).
One block in full: get_quotation_block {quotation_id integer, block_id UUID string}.
- Write:
PUT /quotations/{id} with {builderVersion: 2, blocks: [...]} —
⚠️ the array you send REPLACES everything (same trap as customFields). The V2 editor is
used in parallel by the team: a blind PUT overwrites their work. Edit block by block,
never by re-posting the whole array:
- Add a block, then fill it — a new block always lands with the server's default content
(connector:
add_quotation_block {quotation_id, type, position 0-based, omit =
append} takes no content argument) → second call to write the content.
- Change a block's content: send the COMPLETE
data — the merge is shallow at root,
a partial data wipes the rest of the object (connector: update_quotation_block
{quotation_id, block_id, data JSON object, never a JSON string, title,
showTitle, visible, layout}). Read the block first, edit in memory, send it whole.
- Large text (html
code, long wysiwyg column): edit by anchor instead of resending tens of
KB (connector: replace_quotation_block_text {quotation_id, block_id, field =
dot-path inside data — "code", "columns.0" — plus search+replace or
from+to+replace}). Anchors must be short and unique: no replace_all, no regex,
no occurrence index. A non-text structure (one faq.items entry, one gallery.images)
is not reachable this way → complete data instead.
- Delete / reorder:
delete_quotation_block {quotation_id, block_id} ·
reorder_quotation_blocks {quotation_id, order} where order is the COMPLETE list
of ids top to bottom — a partial list silently pushes the omitted blocks to the end.
- Retype or duplicate a block: no tool for either — a block's
id and type are
immutable. Delete + add of the right type + reorder; to copy one, read the source block and
write its data into a freshly added block of the same type (watch internal ids like
faq.items). No history either: keep the data you read before any write, it is your undo.
- Block
ids are client-side generated UUIDs, persisted as-is (a clone made in the Duodeal
interface preserves them). The connector has no clone_deal / clone_quotation: either
rebuild deal → quotation → lines → blocks, or clone in the app and read the result back.
- Lines ↔ pricing: each quotation-line attaches to the pricing block via
blockId
(payload of create/update_quotation_line). Without blockId, lines fall back to the
first pricing block — only required when there are several pricing blocks. If you
replace the pricing block, re-attach the lines. Rows never live in block.data, and
deleting a pricing block does not delete its lines — remove them explicitly.
- Images inside blocks (header
cover/logo, gallery, attachments, pdfviewer):
register the media first — ⚠️ reuse an existing media first (list_medias); file in base64 is the normal, supported route for the upload itself. ⚠️ What is forbidden is base64 inside the HTML: reference the url the media returns, never a data: URI (see duodeal-mcp-best-practices → images); never from_url, the URL import 500s on most CDNs whatever the tool description says (connector: create_media {name, folder, file}) —
then reference its url/id in the complete data of the
target block. Line and product tools have no media argument — an image on a line is bound
by REST (POST|PUT /quotation-lines {medias: [{id}]}) when a key is already configured,
otherwise in the Duodeal interface, and you say it is still pending.
- The quotation must already be V2 for any of this to apply: a quote created through the
connector starts at
builderVersion: 1, blocks: null, and no connector argument exposes
builderVersion. Check it on the builderVersion field itself (get_quotation →
builderVersion == 2). ⚠️ Non-empty blocks[] is NOT a proof of V2: a quotation can carry
V2 blocks and still be builderVersion: 1 — the menu then opens it in the old (V1) editor,
and the blocks are ignored. To flip it, PUT /quotations/{id} with {builderVersion: 2, blocks: [...]} via REST if a key is configured,
otherwise convert it in the Duodeal interface — and say so to the user.
quotation.shareLinks = V2 share links (filtered view of the blocks), read-only in
get_quotation: the connector cannot create one.
JS API of html blocks (micro-apps)
The code runs in a sandboxed iframe with window.DuoDeal injected:
DuoDeal.deal / .quotation / .lines / .customFields — read the quote data
DuoDeal.onUpdate(cb) — re-render on live pricing edits
DuoDeal.get/set/update/getData/setData — per-block persisted state (data.state)
DuoDeal.uploadFile/removeFile/isFile — files attached by the client
DuoDeal.storage / DuoDeal.locale — encryption mode of the answers, client language
DuoDeal.formatCurrency(n) / formatDate(d) / autoResize()
➡️ Full contract in duodeal-html-block-js: the four
render contexts (a block that paints itself only in an event handler prints empty in the
PDF), the field maps, the fields never to show a client, the state and its 64 KB limit, the
encryption of client answers and the _ prefix, files. Read it before writing any block
that reads data or collects some.
Always end with autoResize() — otherwise the iframe keeps its default height
(white space or clipped content). Check the call is there before writing the block: no
connector tool validates the code you send.
Checklist before writing blocks
- Test/demo tenant only; label anything disposable as "to delete".
- Read the existing blocks first — understand what exists before touching anything
(connector:
get_quotation → blocks[], then get_quotation_block for the ones you edit).
- NEVER post a partial
blocks array: through REST it replaces the whole page, and the
connector has no raw-HTTP tool (api_call does not exist) — edit block by block, and
send the complete data each time.
- Rich sections →
wysiwyg; interactive code/logos → html (+ autoResize()).
- Do not embed spacers (
<div style="height:71px">…): each block handles its own
spacing — the spacer turns into a white band at the top of the card.
faq: raw text only.
accept never ships alone → always add a signstamp block next to it (the button
disappears once signed; the stamp is the only remaining proof of signature).
- Check the render in the V2 editor — no tool returns a preview, so open the edit link in a
browser (or ask the user to look, and say so). Build it by hand, there is no link tool:
https://duodeal.app/app/quotations/{dealId}/{quotationId} from the deal id
(get_deal) and the quotation id; the customer link is
https://duodeal.app/quotations/deal/{deal.uid}.
Reference structure (validated — Onboarding Agent)
Canonical order of a generated V2 quote: 1) header (native cover, noLogo: true) ·
2) html block with sender + client logos side by side · 3) one wysiwyg block per section
(cover, your project, who we are, product range, video, gallery, investment) ·
4) pricing (lines attached via blockId) · 5) post-table wysiwyg (terms,
testimonials, FAQ, contact) · 6) contacts then legalnotice. Do not change this reference
structure without explicit approval.
1---2name: duodeal-v2-blocks3description: V2 block system of Duodeal quotations (builderVersion 2) — data model, block types (wysiwyg, html, pricing, header, faq…), off-spec REST contract, MCP tools for safe manipulation, DuoDeal JS API of html blocks, anti-overwrite checklist. Use whenever you read or write blocks, add/update/reorder/delete a block, convert a quote to V2, build an html micro-app inside a quote, or debug a V2 render (iframe height, autoResize, blocks array overwritten).4---56# V2 blocks of Duodeal quotations78> 🔄 **Are these skills current?** They are a **copy** taken from the public repo — there is9> no git remote behind them and nothing refreshes them on its own, so an install silently10> stays on the version of the day it was made. Read the line `Skills Duodeal : mises à jour11> le …` in `DUODEAL-CONTEXT.md` (project root): **absent, or more than 7 days old → offer12> the refresh in one sentence before working**. Procedure: [../duodeal-onboarding/references/updating-skills.md](../duodeal-onboarding/references/updating-skills.md). Ask **once per session**;13> if the user declines, work with what is installed and drop it.141516A V2 quotation is an **ordered list of `blocks`** (`builderVersion: 2`), edited17in the block editor. Contract verified empirically — **nothing is in openapi.yaml**18(spec predates V2).1920## Data model2122On the quotation: `builderVersion: 2`, `blocks: [...]` (+ `groupId`/`versionNumber`23for versioning). Each block:2425```json26{"id": "<UUID>", "type": "wysiwyg", "version": 1, "visible": true,27 "title": "", "showTitle": true, "layout": {"columns": 1, "rows": 1}, "data": {...}}28```2930`data` per type — the main ones:3132| Type | data | Notes |33|---|---|---|34| `wysiwyg` | `{columns: [html]}` | Inline render — V2 equivalent of an HtmlSimple CF |35| `html` | `{code, state}` | **Sandboxed iframe**, `window.DuoDeal` JS API injected — **must end with `DuoDeal.autoResize()`** |36| `header` | `{cover, noCover, logo, noLogo}` | |37| `pricing` | `{discountEnabled, discount, discountType, columns}` | Lines attach to it via `blockId` |38| `customfields` | `{fields: [names]}` | |39| `legalnotice` | `{companyName, legalText, other…}` | |40| `faq` | `{items: [{id, question, answer}]}` | **RAW text** (`{{ }}` interpolation, no v-html) — HTML is rendered literally |41| `contacts` | `{salesLabel, clientLabel}` (older blocks: `[]`) | Labels over the sender and recipient cards; empty = the app's defaults (« Commercial » / « Client »). The sender card shows the **deal owner** with their **login email** (not `publicEmail`): pick the owner accordingly |42| `accept` | `{}` | Legacy "Accept & sign" button (opens the signature modal). **Disappears once signed** — always pair it with `signstamp`. New quotes use `signature` |43| `signature` | `{heading, text, buttonLabel, mode: "signing", otp, showSummary, consent: {enabled, text}, termsBlockId, fields, signatories: [{id: "c<customerId>", customerId, firstName, lastName, email}], stampFields, stampLines: {thanks, signedOn, signedBy, signature, method, quotation, download}}` | The new Accept & sign block. `otp` = code sent by email before signing; `consent.enabled` = a tick the signer **must** check (its `text` is the sentence); `signatories` = who signs, written on the block (a block copied through the API keeps the source's list: rewrite it per deal). Once signed it shows **its own stamp**, no `signstamp` needed. `stampLines.quotation: false` hides the « quote · title · amount » line of that stamp (writable even after signature). Empty `heading`/`text` = the app's defaults. The code email and the "signed" email are sent by the platform and show the **deal name**: name deals the way the client should read them. Verified 2026-09-21 |44| `signstamp` | `{}` | Signature proof (signed date, signer + email, validation CFs). Not signed → renders **nothing** client-side; signed → visible on both faces |4546Known types: header, contacts, wysiwyg, html, pricing, customfields, attachments,47legalnotice, paymentschedule, pdfviewer, youtube, faq, pptx, googleslides, canva,48gallery, accept, signstamp, signature, pagebreak.4950## REST contract (off-spec) and MCP tools5152- **Take the inventory before touching anything** — every block `id`, `type` and the current53 order (connector: `get_quotation(id)` → `blocks[]`; there is **no** block-listing tool).54 One block in full: `get_quotation_block` {`quotation_id` integer, `block_id` **UUID string**}.55- **Write**: `PUT /quotations/{id}` with `{builderVersion: 2, blocks: [...]}` —56 ⚠️ **the array you send REPLACES everything** (same trap as customFields). The V2 editor is57 used in parallel by the team: a blind PUT overwrites their work. **Edit block by block**,58 never by re-posting the whole array:59 - Add a block, then fill it — a new block always lands with the **server's default content**60 (connector: `add_quotation_block` {`quotation_id`, `type`, `position` 0-based, omit =61 append} takes no content argument) → second call to write the content.62 - Change a block's content: send the **COMPLETE `data`** — the merge is **shallow at root**,63 a partial `data` wipes the rest of the object (connector: `update_quotation_block`64 {`quotation_id`, `block_id`, `data` **JSON object, never a JSON string**, `title`,65 `showTitle`, `visible`, `layout`}). Read the block first, edit in memory, send it whole.66 - Large text (html `code`, long wysiwyg column): edit by anchor instead of resending tens of67 KB (connector: `replace_quotation_block_text` {`quotation_id`, `block_id`, `field` =68 dot-path inside `data` — `"code"`, `"columns.0"` — plus `search`+`replace` **or**69 `from`+`to`+`replace`}). Anchors must be **short and unique**: no `replace_all`, no regex,70 no occurrence index. A non-text structure (one `faq.items` entry, one `gallery.images`)71 is **not** reachable this way → complete `data` instead.72 - Delete / reorder: `delete_quotation_block` {`quotation_id`, `block_id`} ·73 `reorder_quotation_blocks` {`quotation_id`, `order`} where `order` is the **COMPLETE** list74 of ids top to bottom — a partial list silently pushes the omitted blocks to the end.75 - Retype or duplicate a block: **no tool for either** — a block's `id` and `type` are76 immutable. Delete + add of the right type + reorder; to copy one, read the source block and77 write its `data` into a freshly added block of the same type (watch internal ids like78 `faq.items`). No history either: keep the `data` you read before any write, it is your undo.79- Block `id`s are **client-side generated UUIDs**, persisted as-is (a clone made in the Duodeal80 interface preserves them). The connector has **no `clone_deal` / `clone_quotation`**: either81 rebuild deal → quotation → lines → blocks, or clone in the app and read the result back.82- **Lines ↔ pricing**: each quotation-line attaches to the pricing block via `blockId`83 (payload of `create/update_quotation_line`). Without `blockId`, lines fall back to the84 **first** pricing block — only required when there are several pricing blocks. If you85 replace the pricing block, re-attach the lines. Rows never live in `block.data`, and86 deleting a pricing block does **not** delete its lines — remove them explicitly.87- **Images inside blocks** (header `cover`/`logo`, `gallery`, `attachments`, `pdfviewer`):88 register the media first — ⚠️ reuse an existing media first (`list_medias`); `file` in **base64 is the normal, supported route** for the upload itself. ⚠️ What is forbidden is base64 **inside the HTML**: reference the url the media returns, never a `data:` URI (see **duodeal-mcp-best-practices** → images); **never `from_url`**, the URL import 500s on most CDNs whatever the tool description says (connector: `create_media` {`name`, `folder`, `file`}) —89 then reference its url/id in the **complete `data`** of the90 target block. Line and product tools have **no media argument** — an image on a line is bound91 by REST (`POST|PUT /quotation-lines {medias: [{id}]}`) when a key is already configured,92 otherwise in the Duodeal interface, and you say it is still pending.93- **The quotation must already be V2** for any of this to apply: a quote created through the94 connector starts at `builderVersion: 1, blocks: null`, and **no connector argument exposes95 `builderVersion`**. Check it on the **`builderVersion` field itself** (`get_quotation` →96 `builderVersion == 2`). ⚠️ **Non-empty `blocks[]` is NOT a proof of V2**: a quotation can carry97 V2 blocks and still be `builderVersion: 1` — the menu then opens it in the **old (V1) editor**,98 and the blocks are ignored. To flip it, `PUT99 /quotations/{id}` with `{builderVersion: 2, blocks: [...]}` via REST if a key is configured,100 otherwise convert it in the Duodeal interface — and say so to the user.101- `quotation.shareLinks` = V2 share links (filtered view of the blocks), **read-only** in102 `get_quotation`: the connector cannot create one.103104## JS API of `html` blocks (micro-apps)105106The `code` runs in a sandboxed iframe with `window.DuoDeal` injected:107108- `DuoDeal.deal / .quotation / .lines / .customFields` — read the quote data109- `DuoDeal.onUpdate(cb)` — re-render on live pricing edits110- `DuoDeal.get/set/update/getData/setData` — per-block persisted state (`data.state`)111- `DuoDeal.uploadFile/removeFile/isFile` — files attached by the client112- `DuoDeal.storage` / `DuoDeal.locale` — encryption mode of the answers, client language113- `DuoDeal.formatCurrency(n)` / `formatDate(d)` / `autoResize()`114115➡️ **Full contract in [duodeal-html-block-js](../duodeal-html-block-js/SKILL.md)**: the four116render contexts (a block that paints itself only in an event handler prints **empty** in the117PDF), the field maps, the fields never to show a client, the state and its 64 KB limit, the118encryption of client answers and the `_` prefix, files. Read it before writing any block119that reads data or collects some.120121**Always end with `autoResize()`** — otherwise the iframe keeps its default height122(white space or clipped content). Check the call is there **before** writing the block: no123connector tool validates the code you send.124125## Checklist before writing blocks1261271. Test/demo tenant only; label anything disposable as "to delete".1282. Read the existing blocks first — understand what exists before touching anything129 (connector: `get_quotation` → `blocks[]`, then `get_quotation_block` for the ones you edit).1303. NEVER post a partial `blocks` array: through REST it replaces the whole page, and the131 connector has **no raw-HTTP tool** (`api_call` does not exist) — edit block by block, and132 send the complete `data` each time.1334. Rich sections → `wysiwyg`; interactive code/logos → `html` (+ `autoResize()`).1345. Do not embed spacers (`<div style="height:71px">…`): each block handles its own135 spacing — the spacer turns into a white band at the top of the card.1366. `faq`: raw text only.1377. `accept` never ships alone → always add a `signstamp` block next to it (the button138 disappears once signed; the stamp is the only remaining proof of signature).1398. Check the render in the V2 editor — no tool returns a preview, so open the edit link in a140 browser (or ask the user to look, and say so). Build it by hand, there is no link tool:141 `https://duodeal.app/app/quotations/{dealId}/{quotationId}` from the deal `id`142 (`get_deal`) and the quotation `id`; the customer link is143 `https://duodeal.app/quotations/deal/{deal.uid}`.144145## Reference structure (validated — Onboarding Agent)146147Canonical order of a generated V2 quote: 1) `header` (native cover, `noLogo: true`) ·1482) `html` block with sender + client logos side by side · 3) one `wysiwyg` block per section149(cover, your project, who we are, product range, video, gallery, investment) ·1504) `pricing` (lines attached via `blockId`) · 5) post-table wysiwyg (terms,151testimonials, FAQ, contact) · 6) `contacts` then `legalnotice`. Do not change this reference152structure without explicit approval.