From "can I get pricing?" to a signed PDF in fifteen minutes
The customer quote workflow is the most-run flow on the platform. Customers email or call asking for pricing on assemblies, often by their own internal SKU. The AI gathers context (who they are, what they typically buy, current cost basis, AR status), proposes line-by-line pricing, Mike reviews, the system renders an HTML artifact + PDF, and the email goes out.
The contract is stored in workflow_definitions WHERE workflow_type='draft_quote' — risk level 2 (low-med), expected duration 15 minutes, three HITL gates at steps 1, 2, and 4.
The defining property: nothing about pricing is invented. Every line traces to pricing_master, assembly_cost_rollup, or an explicit override that Mike typed into chat. The AI narrates; the database decides.
Trigger conditions
- Customer emails
quotes@ororders@asking for pricing on an assembly. - Sales call wraps up and Mike needs to send a follow-up quote.
- RFP comes in via the intake page (Dropbox or upload) and needs a structured response.
- Existing customer wants pricing on a new pack/format of an item they already buy.
- Mike wants to test a price point before formalizing it into the pricing_master.
Customer Z requests pricing on assembly A123
Cardinal Foods (NS customer #1843) emails: "Can you quote A123 — Right Start Foods Breakfast Burrito assembly — for 5,000 cases monthly starting July?"
Mike opens chat, types "Draft quote: Cardinal, A123, 5,000 cases/mo July onwards". The draft_quote workflow starts. Within 90 seconds the AI has pulled Cardinal's customer 360 (30-day order history, AR aging — they're current, their typical bid eligibility), looked up A123's current cost rollup ($3.42 landed), proposed $4.18 per case (22% gross margin per Mike's standard), and rendered a side-by-side comparison.
Mike skims, taps Approve lines, then Approve send. The HTML quote artifact lands at /quote/Q2026-0512, the PDF goes into R2, and the email lands in Cardinal's inbox with a tracked link. Total wall-clock: 11 minutes.
The five beats
-
01
Gather context (auto)
The
get_customer_360chat tool pulls the customer record from the D1 mirror, the trailing-90-day order history, current AR aging buckets, and any flags fromcustomer_health_scores. This becomes the prompt context for the line-builder. -
02
Build lines (HITL)
propose_quotelooks up each assembly's cost inassembly_cost_rollup, applies the customer's negotiated margin band (or the platform default if none), and writes draft rows toquote_lineswithstatus='draft'. A proposed_action row lands in Mike's queue. -
03
Approve lines (HITL)
Mike taps Approve in
/proposed-actions.html. The quote rows flip tostatus='approved'and the atomic claim (R560 pattern) prevents double-approval. Mike can edit individual lines before approving — those edits write back to the samequote_linesrows. -
04
Render PDF (auto)
render_quote_pdfgenerates the HTML artifact (Pages Function at/quote/) and converts to PDF via Browser Rendering. Both land in R2 underquotes/. The HTML link is what gets put in the email body./ -
05
Send to customer (HITL)
A draft email row in
outbound_email_logwithstatus='pending_review'. Mike skims, taps Send, the email goes out via Email Routing. Eventquote.sentis appended to the events ledger. The customer-facing tracked link is logged for open/click tracking.
What's different after the workflow runs
- A row in
quoteswith statussentand a link to the HTML artifact at/quote/. - Per-line audit trail in
quote_linestying back topricing_masterandassembly_cost_rollup. - An outbound email row with
status='sent', message ID, and tracked open/click links. - An event
quote.sentin the events ledger so customer health + sales velocity watchers see it. - Mike's chat history retains the full transcript for next-time recall.
What can go wrong and how to recover
If assembly_cost_rollup doesn't have a row for the requested assembly, the proposer refuses to invent a margin. Mike sees a "no cost basis" error and has to add the cost first (vendor cost update workflow).
If the customer's customer_health_scores.risk_tier is red, the workflow surfaces a warning band but doesn't block. Mike decides; this is a soft guardrail not a hard one.
Browser Rendering occasionally hits 30s. The HTML artifact still works — Mike can send just the link. Re-trigger PDF via POST /admin/quotes/.
Most common quiet failure. The outbound_email_log dashboard has a "pending_review > 24h" filter to catch these. Daily 9am digest highlights them too.
Adjacent workflows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow contract | workflow_definitions WHERE workflow_type='draft_quote' |
| Customer 360 | src/chat_tools/impls.ts get_customer_360 |
| Line builder | src/chat_tools/impls.ts propose_quote |
| PDF render | render_quote_pdf — Browser Rendering binding |
| HTML artifact | pages-functions/quote/[id].ts |
| HITL gates | steps 1, 2, 4 — see hitl_gates_json |
| Cost rollup invariant | never invent margin — refuse if assembly_cost_rollup empty |
| Email send | Cloudflare Email Routing — outbound_email_log first |
Dated trail · spot stale claims
Dated trail of when this doc was last touched, what changed, and what to look at if it feels stale.
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R586 | Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation. | Mike + Claude |
2026-05-25 | R584/R585 | Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers). | Mike + Claude |
workflow_definitions WHERE workflow_type='customer_quote' before acting on these claims.The machine-readable spec
Canonical fields, table names, endpoint signatures. What code should match, what tests should assert. workflow_type · customer_quote · risk_level · 2.
Inputs (required + optional)
| Field | Type | Description |
|---|---|---|
customer_id | integer | NS customer internal ID. Required. |
items | json | Array of {item_id, qty, requested_price?}. Required. |
effective_date | date | Quote effective date; defaults to today. |
bid_context | string? | Optional bid_id if quote is bid-anchored. |
D1 tables written
| Table | Operation | Trigger |
|---|---|---|
customer_quotes | INSERT | Quote header row |
customer_quote_lines | INSERT (one per item) | Per-line pricing snapshot |
proposed_actions | INSERT | If any line price departs from pricing_master |
outbound_email_log | INSERT (status=pending_review) | Quote PDF email draft |
events | INSERT (quote.generated) | Hub + analytics subscribers |
Endpoints called
| Method | Path | Purpose |
|---|---|---|
POST | /api/quote/create | Quote creation endpoint |
GET | /quote/:id | Live customer-facing quote page |
POST | /admin/quote/:id/regen-pdf | Manual PDF regeneration |
Events fired
| event_type | When | Subscribers |
|---|---|---|
quote.generated | After all lines settle | customer_health, analytics |
quote.sent | Email approved + sent | follow-up scheduler |
It broke at 2am — what now
Different from "how do I use this." This is the page Mike pulls up when something is wrong: logs to check, recovery steps, who to escalate to.
Scenario · Quote shows a price different from pricing_master
pricing_master may have updated AFTER the quote snapshot. Quotes are snapshots at creation time.
- Check timestamps:
SELECT created_at FROM customer_quotes WHERE id=<id>vsSELECT last_updated FROM pricing_master WHERE item_id=? - If quote was created before the update: Working as designed. Tell the customer the quote was a snapshot.
- If after: Bug — investigate the snapshot logic in
src/chat_tools/impls.tsdraft_quote tool.
Scenario · Quote PDF won't render — Browser Rendering 500
Usually a malformed line (missing item_name) or template syntax error.
- Check the lines:
SELECT * FROM customer_quote_lines WHERE quote_id=<id> AND (item_name IS NULL OR price IS NULL) - Patch: Fill missing fields from items table, then
POST /admin/quote/<id>/regen-pdf - Long-term: Tighten the CHECK constraint on customer_quote_lines.
Scenario · Customer never received the quote email
outbound_email_log row likely stuck in pending_review.
- Check:
SELECT status, approved_at, sent_at FROM outbound_email_log WHERE entity_ref='quote:<id>' - Recover: If pending_review: Mike approves. If approved but not sent: check email-routing logs in
wrangler tail.
Logs to check
workflow_run_log· top-level run auditworkflow_step_log· per-step traceworkflow_verify_results· post-window verify outcomescron_locks· stuck cron lock detectionevents· workflow.completed / workflow.failed event trailreflexion_log· per-run narrative (if reflexion_enabled)npx wrangler tail· live Worker logs
Kill switch · emergency stop
If this workflow is misbehaving in a high-impact way (creating bad proposed_actions in volume, pushing wrong things to NS), flip a kill switch:
kill:ns_writes· stops every NS push platform-widekill:proposed_apply· stops HITL approvals from executing fan-outkill:high_risk_ops· stops risk_level >= 4 fan-out
See kill-switches-state-machine.html for the full state machine + recovery procedure.
Escalation
Primary: Mike Levine (single-admin) · mikelevine@globalfoodsolutions.co. For prolonged outage during business hours, notify warehouse lead + accounting lead so they can defer dependent work.
What's not done · what's uncertain
What's not done, what's uncertain, what we punted. Captured so it survives context switches and doesn't die in someone's head.
-
STUB
Auto-rollover quotes (90-day expiry)
Today quotes don't auto-expire; they just sit. Need a cron that flags quotes >90 days old and emails Mike to refresh or close.
-
OPEN
Should quote acceptance trigger a sales order automatically?
Today acceptance is silent — Mike still creates the SO. Could be auto-staged as proposed_action with action_type='create_sales_order'.
-
DECISION
Per-customer quote templates
Schools want different headers than commercial. Today one template; need a customer_quote_template field.
-
STUB
Quote analytics (acceptance rate, time-to-decision)
Events fire but no dashboard reads them yet.