Facilities-maintenance quoting autopilot: it doesn't just price the job — it rehearses the negotiation.
Every quoting tool tells you what you should charge. This one also works out what the client is willing to pay, writes a justification they can say yes to, and then war-games how they'll push back — before you ever walk into the room.
Thesis: AI preps the fight. You close it. The agent runs the rehearsal; a human makes every decision that matters. The two approval gates aren't a UX afterthought — they're the architecture.
I'm not a developer who picked a domain. I run a facilities-maintenance company and quote these jobs myself. I've gone through thousands of maintenance invoices by hand and watched a six-figure profit leak hide in quotes priced a little too low, a little too fast. The hard part of quoting was never the arithmetic — it's pricing the haggle under thin margins, where one underpriced recurring contract compounds into a loss. So I built the quoting agent I actually needed. (All data in this repo is 100% synthetic — fictional clients, fictional prices.)
① Work order comes in (e.g. walk-in refrigerator down, store losing cold chain)
② Two-anchor pricing vertical = market going-rate (the floor)
horizontal = THIS client's willingness to pay,
inferred from their own deal history (the ceiling)
+ a client-facing justification written for this exact job
🔶 GATE 1 — human approves / adjusts price / rewrites pitch
③ Battle rehearsal (self-play) the agent ARGUES BOTH SIDES: a client agent
attacks the price, a contractor agent defends it,
turn by turn on screen — then the bout is distilled
into the 2-3 strongest attacks and your counters
🔶 GATE 2 — human keeps the lines worth keeping
④ Output a defensible quote + a negotiation battle card
⑤ The loop deal won/lost (+ why) writes BACK to the client's
history → the next quote prices against it
⑥ The playbook every line you keep is distilled into a reusable
negotiation principle — cross-deal craft, not per-deal state
→ and briefed into the NEXT rehearsal, which cites
the moves it used (📖 "From your playbook")
Outcomes aren't logged into a void. A recorded loss ("exceeded approved budget") becomes a history row that the very next quote for that client cites — you can watch the deal counter climb and the pricing rationale change. The agent gets sharper with every deal, per client.
LLM temperature means the "same" quote can drift between runs. In a real business that's intolerable: a client who sees two different prices stops trusting both, and internal records stop being auditable. So every generated quote is frozen to the database — reopening a work order returns the exact same quote, instantly. A new roll requires an explicit Regenerate. One mechanism, four wins: consistency, auditability, token savings, and no 18-second wait on reopen.
This isn't asserted — it's measured. npm run eval:pricing replays the product's own prompt 3× per work order against the live model (docs/eval-pricing.md): 24/24 valid JSON, 24/24 prices inside the two anchors (the anchors drive the price — they're not decoration), but run-to-run price spread up to 18.8% on the same order. Consistent in placement, not in digits — which is exactly why the digits get frozen.
The same rule covers the human's decisions: the approved price and pitch and the battle rehearsal persist with the order. Reopen a work order and everything is sitting where you left it — the rehearsal is an artifact, not a session effect.
The engineering tradeoff — consistency as a state machine, not a frozen cell:
- The freeze is a chain, not a table. Three snapshots, each keyed tighter — the quote by work order, the rehearsal by work order + price, each kept play by + scenario. The rehearsal is content-addressed on the price it argued for: reopen at a price already seen and the cached bout returns instantly.
- Regenerate cascades — on purpose. A fresh quote DELETEs the battle and clears approval + outcome, because a rehearsal defending a price that no longer exists is worse than none. The rejected alternative — leaving stale artifacts lying around — is the one that silently rots an audit trail.
- Truth is kept out of the model. The "past N deals, M won, why" line that proves the loop feeds pricing is computed from DB rows, never generated; outcome write-back enforces three invariants (an approved quote must exist, no double-recording, a loss must carry its reason — the learning signal). The LLM is removed exactly where a wrong number would be a lie.
A fresh quote doesn't hide behind a loading line — it assembles itself on screen. The API routes stream the model's tokens over SSE, and the client parses the incomplete JSON on every delta (lib/partial-json.ts): the recommended price lands first (~2s), the two anchor cards pop in as they complete, then the justification types itself out. Numbers are held until they're settled — you never see $48 on its way to $4,800.
Two latency findings behind this (measured on this workload, scripts/probe-thinking.mjs):
| first token | full quote | |
|---|---|---|
| before: blocking call, default settings | — | ~20.5s white screen |
| streaming alone (thinking still on) | 19.1s | 20.5s — thinking is silent, so streaming bought almost nothing |
| streaming + thinking off for interactive calls | ~1.2s | ~3.6s |
Hybrid Qwen models think before they speak by default — 19s of a 20.5s answer was invisible reasoning. So thinking is disabled where a human is watching (quote, battle) and kept where latency is free (playbook distillation runs after the optimistic UI, so it spends its thinking budget on a better principle instead). Reopening a quoted order still skips the model entirely — frozen snapshots stay instant.
Structured outputs ride native JSON mode (response_format: json_object) — probed to compose with streaming and thinking-off on this endpoint (scripts/probe-jsonmode.mjs), so the quote, the battle-card summary, and the distilled principles are guaranteed-valid JSON instead of fence-stripping prayers. Self-play turns stay plain prose — they're spoken lines, not data.
Negotiation moves repeat across deals even when the words don't. So the lines you keep at gate 2 don't stay buried in one work order: each kept exchange is stored verbatim and distilled by the model into a one-line reusable principle ("Reframe a budget cap as cost-of-delay; concede on terms, never on rate"). They accumulate into a cross-deal playbook — the same memory system that learns your clients also grows you. Plays survive rehearsal regeneration on purpose: they're knowledge, not state. If distillation fails, the play is kept without the principle — never lost.
And the playbook feeds back. Every new rehearsal is briefed with your kept principles, and each counter cites the moves it actually used — visible on the battle card as 📖 "From your playbook". That closes the second memory loop: outcomes → client memory → sharper pricing; kept plays → playbook → sharper rehearsals. The model is told to never force a citation — a principle is applied only where it genuinely strengthens the counter. (At today's scale the whole playbook fits in the prompt; at real scale this brief becomes embedding retrieval of the most relevant plays — same loop, bigger library.)
One prompt asked to "imagine the haggle" produces plausible objections. A bout produces tested ones: a client agent (in character, armed with this client's real deal history) attacks the price; a contractor agent (briefed with your playbook) defends it — two exchanges, every turn streaming on screen. Only then does a third pass distill the transcript into the battle card, keeping the attack lines the client agent actually used and the counters that actually held. Each attack is labeled with the negotiation move behind it (anchor on a past deal, budget-cap squeeze, competitor leverage) — because moves repeat across deals even when the words change, which is the same observation the playbook is built on. In a measured run the client agent opened by quoting the client's own prior deal ("you did our last electrical job for $2,790 — why is this $160 more?") — that specificity is what an adversarial pass buys. The full bout is stored with the card: reopen the order and "View the self-play bout behind this card" replays the argument that produced your advice (~11s end-to-end, every token visible).
The engineering tradeoff — a creative process behind a deterministic interface:
- Sequential by necessity. Five model calls in order (client → contractor, ×2, then a distiller), each turn conditioned on the transcript so far — a dialogue can't be parallelized. The ~11s is paid on purpose: objections that survived a real exchange beat ones a single prompt imagined.
- A free-form bout, forced back into a fixed contract. The snapshot, the gate-2 keep, the 📖 citations, and the card UI were all built against a
scenariosJSON shape before self-play existed. So the bout runs hot (temperature 0.7, sound alive) and a final pass distills it back into that exact shape (temperature 0.4, native JSON mode) — adding adversarial multi-agent rehearsal didn't touch a single downstream consumer. - Citations validated server-side.
sanitizeRefslets a counter cite only a principle that was actually in its brief — "📖 From your playbook" can't be a hallucinated reference.
flowchart LR
subgraph Browser
UI["Next.js UI<br/>work orders · gates · battle card"]
end
subgraph "Next.js API routes (the agent)"
Q["/api/agent/quote<br/>two-anchor pricing"]
B["/api/agent/battle<br/>haggle rehearsal"]
A["/api/approve<br/>gate-1 writeback"]
O["/api/outcome<br/>win/loss writeback"]
P["/api/playbook<br/>keep + distill principle"]
end
LLM["Qwen Cloud (OpenAI-compatible)<br/>interactive: qwen3.6-flash, thinking off<br/>background: qwen3.7-plus, thinking on"]
DB[("PostgreSQL<br/>PolarDB · Alibaba Cloud<br/>history · benchmarks ·<br/>frozen snapshots · playbook")]
UI -->|"① quote (SSE stream)"| Q
UI -->|"🔶 gate 1"| A
UI -->|"③ rehearse (self-play ×2, SSE)"| B
UI -->|"🔶 gate 2: keep"| P
UI -->|"⑤ outcome"| O
Q --> LLM
B --> LLM
P -->|"distill the move"| LLM
Q <--> DB
B <--> DB
A --> DB
P <--> DB
O -->|"learning loop"| DB
DB -.->|"playbook briefs the rehearsal"| B
- No agent framework. The flow is staged API calls with a human gate between stages — that round-trip is the product's thesis in code form. A graph framework would bury the most important edges (the human ones) inside a library.
- Data access layer as a seam. Slices 1-3 ran on in-memory synthetic data; slice 4 swapped the accessors to PostgreSQL without touching a single API route or UI component.
- Docker Postgres in dev; PolarDB for PostgreSQL on Alibaba Cloud for the deployed build — same connection string mechanism, one env line, zero code change.
| Model | Latency (this workload) | Output quality |
|---|---|---|
| qwen3.7-plus | ~37.5s | good — occasionally more precise numbers |
| qwen3.6-flash ✓ | ~18s (2× faster) | good — more specific justifications in our A/B |
Both clear the quality bar, so the pick is latency × cost: flash. The lesson isn't "use the biggest model" — it's match the model to the job and measure. Switching models is one line of .env.
That lesson generalized into routing by task profile (lib/llm.ts):
| Profile | Who's waiting | Model | Thinking | Why |
|---|---|---|---|---|
interactive — quote, battle |
a human, watching tokens stream | qwen3.6-flash | off | first token ~1.2s; speed is the quality |
background — playbook distillation |
nobody (optimistic UI already confirmed the keep) | qwen3.7-plus | on | latency is free — spend it on a better principle |
One .env line per profile (LLM_MODEL_INTERACTIVE / LLM_MODEL_BACKGROUND); both fall back to LLM_MODEL.
The engineering tradeoff — routing is a policy, and it stays portable:
- Portable by feature-detection. The thinking toggle (
enable_thinking) is DashScope-specific, so it's gated by base URL — the same codebase optimizes hard on Qwen and still runs unchanged on any OpenAI-compatible provider (OpenAI, Groq, DeepSeek). - Three dials, per task profile. Every call is routed on model (flash vs plus) × thinking (off vs on) × temperature (0.4 quote / 0.7 bout / 0.3 distill) — fast-and-cold where a human waits on tokens, slow-and-warm where nobody does.
- Degrades to one line. Both profiles fall back to
LLM_MODEL, so a single-model.envruns the whole app unchanged — the routing is an optimization, never a requirement.
Production-readiness is mostly about what happens when things go sideways:
| Failure | Handling |
|---|---|
| A price streams in mid-digit | Settled-gate: a number renders only once its value is complete in the JSON |
| Truncated / malformed streaming JSON | Incremental tolerant parser (lib/partial-json.ts), fuzz-tested against 1,168 truncation points; structured calls additionally ride native JSON mode |
| Playbook distillation fails | The kept line is written first (INSERT-first, durable in ~0.5s); distillation enriches it in the background, and on failure the play survives verbatim — knowledge is never lost to a model error |
| Distillation is slow (~16-23s) | Optimistic UI: the keep is confirmed instantly, a ⏳ placeholder backfills by light polling — nobody waits on a background model call |
| Double-click on Keep | In-flight dedup + idempotent upsert — measured: exactly one distillation per keep |
| Reopening a quoted order | Never re-rolls: frozen snapshot, instant and identical (measured drift without it: up to 18.8% on the same order) |
| No genuine playbook match | The brief instructs: never force a citation — a principle is cited only where it actually strengthens the counter |
| Model or endpoint needs swapping | Per-profile env with fallback (LLM_MODEL_INTERACTIVE / LLM_MODEL_BACKGROUND → LLM_MODEL) |
What we don't measure — deliberately. eval:pricing covers what's measurable before field use: output validity, anchor adherence, run-to-run drift. It does not score justification quality or battle-card usefulness, because negotiations are adversarial and shifting — a usefulness benchmark of our own invention would be theater. The honest instrument is built into the product instead: every outcome writes back (won, or lost and why), so the system accumulates its own ground truth deal by deal in production.
# 1. deps
npm install
# 2. database (one-time): local Postgres in docker + schema + synthetic seed
docker run -d --name quote-battle-pg \
-e POSTGRES_PASSWORD=localdev -e POSTGRES_DB=quote_battle \
-p 5433:5432 -v quote-battle-pgdata:/var/lib/postgresql/data postgres:16-alpine
cp .env.example .env # then paste your Qwen Cloud API key
npm run db:init
# 3. go
npm run dev # http://localhost:3000Get a Qwen Cloud key at home.qwencloud.com → API Keys. Any OpenAI-compatible provider works (swap three LLM_* lines in .env), but the product targets Qwen.
Every client, price, deal, and work order in this repo is fictional (see db/init.sql). The problem is real — the data never was. No real client names, contracts, or pricing exist anywhere in this codebase or its history.
One order is seeded vague on purpose: WO-1008 arrives as a secondhand complaint — "keeps backing up every few weeks, smells bad some mornings, plunger doesn't help" — intermittent, cause unknown. Real work orders are half-articulated like this more often than not. Quote it and watch the agent price the ambiguity instead of choking on it: a diagnostic-first quote, framed around this client's known sensitivity (they once walked over speed).
Built for the Qwen Cloud Global AI Hackathon — Track 4: Autopilot Agent. The track asks for agents that automate real-world business workflows end-to-end, handle ambiguous inputs, and "incorporate human-in-the-loop checkpoints at critical decision points," with an emphasis on production-readiness over toy demos. Mapped onto this build: a free-text fault description goes in (the ambiguous artifact every real work order starts as), a priced, defensible, rehearsed position comes out (end-to-end), and the two approval gates are those checkpoints — placed exactly where money changes hands. Frozen quotes, measured drift, the failure-mode handling above, and outcome write-back are the production-readiness part.
Product brain runs on Qwen (qwen3.6-flash + qwen3.7-plus via the OpenAI-compatible endpoint); development was pair-programmed with AI coding tools, with every architectural call made by a human — which is rather the point of the product, too.
Deployment. The backend was deployed and verified end-to-end on Alibaba Cloud — Function Compute (custom Node.js runtime, us-west-1) serving the Next.js app, PolarDB for PostgreSQL for the data, Qwen via the DashScope endpoint for the model. A separate deployment-proof recording (linked in the Devpost submission) shows the live backend on Alibaba Cloud serving real requests — work orders read from PolarDB, a fresh quote generated by Qwen, and the X-Fc-Request-Id gateway header on every response. To stay within the hackathon's free tier the hosted instance isn't kept permanently online; run it locally with the steps above, or follow docs/DEPLOY-aliyun.md to redeploy.
MIT — see LICENSE.