Build a Duodeal quote from A to Z
🔄 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.
Proven sequence, written as what to obtain, with the tool in parentheses when one
exists. Tool names are authoritative in duodeal-api-reference → references/connector-tools.md:
if a step needs something the connector does not expose, it says so and gives the way out
(REST with X-API-KEY only if a key is already configured, otherwise the Duodeal
interface — and tell the user it is pending).
Two starting points: from scratch (§1) or from an existing reference quote (§2 —
prefer this when one exists). For a visually premium quote (design selling page, polished
HTML blocks), chain the duodeal-quote-design skill after the creation step.
0. Before anything
- Know which tenant you are writing to, and say it out loud (
get_current_user, thenget_companyfor the details). Writes: test/demo account only. - Collect the ids of THIS tenant before any line: taxes and units (
list_taxes,list_unities), the quotation statuses (list_quotation_statuses— you need the Draft id for step 7) and the price categories (list_price_categories— the default one for step 7, all of them if there is a catalog). Never reuse the ids of another account (cause #1 of 400s). Rates come back as decimals (0.20 = 20 %).
1. Full from-scratch flow
1. Know the tenant (get_current_user)
2. Hold this tenant's ids (list_taxes, list_unities,
list_quotation_statuses, list_price_categories)
3. Have the client organization (create_customer_company {name})
4. Have the contact (create_customer {customer_company_id,
first_name, last_name, email})
5. Have the deal (create_deal {name, customer_id})
6. Have ONE quotation on that deal (list_quotations {deal_id} to see what exists,
create_quotation {deal_id, title, valid_until}
if there is none — never assume one was
auto-created)
7. Refine the quotation (update_quotation {id, title, valid_until,
status_id, price_category_id, customFields}: a
created quotation is born with NO status and no
price category, and a null status reads as
broken), then CHECK the primary flag: get_deal →
quotations[].primaryQuotation must be true, it
is born null too (§5)
8. Fill the price table (add_quotation_lines {quotation_id, lines[]} as
soon as there are 2+ lines, create_quotation_line
for a single one)
9. Attach T&Cs + legal notice (no template tool on the connector — see the last
section)
10. Hold the 2 delivery links (rebuild them from get_deal + get_quotation — §5)
There is no get_links tool and no links field to rely on in the create_deal
result: both links are rebuilt by hand from the deal uid / id and the quotation id (§5).
Lines — rules
weightis mandatory and increasing (= display order).- Typical structure: a
titleline (section separator, inline HTML accepted:<p><span style="font-size:18px;">Included in our offer</span></p>), thennormallines (productTitle,unitPrice,quantity,unity_id, HTMLdescription— required onlineType: "normal"), thensubtotal. - Every line carries a
tax_id, including thetitleandsubtotalones (otherwise 400). - The FIRST
titleline also carries the print rule, so the PDF exports in A4 with margins instead of US Letter: prepend the<style>from duodeal-mcp-best-practices → PDF export to that line'stitlewhen you create it, then check the export's page size before delivering. - Discounts: a
normalline with a negativeunitPrice, ORdiscount+discountType(percentage/amount) on the line —lineType: "discount"does not exist. option: true→ "Option not included" badge (French deals: « Option non incluse »), excluded from the total.- When the sender's loudest promise is transparency ("no hidden fees"), the price table IS the demonstration: quote in the unit THEY publish (43/week × 52 on one row, so the reader can divide), and give every included service a real row at 0.00 — a number can be audited, an adjective cannot. Confirm each zero line with the user: a 0.00 row is a deliberate gift, not an oversight.
- An image on EVERY product line (blocking checklist item of duodeal-quote-design), on
the media of the LINE, square and centered on the subject. ⚠️ No media argument exists on
the line tools, nor on the product tools (
urlon a product is an external link, not an image). Store the file (⚠️ reuse an existing media first (list_medias);filein 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 adata:URI (see duodeal-mcp-best-practices → images);create_media {name, folder, file}, neverfrom_url, it 500s on most CDNs), then bind it to the line by RESTPOST|PUT /quotation-lines {medias: [{id}]}if a key is already configured, otherwise attach it in the Duodeal interface and say the images are still pending. Failing that, an image can only be shown in a V2 block (gallery.images,attachments,header.cover) viaupdate_quotation_block. Say which route you took. - V2 quotes (blocks): pass the pricing block's
blockId(camelCase, inside the line) so the line lands in the right table — see the duodeal-v2-blocks skill.
Quotation branding
- Get the logo / cover stored (⚠️ reuse an existing media first (
list_medias);filein 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 adata:URI (see duodeal-mcp-best-practices → images);create_media {name, folder, file}, neverfrom_url) and then shown:update_quotationhas nologo/coverargument on the connector → put the media in the nativeheaderblock (update_quotation_blockwith the completedata), or set it via REST / the interface. noLogo/noCover: trueonly if there is NOTHING to show (otherwise conflict) — they areheaderblock fields, sent inside itsdata, not quotation arguments.
2. Start from an existing reference quote (recommended when one exists)
1. Identify the reference deal (list_deals {search: "..."} — there is NO template
flag or template tool on the connector: confirm
with the user which deal is the reference, and
re-check what you got)
2. Read the source completely (get_deal, get_quotation → blocks[] with every id
and type, list_quotation_lines)
3. Rebuild on a NEW deal (no clone_deal / clone_quotation on the connector:
create_deal → create_quotation →
add_quotation_lines → per block
add_quotation_block {type, position} then
update_quotation_block with the read data.
Alternative: duplicate in the Duodeal interface
and read the copy back with get_quotation)
4. Customize (update_deal {id, name, customer_id},
update_quotation, block tools —
replace_quotation_block_text for large html/wysiwyg)
5. Deliver (rebuild the 2 links — §5)
⚠️ A clone inherits the identity of its source. The owner avatar, the quotation title (the YEAR!), gallery media and custom fields all travel with the copy. Audit them before showing a clone-based build: a photo of someone else is worse than no photo, and a rep's verdict on an inherited avatar was "the sales contact is completely wrong". Delivery test: would this page embarrass the client if they sent it tomorrow, unedited, to a real customer of theirs? A demo mailbox address or the wrong person's face fails it instantly.
2nd quotation on an existing deal: there is no clone_quotation. Create it
(create_quotation {deal_id} — deal_id is required, a REST POST /quotations without a
deal fails 500), then copy the content block by block and re-run add_quotation_lines, then
update_quotation on the new one (title…). The primary flag is not settable through the
connector, and every quotation you create is born without it — see §5 before delivering.
3. Product catalog (if requested)
Have the price tiers (create_price_category {name, by_default} — 1 category per
volume tier; list_price_categories first, do not recreate)
Have the product record (create_product {name, reference, description, ...})
Have its price (create_product_price {product_id, price_category_id,
tax_id, price} — the four are required, only 1 price per
product × category pair; delete_product_price is a HARD delete)
Then bind them to the lines: product_id on the line. The productPrice{id} reference
exists on POST|PUT /quotation-lines (REST) but is not a connector argument — through the
connector the amount charged is the line's own unitPrice, so read the catalogue price
(list_product_prices) and carry it over yourself.
4. Custom fields (structured data)
- Have the definition (
list_custom_fieldsfirst, thencreate_custom_field {name, type, scope}— the three are required on create,labelrecommended; scope = deal / customer / product / quotation). - Have the value on the quotation (
update_quotation {id, customFields: {key: value}}, key = the field name). Read the current dict first and resend it merged: a partialcustomFieldscan overwrite the rest. For a large text value usereplace_quotation_custom_field {quotation_id, field, search+replace}instead of rewriting everything. - Display it in the quote: via the V2
customfieldsblock (list of CF names) — see duodeal-v2-blocks.
5. Delivery — always the 2 links
Primary quotation — check it on every deal you built, before delivering (§1 and §2 alike).
A quotation created with create_quotation (connector) or POST /quotations (REST) is born with
primaryQuotation: null, even the first and only quotation of a new deal: the deal's primaryQuotationId is filled, the
quotation's own flag is not. The app's deals table lists deals through their primary
quotation (status, amount, Hot Deal Score and number all come from it), so a deal without one
is missing from the table: it exists, the API returns it, the client link works, and the rep
still cannot find it. "A deal's first quotation is primary by default" is only guaranteed for deals
created in the app: check, never assume.
- Read
get_deal {id}→quotations[].primaryQuotation: one quotation of the deal must saytrue. - If it is not
true: a REST key already configured →PUT /quotations/{id} {"primaryQuotation": true}, then readget_dealagain. No key → the connector has no argument for it: tell the user plainly, before delivering, that the deal will not appear in their deals table until the quotation is set as primary (in the Duodeal interface, or by the Duodeal team), and list it as pending. - A deal with several quotations (§2): ask the user which one should be primary before switching anything.
No get_links tool exists: read the ids (get_deal {id} → deal uid + id,
get_quotation {id} or list_quotations {deal_id} → quotation id) and build both links:
- client link
https://duodeal.app/quotations/deal/{deal.uid}— the selling page sent to the prospect (default link, nothing to generate) - edit link
https://duodeal.app/app/quotations/{dealId}/{quotationId}— the internal V2 editor (⚠️ never/app/deals/…, that is the V1 editor)
⚠️ The {dealId} in that URL must be the real parent deal of the quotation: read it back on
get_quotation {id} → deal.id, and never paste a deal id from memory or from another quote.
A wrong dealId opens someone else's deal or a blank editor.
Alternative sharing: V2 share link https://duodeal.app/quotations/share/{shareUuid}
(filtered view of the blocks) — usable only if get_quotation already returns a
shareLinks entry; the connector cannot create one, and it cannot send the quote by email
either (do it from the app).
5bis. Take the deal out of Draft when you hand it over
A deal created through the API is born Draft and stays there. Draft should mean not yet sent
out: when it also holds everything already delivered, the rep's pile stops meaning anything.
So at handoff — whether the rep sends the quote as is or edits it first — move the deal AND its
quotation to the deal-sent status:
- Status ids are per company: resolve them (
list_deal_statuses,list_quotation_statuses, take the one whoseonAction == "deal-sent"), never hard-code an id seen on another account. - Deal and quotation:
update_deal {id, status_id}/update_quotation {id, status_id}. ⚠️ If you go through REST on the deal instead, resendusers(see duodeal-mcp-best-practices → sender identity). - ⚠️ The quotation also carries a
send: trueboolean, and the status alone leaves it in the "to be sent" view.sendis not a connector argument: set it by REST (PUT /quotations/{id} {"send": true}) only if a key is already configured, otherwise hand that step to the user in the interface and say it is pending. - Deliberately left in
Draft: internal templates, technical drafts, test fixtures, and any quote nobody could send as it stands.
6. Final check
- Re-read the quote from the server (
get_quotation {id}— carriesblocks[]andshareLinks). - Check order (
weight), totals and options on the price table (list_quotation_lines {quotation_id}). - A quote is only "done" after a real visual check. No connector tool renders the page or a block preview — if you cannot look yourself, say so and ask the user to look. Never claim a render you have not seen.
- ⚠️ Opening the client link is recorded as a prospect visit. The client view posts a
visiton load and aheartbeatevery 15 s to/api/access-sessions(browserfetch; a plain server-side GET counts nothing). Your own check then reads as a highly engaged buyer and inflates the Hot Deal Score of the very deal whose re-opens drive the follow-up. There is no internal-view opt-out today. Verify on the edit link and the PDF export wherever they can show it, and ask the user before opening the client view; if you open it, say so in your delivery.
T&Cs / legal notice / email templates
No template tool on the connector (no ensure_template, no list_templates): reusable
T&Cs, legal notices and email templates are created in the Duodeal interface (Settings), then
read back on the quotation. On the quote itself, the legal text goes in the native
legalnotice block (add_quotation_block {type: "legalnotice"} then
update_quotation_block with the complete data) — update_quotation has no
legalNoticeText argument. Variables in email templates are written in square brackets:
[client.firstName], [client.fullName], [client.lastName], [deal.link],
[sales.firstName], [sales.fullName], [sales.jobTitle], [company.name] (measured on the
platform's own default template; the {{…}} form found in older docs is not substituted).
The link to the page is not added by itself: place [deal.link] on its own line. Email templates
carry subject + byDefaultSendDeal and are set up in the interface only. If a REST key is already
configured, that write can go through X-API-KEY; otherwise hand the step to the user and
flag it as pending. Never invent a T&C or a legal notice the client did not provide.