# Duodeal MCP Best Practices

> Golden rules, render contract and checklist for generating, editing or delivering a Duodeal quote (HTML selling page) through the official MCP connector. Use before generating, editing or delivering a Duodeal quote, and whenever the user talks about "best practices", "golden rules", "quote checklist", "render contract", "selling page", "design quote", "premium proposal", "V2 blocks" or the Duodeal API reference. Covers the structure of native blocks, the HTML blocks that survive the visual editor and the PDF, prices and currencies, content/copy, and API write guardrails.

- Skill: `duodealteam/duodeal-mcp-best-practices` (Agent Skill)
- Install (CLI): `npx skillmds@latest add duodealteam/duodeal-mcp-best-practices`
- Raw SKILL.md: https://api.skillmd.com/api/skills/duodealteam/duodeal-mcp-best-practices/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: DuodealTeam (https://skillmd.com/u/duodealteam)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/duodealteam/duodeal-mcp-best-practices

---


# Best practices — Duodeal quotes (via MCP)

> 🔄 **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](../duodeal-onboarding/references/updating-skills.md). Ask **once per session**;
> if the user declines, work with what is installed and drop it.


These rules apply as soon as you generate, edit or deliver a quote through the official Duodeal MCP connector. The client context is already known: apply them directly, with no prior research. For the detailed know-how, see also **duodeal-quote-building**, **duodeal-quote-design** and **duodeal-v2-blocks**.

## Blocking checklist (before any delivery)

One failed item = redo the quote.

1. **Native header filled in** with the sender's logo + cover, never hidden nor recoded in HTML.
2. **Native contacts block present**, sender (dedicated, named owner) AND recipient filled in.
3. **Every block carries a non-empty `title`** (otherwise the interface displays "html" instead of the title).
4. **Every product line has an image**, square and centered, on the media of the LINE (not of the linked product). ⚠️ The connector cannot attach it: line tools take no media argument, and `create_product`/`update_product` have none either. Upload with `create_media`, then bind the line media through the REST API when a key is already configured, otherwise ask the user to attach it in the Duodeal interface and say so.
5. **Every HTML block ends with `DuoDeal.autoResize()`** and stays presentable once its `<style>` tags are stripped (everything styled inline, no decorative `<script>`).
6. **`builderVersion` 2 enabled**, language and currency set at deal level, without touching the account settings. ⚠️ The connector exposes none of the three (no `builderVersion` argument anywhere; `create_deal`/`update_deal` accept no language and no currency): check what you have on the **`builderVersion` field** in `get_quotation` (`builderVersion == 2`, **not** the mere presence of `blocks[]` — blocks can sit on a `builderVersion: 1` quote, which then opens in the old V1 editor) — and set what is missing through REST or in the app, saying which.
7. **No `{{...}}` placeholder and no em dash left**, rendering verified on the PDF export and the rendered page (not on code reading alone). ⚠️ No connector tool returns a render: look yourself, or ask the user to look and say you have not seen it. ⚠️ **But opening the CLIENT link counts as a prospect visit** (see the tracking rule below): decide with the user before you open it.
8. **The PDF exports in A4, with margins, no section cut in two.** Left alone, the export is US Letter with no margins. One `<style>` in the `title` of the first title line fixes it, through the connector: see **PDF export** below.

## Checking your work without polluting the prospect's statistics

⚠️ **Opening the client link is recorded as a prospect visit.** The public client view posts a `visit` on load, then a `heartbeat` every 15 s, to `/api/access-sessions` (browser `fetch`; a plain server-side GET counts nothing). Your own review — open, scroll to hydrate the blocks, read each section — therefore reads as a highly engaged buyer, on the very deal whose re-opens drive the follow-up and the Hot Deal Score.

