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 …inDUODEAL-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.
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.
- Native header filled in with the sender's logo + cover, never hidden nor recoded in HTML.
- Native contacts block present, sender (dedicated, named owner) AND recipient filled in.
- Every block carries a non-empty
title(otherwise the interface displays "html" instead of the title). - 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_producthave none either. Upload withcreate_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. - Every HTML block ends with
DuoDeal.autoResize()and stays presentable once its<style>tags are stripped (everything styled inline, no decorative<script>). builderVersion2 enabled, language and currency set at deal level, without touching the account settings. ⚠️ The connector exposes none of the three (nobuilderVersionargument anywhere;create_deal/update_dealaccept no language and no currency): check what you have on thebuilderVersionfield inget_quotation(builderVersion == 2, not the mere presence ofblocks[]— blocks can sit on abuilderVersion: 1quote, which then opens in the old V1 editor) — and set what is missing through REST or in the app, saying which.- 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. - 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 thetitleof 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 thetitleand setshowTitle:false. - An image on every product line, on the media of the LINE, square, centered on the subject (
object-fit: cover); upload it withcreate_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 andcreate_deal/update_dealtake no owner — assign it in the app (or by REST) and say so. - ⚠️ On a REST
PUT /deals/{id}, always resendusers. A partial PUT resetsdeal.usersto the API key's own owner, anddeal.usersdrives 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 itsusers, 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_dealhave neither, and currency exists only company-wide viaupdate_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
legalnoticeblock, 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
acceptblock (or enabledata.showAcceptButtonon thepricingblock), add asignstampblock next to it. Once the quote is signed theacceptbutton disappears, andsignstampis 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,signstamprenders nothing on the client side (dashed placeholder in the editor only), so adding it costs nothing. - Switch every quote to
builderVersion2 as soon as it is created. ⚠️ No connector argument selects it: confirm the state withget_quotationon thebuilderVersionfield (builderVersion == 2). ⚠️ Having blocks proves nothing: abuilderVersion: 1quotation 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) orPOST /quotations(REST) is born withprimaryQuotation: 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 aftercreate_quotation, readget_deal→quotations[].primaryQuotation; if it is nottrue, 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-faceexception — see the font rule below), no decorative<script>(presentation interactivity through inlineonclick). A block that reads the quote or collects client data keeps itsDuoDeallogic 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(nevergrid-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 atry/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
onUpdatenever 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-faceon a media url (fonts are not in the accepted MIME list) — and every@font-faceroute 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+xmlis 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:
- Reuse what is already there (
list_medias,search) before uploading anything. create_media{name,folder,filein base64} — this is the normal, supported route. Base64 is how the API takes a file: there is no multipart, no local path and noupload_mediatool. Ceiling ~4 MB, nothing resizes for you.from_url— never, whatever the tool description says: the URL import 500s on most CDNs.- Then reference the url the media returns inside the block's
data.
- Reuse what is already there (
- 🚫 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-boxon 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 honourbreak-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 ownmargin-bottom, or give the spacerbreak-after:avoidtoo. Withoutbreak-inside:avoidon 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, novh, 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), nobox-shadowon 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:0on the text column,overflow-wrap:anywhereon long words/emails, stack into a column on narrow screens. - Cap wide images on mobile through
width/max-width, nevertransform:scale(it does not reduce the layout width). - Table with an incompressible minimum width inside an
overflow-x:autocontainer, or make it fit under ~360 px. - Covers/full-bleed at
width:100%andborder-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:containon a white background, nevercover(which crops and zooms). - Video: check that the embed is allowed, opaque branded poster over an
about:blankiframe, 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:
The carrier: the
titleof the FIRSTlineType:"title"line of the price table. Write it with the connector,update_quotation_line {id, title}on an existing line, ortitleincreate_quotation_line/add_quotation_linesat 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.
@pagesets 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.Why a line title and never an html block. A line title renders in the page's root document, where
@pagereaches the printed page. Anhtmlblock is isolated from the printed page (a sandboxed iframe in the client view, a shadow DOM in the PDF export): a@pagerule 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.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.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 carriesbreak-inside:avoid;page-break-inside:avoid;break-after:avoid;page-break-after:avoidon its wrapper, with no empty spacer between it and its content (or the spacer carriesbreak-after:avoidtoo), as in HTML blocks above. A legal article title written as a styled paragraph (<p style="font-weight:600">8. Exclusivity</p>) carriesbreak-after:avoidinline as well.A quote with no price table has no title line. The native
legalnoticeblock'sotherfield also renders in the root document and carries the same<style>(measured by REST; through the connector it isupdate_quotation_blockwith the completedata, then verify, step 7).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.
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.
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
optionflag 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 oncreate_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
productTitleat 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
é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_dealthencreate_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_companyandupdate_numbering_settingterritory): 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_quotationfor the inventory ofblocks[],get_quotation_blockfor one block), re-check after every write. - Surgical edits block by block (
replace_quotation_block_textfor long html/wysiwyg,update_quotation_lineline by line); ⚠️ never rewrite the wholeblocksarray 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
dataobject toupdate_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 withreplace_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 onreplace_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_quotationresponse 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 theuidofget_deal, edit linkhttps://duodeal.app/app/quotations/{dealId}/{quotationId}whose{dealId}is the real parent deal read onget_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 (shareLinkscomes back read-only inget_quotation): the user sends from the app. - ⚠️ Some steps have no connector tool at all: primary flag,
builderVersion, deal language/currency anddisplayCurrencyFormat, 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 iscreate_media),get_me(it isget_current_user),ensure_template,create_tax,create_unity,clone_deal,clone_quotation,list_quotation_blocks,send_quotationdo not exist. Take the REST API withX-API-KEYonly 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).