- There is **no internal-view opt-out** today: you cannot look without being counted.
- So **tell the user before you open the client link**, and let them decide. On a real prospect's deal the honest default is not to open it.
- The **PDF export** (`GET https://api.duodeal.app/api/public/quotations/pdf/deal/{deal_uid}/{quotation_id}`, the route behind the page's own "Download PDF" button, public, no key) and the **edit link** cost nothing on the prospect's statistics counter: use them for everything they can show.
- If you do open the client view, keep it short and say in your delivery that one visit was recorded.

## Quote structure and native blocks (render contract)

Native blocks carry the sender's identity, the signature and the legal notices: keep them filled in and properly populated.

- ⚠️ **Native header**: the sender's logo + cover, never empty nor recoded in HTML (emptying it or rebuilding it breaks the rendering and forces a rework).
- ⚠️ **Native contacts block** (sender + recipient), never rebuilt in HTML; check that the recipient is present (otherwise the information is out of sync and the delivery is non-compliant).
- Give every block a non-empty `title`; if the visible title lives in the HTML, keep the `title` and set `showTitle:false`.
- An image on every product line, on the media of the LINE, square, centered on the subject (`object-fit: cover`); upload it with `create_media`, then bind it by REST or in the app (no media argument on the line and product tools).
- Consistent logos (same format, background, size) across all quotes of the same account.
- Deal owner = a dedicated, named sender user (person + job title) with their real photo, never the company nor a generic account. Pick the owner among the existing users (`list_users`, `get_user`); the connector creates no user and `create_deal`/`update_deal` take no owner — assign it in the app (or by REST) and say so.
- ⚠️ **On a REST `PUT /deals/{id}`, always resend `users`.** A partial PUT resets `deal.users` to the API key's own owner, and `deal.users` drives the **sender identity displayed on the client page**: another prospect's contact then ships in the delivered HTML, silently and beautifully. Read the deal, keep its `users`, send them back with every write, and re-read them afterwards.
- Choose a plausible login address for the owner: the sender card displays the LOGIN email. That address is set when the user is created, which only the client's admin does in the app.
- Set language and currency at deal level (per-deal), without touching the account. ⚠️ Not reachable from the connector (`create_deal`/`update_deal` have neither, and currency exists only company-wide via `update_company`, which you must not overwrite): do it by REST if a key is configured, otherwise in the app.
- Group legal notices and terms & conditions in the native `legalnotice` block, only once, with every structured field filled in (at minimum the name of the issuing company); ⚠️ an empty field falls back to the ACCOUNT name.
- Single CTA = the native "Accept & sign" button (French deals: « Accepter et signer »); ⚠️ never a fake HTML button (it does not trigger the signature).
- ⚠️ **Always pair the signature with its stamp**: whenever you add an `accept` block (or enable `data.showAcceptButton` on the `pricing` block), add a `signstamp` block next to it. Once the quote is signed the `accept` button **disappears**, and `signstamp` is the only thing that shows the signature (signed date, signer, email, validation custom fields) — without it, a signed quote displays no trace of the signature at all. Before signing, `signstamp` renders nothing on the client side (dashed placeholder in the editor only), so adding it costs nothing.
- Switch every quote to `builderVersion` 2 as soon as it is created. ⚠️ No connector argument selects it: confirm the state with `get_quotation` on the **`builderVersion` field** (`builderVersion == 2`). ⚠️ Having blocks proves nothing: a `builderVersion: 1` quotation can hold V2 blocks and will still open in the **old editor**. If it is still V1, convert by REST or in the app before writing any block.
- **Primary quotation — check it on every quote you create.** A quotation created with `create_quotation` (connector) or `POST /quotations` (REST) is born with `primaryQuotation: null`, even as the deal's first and only quotation, and the app's deals table lists deals **through their primary quotation**: the deal is then **missing from the table** (it exists, its links work, the rep just cannot find it). Right after `create_quotation`, read `get_deal` → `quotations[].primaryQuotation`; if it is not `true`, set it by REST (`PUT /quotations/{id} {"primaryQuotation": true}`) when a key is already configured, otherwise say it to the user before delivering: the connector has no argument for it. "The first quotation of a deal is primary by default" is only guaranteed for deals created in the app: check, never assume. Procedure: **duodeal-quote-building** §5.
- Structure into dedicated blocks (one topic = one block), favoring native blocks (header, contacts, pricing, legalnotice, attachments).
- Consistent visual identity: one reference font, one fixed palette with defined roles (background, accent, primary, contrast).

## HTML blocks: surviving the visual editor and the PDF

⚠️ The visual editor strips `<style>` tags and neutralizes `<script>` tags the first time the sales rep edits; the final rendering also goes through a PDF export. Every block must hold up in both states.

- All styling inline (`style="..."`); **no `<style>` at all** (no `@font-face` exception — see the font rule below), no decorative `<script>` (presentation interactivity through inline `onclick`). A block that reads the quote or collects client data keeps its `DuoDeal` logic in the block's **final** script, never before the first element — the editor deletes what precedes it (**duodeal-html-block-js**).
- Responsive without media queries: `flex` + `flex-wrap` + `flex:1 1 basis` (never `grid-template-columns`), falling back to a single column on narrow screens and in print.
- Before delivery, check that every block stays presentable once its `<style>` tags are stripped: that is the state the prospect will see.
- End every block with `DuoDeal.autoResize()` inside a `try/catch`; ⚠️ otherwise the iframe keeps a fixed height and cuts off the bottom.
- A block that **reads the quote or collects data from the client** (bound recap, form, upload, configurator) follows **duodeal-html-block-js**. The one that ships broken blocks: it must render **from its saved state alone, at load** — in the PDF there is no interaction and `onUpdate` never fires, so a block painted only inside an event handler prints empty.
- Brand font: ship the **system fallback stack**, full stop. No CDN `<link>` (CORS, and missing from the PDF), no base64 `@font-face` (far too heavy), no `@font-face` on a media url (fonts are not in the accepted MIME list) — and every `@font-face` route dies anyway, because it needs a `<style>` and the editor strips `<style>` on the first rep edit. Carry the brand through color, weights, scale and spacing instead. The typeface is preserved where it counts by shipping the **logo as SVG** (`image/svg+xml` is accepted): outlines, not a font.
- Images/logos in the Duodeal media library, `max-width:100%; height:auto`; never an external hotlink. A media cannot be renamed, moved or deleted from the connector, so create a new one and re-point the block rather than trying to replace it.
- **Getting an image into the library:**
  1. **Reuse what is already there** (`list_medias`, `search`) before uploading anything.
  2. **`create_media` {`name`, `folder`, `file` in base64}** — this is the **normal, supported route**. Base64 is how the API takes a file: there is no multipart, no local path and no `upload_media` tool. Ceiling ~4 MB, nothing resizes for you.
  3. **`from_url` — never**, whatever the tool description says: the URL import 500s on most CDNs.
  4. Then reference the **url the media returns** inside the block's `data`.
- 🚫 **Never base64 inside the HTML.** A `data:` URI in a block (`<img src="data:image/png;base64,…">`, an inline background, a base64 font) **bugs every time**: the block is far too heavy, the editor and the PDF export choke on it, and the same bytes travel back through every read of the quote. An image always goes to the media library first and the HTML carries **its url**. The rule holds for every asset: images, logos, icons, fonts.
- `box-sizing:border-box` on every sized element; ⚠️ its absence is the number one cause of mobile overflow (`width:100%` + padding).
- `break-inside:avoid` (+ `page-break-inside:avoid`) on cards, steps, panels, CTAs.
- **A section title never sits alone at the foot of a page.** The head (kicker + title in one wrapper) carries `break-inside:avoid;page-break-inside:avoid;break-after:avoid;page-break-after:avoid`, and nothing empty sits between it and its content. The PDF engine does honour `break-after:avoid`, but only at that exact boundary: with a spacer `<div style="height:26px">` between head and content, it simply breaks after the spacer and the title is orphaned all the same. Space the head with its own `margin-bottom`, or give the spacer `break-after:avoid` too. Without `break-inside:avoid` on the head, the break lands between kicker and title instead. The page-level print rule cannot do this for you inside an html block: see **PDF export** below.
- Height determined by the content: no fixed `height`, no `vh`, no forced page break.
- ⚠️ No internal scroller (`max-height` + `overflow-y:auto`): auto-sizing injects thousands of pixels of blank space in the client view and the PDF, invisible in the editor.
- One print override for every mobile media query; cap spacers on mobile and restore the desktop value in print.
- Consistent spacers at the top (and bottom) of every section block, except the cover/intro that follows the header; one spacer before the pricing table.
- Namespace every CSS class with a short prefix specific to the block (avoids collisions between blocks on the same page).
- Icons as inline SVG (no exotic unicode glyphs → tofu), no gradient text (`background-clip:text` → stray hairline), no `box-shadow` on critical blocks (prefer a 1px border, better PDF rendering).
- ⚠️ No native FAQ block: it renders the HTML literally (entities show up as-is). Build the FAQ as an HTML block.
- Flex card with text + media: `min-width:0` on the text column, `overflow-wrap:anywhere` on long words/emails, stack into a column on narrow screens.
- Cap wide images on mobile through `width`/`max-width`, never `transform:scale` (it does not reduce the layout width).
- Table with an incompressible minimum width inside an `overflow-x:auto` container, or make it fit under ~360 px.
- Covers/full-bleed at `width:100%` and `border-radius:0`, validated against the client view (not the editor card, which has a radius and a clip that are absent on the client side).
- Portrait shot in a landscape frame: `object-fit:contain` on a white background, never `cover` (which crops and zooms).
- Video: check that the embed is allowed, opaque branded poster over an `about:blank` iframe, inject the embed URL (autoplay, playsinline) when the poster is clicked.

## PDF export: A4, 10 mm margins, no section cut in two

The client's "Download PDF" button runs a headless Chrome print that passes **no paper format**: every export comes out **US Letter (612 × 792 pt) with near-zero margins**, and sections are sliced wherever the page ends, a totals card left alone on a page, a product line with its price on one page and its description on the next. A Letter file printed on A4 rescales and loses its margins. Clients who print proposals for meetings notice it first.

The export honours a CSS `@page` rule found in the page (`preferCSSPageSize` is on), so the fix lives in the quote's content and needs no key and no dev:

1. **The carrier: the `title` of the FIRST `lineType:"title"` line** of the price table. Write it with the connector, `update_quotation_line {id, title}` on an existing line, or `title` in `create_quotation_line` / `add_quotation_lines` at build time. Put the `<style>` first and keep the section's visible text after it:

   ```
   <style>/*dd-print-v3*/@page { size: A4; margin: 10mm; } @media print { [data-block-type] { break-inside: avoid; page-break-inside: avoid; } .qv-row-stacked, .qv-summary, .qv-summary-card { break-inside: avoid; page-break-inside: avoid; } .qv-title-row, h1, h2, h3, h4, h5, h6, p:has(> strong:only-child), p:has(> b:only-child) { break-after: avoid; page-break-after: avoid; } }</style><p>Your section title</p>
   ```

   Copy it verbatim. `@page` sets paper and margins; `[data-block-type]` keeps each block whole; the three `.qv-` classes keep a price-table row and the totals card whole, so a long table breaks between two lines instead of through one; the last group keeps a **title with what follows it**: a price-table section title (`.qv-title-row`), a heading, a bold one-line paragraph used as a title (legal articles, rich text). Measured: a price-table section title left alone at the foot of a page moves to the next page with its lines.
2. **Why a line title and never an html block.** A line title renders in the page's root document, where `@page` reaches the printed page. An `html` block is isolated from the printed page (a sandboxed iframe in the client view, a shadow DOM in the PDF export): a `@page` rule inside it never reaches the printed page, and the PDF stays Letter. This does not relax the inline-first rule above, which is about html blocks and still stands. The rule is print-only: nothing changes on the web page, and the section title keeps its exact look.
3. **Exactly one rule per quotation.** On a rework, replace the existing `<style>…@page…</style>` rather than adding a second one. A **hidden** title line (`hide: 1`) does not work: hidden means not rendered, so the rule never reaches the page.
4. **Selectors come from the print DOM, not the client view.** When printing, each block is a `<div data-block-type="…">`, and the client view's own layout classes do not exist there. A rule written on a class read in the client view matches nothing, silently, and the PDF comes out unchanged, which looks like "CSS does not work here" when it is only a wrong selector. Use the rule above as is.
4bis. **Titles inside html blocks need their own, inline protection.** When printing, an html block renders inside a **shadow DOM**, so the page-level rule above never matches anything in it, whatever the selector. Inline styles do apply there. So every section head in an html block carries `break-inside:avoid;page-break-inside:avoid;break-after:avoid;page-break-after:avoid` on its wrapper, with no empty spacer between it and its content (or the spacer carries `break-after:avoid` too), as in **HTML blocks** above. A legal article title written as a styled paragraph (`<p style="font-weight:600">8. Exclusivity</p>`) carries `break-after:avoid` inline as well.
5. **A quote with no price table** has no title line. The native `legalnotice` block's `other` field also renders in the root document and carries the same `<style>` (measured by REST; through the connector it is `update_quotation_block` with the complete `data`, then verify, step 7).
6. **An existing quote the client already has**: add the rule only when the user asks for it. It touches one line title and nothing visible, but it is still a write on a live quote.
7. **Verify, always.** Download the PDF from the route above and read the page size: **595 × 842 pt on every page** (210 × 297 mm), and text no longer touching the edge. An unchanged export means the rule did not land, never that it was useless. Look at the pages too: each section whole, the totals with their table, and **the last lines of every page**: a title, a kicker or an article heading there, with its content on the next page, is a defect to fix before delivery. If you cannot fetch or measure a PDF, ask the user to download it and check the page size in their viewer, and say you have not seen it. A block taller than one page still breaks; that is expected.
8. **Copy that points at the screen is false on paper.** "The button at the top right of this page" or "click here" describes nothing in a PDF. Word it so it holds in both: "the comment button beside any section of the online proposal".

## Prices, totals and currencies

The native table exposes only ONE total and the platform can rescale amounts through a change rate.

- ⚠️ Recurring amounts (subscriptions) in an HTML recap block, never in the native table; one single total per quote (otherwise the total is nonsensical).
- Do not rely on the `option` flag on a line to keep a mandatory amount out of the total (it displays an unsuitable "Option not included" badge; French deals: « Option non incluse »).
- Foreign currency through the deal's cosmetic formatting (`displayCurrencyFormat`: symbol, position, separators), without changing the real currency; enter the amounts natively in the target currency. ⚠️ The connector has no such argument on `create_deal`/`update_deal`: set it by REST when a key is configured, otherwise state that the formatting stays the account default.
- ⚠️ Deal in a currency different from the account: edit the amounts line by line (`update_quotation_line`, one call per line — never a whole-quotation rewrite), keep the totals STATIC in the HTML blocks without reading the amount returned by the API, re-check after every write — the change rate rescales silently.

## Content and copy

- ⚠️ Never use an em dash "—" anywhere (the server truncates a `productTitle` at the em dash); prefer ":", ";", "·" or the comma.
  ⚠️ **Floor of the rule: it targets U+2014 "—" and U+2013 "–", never the ASCII hyphen of compound words.** Keep « ce soir-là », « ci-dessus », « c'est-à-dire », « quarante-huit ». A too-broad de-hyphenation pass has already shipped those as spelling errors on a live client page.
- Real logo files (official SVG/PNG), never a brand name typed as styled text; do not stretch, distort, recolor or rotate it.
- A real photo of the sales rep (square portrait centered on the face) in the sender card; an initials monogram as a last resort, never a fabricated face.
- No `{{...}}` placeholder and no unreplaced generic content on the client page.
- ⚠️ Accented characters as literal UTF-8 everywhere; never HTML entities in a plain text field (an `&eacute;` shows up literally).
- Line descriptions in the native table: curly apostrophe, not straight (the PDF does not draw the straight apostrophe).
- Cover image used only once; no reuse in a gallery or on a product line.
- One single accent color reserved for details; only the colors and fonts of the validated design system.
- No disclaimer-style captions under images (they weigh the page down and cheapen the premium rendering).

## Process and API write guardrails

The server state is authoritative; writes are partial or destructive.

- ⚠️ Always a NEW deal and a NEW quotation (`create_deal` then `create_quotation`); never modify, delete, archive nor clone an existing deal, quote, user or media. A rework is isolated on a new deal, the old version stays intact. The connector has no clone and no hard-delete tool for them anyway: do not route around that in the app either.
- Never overwrite the account's global settings (name, currency, logo, banner, taxes, numbering — the `update_company` and `update_numbering_setting` territory): all customization goes into the new deal (the account is shared between senders and quotes).
- Have the design (structure, data, colors, font, tone) validated by the human BEFORE generating any HTML.
- Server state (the quotation JSON) = the only source of truth: re-read it before any edit (`get_quotation` for the inventory of `blocks[]`, `get_quotation_block` for one block), re-check after every write.
- Surgical edits block by block (`replace_quotation_block_text` for long html/wysiwyg, `update_quotation_line` line by line); ⚠️ never rewrite the whole `blocks` array blindly (it wipes the sales rep's manual edits) — re-read and merge. The connector offers no such bulk write; keep the same discipline when falling back to REST.
- Block update: send back the COMPLETE `data` object to `update_quotation_block` (merge is shallow at root); `customFields`: re-read then send back every section (sending replaces the whole object), or edit a long value in place with `replace_quotation_custom_field`.
- A targeted text replacement with no target found (0 replacements) = a conflict: re-read the server state and restart from the server version, never force. Anchors must be short and unique — there is no `replace_all`, no regex, no occurrence index and no dry-run on `replace_quotation_block_text` / `replace_quotation_custom_field`.
- ⚠️ Never leave a Duodeal editor tab open on the deal during API writes: auto-save rewrites with its in-memory copy and cancels the work done through the API.
- Before any write, check that the connector is connected to the target account (`get_current_user`); a 403 on a known deal signals a connection to the wrong account, not a rate limit.
- Do not read a large `get_quotation` response raw (~80 KB): save it, then parse it.
- Never expose the connection credentials (API key, token): neither displayed nor logged.
- Validate a block only after a real rendering (PDF export + live web view) and a test of each block in isolation at a real mobile width, never on code reading alone. ⚠️ No tool returns the rendered page: open the customer link in a browser, or say plainly that you have not seen the render and ask the user to check.
- Deliver both links, rebuilt by hand from the ids (there is no links tool): customer link `https://duodeal.app/quotations/deal/{deal.uid}` from the `uid` of `get_deal`, edit link `https://duodeal.app/app/quotations/{dealId}/{quotationId}` whose `{dealId}` is the **real parent deal read on `get_quotation` → `deal.id`** (never a deal id from memory or from another quote). ⚠️ `/app/deals/{dealId}/{quotationId}` is the V1 editor, never deliver it. Sending the quote by email and creating a share link are not connector operations (`shareLinks` comes back read-only in `get_quotation`): the user sends from the app.
- ⚠️ Some steps have **no connector tool at all**: primary flag, `builderVersion`, deal language/currency and `displayCurrencyFormat`, line medias, creating a tax, a unity, a status, a template or a user, cloning, hard-deleting, sending. Do not simulate them and do not invent a tool name — `api_call`, `get_links`, `upload_media` (it is `create_media`), `get_me` (it is `get_current_user`), `ensure_template`, `create_tax`, `create_unity`, `clone_deal`, `clone_quotation`, `list_quotation_blocks`, `send_quotation` do not exist. Take the REST API with `X-API-KEY` only if a key is already configured in the environment, otherwise hand the step to the user in the Duodeal interface and state it in the delivery.
- If an interactive HTML block sends data outside the platform, flag it; never present hosting, compliance or "real-time" validation as operational on the client page (demo validations are only format checks).

