A stateful, human-in-the-loop agentic workflow that turns an inbound customer message into a priced, human-approved quote — built on LangGraph.js.
Quoting is a chore every trades/services business does by hand: read the request, work out a price, and — for anything sizeable — get a human to sign off before it goes out. SmartSMB automates that loop. An AI agent classifies the message, gathers the job details, computes the price in code (never hallucinated), and pauses for human approval above a configurable threshold — where the operator can approve, edit, or reject before a single email is sent. Every conversation is checkpointed to Postgres, so threads survive restarts and resume cleanly.
Live ops dashboard: an operator edits a $1,950 quote down to $1,755, it re-submits for approval, then sends — nothing leaves before sign-off.
A real day-to-day scenario: a customer emails "quote for a 40 sqm concrete driveway". SmartSMB classifies the intent, runs a tool-using agent to gather and price the job, and routes the draft to a human when it matters — then emails the approved quote.
flowchart LR
A["Customer message<br/>(email / chat)"] --> B{"router<br/>classify intent"}
B -->|quote| C["quote agent<br/>gather details + price"]
B -->|"complaint / faq / unclear"| Z["specialist<br/>or human handoff"]
C --> D{"over approval<br/>threshold?"}
D -->|no| G["Quote emailed"]
D -->|"yes — needs sign-off"| E["Human reviews"]
E -->|approve| G
E -->|edit| C
E -->|reject| H["Human follow-up"]
classDef human fill:#fff3e0,stroke:#f4a261,color:#5a3415
classDef send fill:#e8f6f3,stroke:#2a9d8f,color:#14532d
class E human
class G send
The differentiator is the human gate: the model proposes, a person disposes, and the agent loop lets the operator edit the proposal and send it back through approval rather than rejecting and starting over.
- Stateful multi-turn agent — a typed, checkpointed
Stateis the single source of truth; nodes communicate only through it (no node-to-node calls). - Human-in-the-loop with edits — the approval gate
interrupt()s; the operator can approve, revise (structured price/line-item edit), or reject. A revised quote always loops back for re-approval. (ADR-0005) - Deterministic pricing — the LLM gathers details but never sets the
number; a dedicated
finalize_quotenode prices in code. (ADR-0004) - Idempotent delivery —
send_quoteis keyed on the quote id and short-circuits on replay, so a resume never double-sends. - Swappable LLM — nodes depend on
BaseChatModel; provider/model are env config. (ADR-0002) - Durable by default — Postgres checkpointer; threads pause for approval and survive process restarts.
- 97 passing tests — deterministic unit suites with a mocked model + in-memory
checkpointer; real-dependency suites gated behind
RUN_INTEGRATION=1.
The service is one compiled StateGraph. START → router fans out to a
specialist; the quote path runs a ReAct loop (agent ⇄ tools), finalizes a
priced quote, and enters the approval gate, which can pause, loop through an
edit, and resume.
flowchart TD
START([START]) --> router
subgraph cpgraph["checkpointed graph — every node runs under the checkpointer"]
router -. quote .-> quote_agent
router -. complaint .-> complaint
router -. faq .-> faq
router -. "unclear / low confidence" .-> human_handoff
subgraph react["ReAct loop"]
quote_agent -. "needs a tool" .-> quote_tools
quote_tools --> quote_agent
end
quote_agent -. "stopped calling tools" .-> finalize_quote
finalize_quote --> approval_gate
subgraph hitl["Human-in-the-loop + edit"]
approval_gate -. revise .-> revise_quote
revise_quote --> approval_gate
end
approval_gate -. approved .-> send_quote
approval_gate -. rejected .-> human_handoff
end
approval_gate -. "no quote" .-> ENDN([END])
send_quote --> ENDN
complaint --> ENDN
faq --> ENDN
human_handoff --> ENDN
pg[("Postgres<br/>checkpointer")] -. "snapshots full state<br/>after every super-step" .-> cpgraph
classDef gate fill:#5a3415,stroke:#d99a4a,color:#fef0e0
classDef store fill:#3f1a28,stroke:#d95a7f,color:#fee0ea
class approval_gate gate
class pg store
Dotted edges are conditional (decided in plain code in
src/graph/routing.ts, never by a model). This diagram mirrors the compiled graph — regenerate it any time withbun run viz:graph.
The nodes (src/graph/nodes/):
| Node | Role |
|---|---|
router |
Cheap model classifies intent + extracts entities (structured output). |
quote_agent ⇄ quote_tools |
ReAct loop: gather job details via lookup_pricing / check_availability. |
finalize_quote |
Deterministically prices the gathered job — the LLM never emits the number. |
approval_gate |
interrupt()s above the threshold; records approve / reject / revise. |
revise_quote |
Applies the operator's structured edit and loops back for re-approval. |
send_quote |
Idempotent email delivery of the approved quote. |
complaint / faq / human_handoff |
Non-quote specialist paths. |
This project is a compact tour of the LangGraph primitives. Each concept maps to a concrete place in the code:
flowchart LR
subgraph concept["LangGraph concept"]
direction TB
c1["StateGraph"]
c2["Annotation state + reducers"]
c3["Conditional edges"]
c4["ToolNode + tools_condition"]
c5["interrupt() + Command"]
c6["PostgresSaver"]
end
subgraph here["…where this project uses it"]
direction TB
u1["wires every node & edge"]
u2["messages·customer·quote·approval·revisions"]
u3["route by intent / after approval"]
u4["the pricing ReAct loop"]
u5["human approval + edit gate"]
u6["threads resume after restart"]
end
c1 --> u1
c2 --> u2
c3 --> u3
c4 --> u4
c5 --> u5
c6 --> u6
| Concept | Where | What it does here |
|---|---|---|
StateGraph |
src/graph/graph.ts |
Assembles nodes + edges into the compiled graph. |
Annotation state + reducers |
src/graph/state.ts |
Typed channels; messages/revisions append, customer/entities merge, the rest replace. |
| Conditional edges | src/graph/routing.ts |
All branching is deterministic code reading state — predictable and unit-testable. |
ToolNode + tools_condition |
src/graph/graph.ts, src/tools/ |
The agent ⇄ tool ReAct loop; every tool input is Zod-validated. |
interrupt() + Command |
src/graph/nodes/approvalGate.ts, src/server/app.ts |
Pause for human sign-off; resume with the operator's decision. |
PostgresSaver checkpointer |
src/checkpointer.ts |
Persists every super-step so a thread can pause/resume and survive restarts. |
When a quote crosses the approval threshold, the graph suspends at the approval gate and checkpoints. The operator sees it on the live dashboard and responds over HTTP — approve, reject, or edit-and-resubmit. Crucially, an edit re-prices and re-enters approval: nothing is emailed until a revised quote is explicitly approved.
sequenceDiagram
actor Cust as Customer
participant API as Hono server
participant G as LangGraph
actor Op as Operator
Cust->>API: POST /threads/:id/messages
API->>G: graph.invoke(message)
G->>G: router → quote agent → finalize_quote
G-->>API: interrupt() · status = awaiting_approval
Note over G: state checkpointed to Postgres
Op->>API: GET /dashboard (reviews the pending quote)
Op->>API: POST /approve · action = revise (new amount)
API->>G: Command(resume)
G->>G: revise_quote applies the edit
G-->>API: interrupt() again · re-priced, still pending
Note over G,Op: a revised quote is never sent before re-approval
Op->>API: POST /approve · action = approve
API->>G: Command(resume)
G->>API: send_quote → email sent
API-->>Op: status = complete
The same flow on the live dashboard (GET /dashboard):
The /approve endpoint takes a discriminated decision:
Every edit is recorded in an append-only revisions channel (who, when, the prior
quote, the change) — so the thread keeps a full audit trail of how a quote evolved.
| Concern | Choice |
|---|---|
| Orchestration | LangGraph.js (@langchain/langgraph) |
| Models | Anthropic — Haiku (router), Sonnet (quote agent); swappable |
| Persistence | Postgres + @langchain/langgraph-checkpoint-postgres |
| HTTP | Hono (Node-compatible runtime) |
| Data / pricing | Drizzle ORM over Postgres |
| Validation | Zod (env + every tool input) |
Resend (send_quote side-effect) |
|
| Toolchain | bun (install/scripts), Node 22 (runtime), Vitest, ESLint, Prettier |
Prerequisites: Node 22 (nvm use), bun, Docker.
./app/scripts/setup.sh # installs deps, starts Postgres, migrates, seeds, tests
# then add your ANTHROPIC_API_KEY to app/.env
cd app && bun run dev # http://localhost:3000 (dashboard: /dashboard)Doing it manually instead of the script:
cd app
cp .env.example .env # fill in ANTHROPIC_API_KEY
bun install
bun run db:up # start local Postgres
bun run db:generate # generate migration from src/db/schema.ts
bun run db:migrate
bun run db:seed
bun run testTry the flow by hand (server running):
# 1. customer asks for a quote → pauses for approval
curl -sX POST localhost:3000/threads/demo/messages \
-H 'content-type: application/json' \
-d '{"text":"quote for a 40 sqm concrete driveway","channel":"email","email":"jo@example.com"}'
# 2. operator edits it down and re-submits for approval
curl -sX POST localhost:3000/threads/demo/approve \
-H 'content-type: application/json' \
-d '{"action":"revise","decidedBy":"you","quote":{"amount":1755}}'
# 3. approve → the revised quote is emailed
curl -sX POST localhost:3000/threads/demo/approve \
-H 'content-type: application/json' \
-d '{"action":"approve","decidedBy":"you"}'| Command | What it does |
|---|---|
bun run dev |
Start the server with watch reload |
bun run test |
Unit tests (fast, mocked — no network/DB) |
RUN_INTEGRATION=1 bun run test |
Include integration suites (real DB/model) |
bun run typecheck |
TypeScript type checking |
bun run lint / format |
ESLint / Prettier |
bun run db:up / db:down |
Start / stop local Postgres |
bun run db:migrate / db:seed |
Apply migrations / seed reference data |
bun run viz:graph |
Print the compiled graph as Mermaid |
bun run db:studio |
Browse the database (Drizzle Studio) |
docs/
spec/ # build spec + architecture diagram
adr/ # architecture decision records
diagrams/ # domain type / "class" diagram
runbook/ # manual testing runbook
assets/ # README screenshots / GIF
app/
src/
server/ # Hono HTTP surface + live dashboard
app.ts # routes (createApp); index.ts bootstraps the service
views.ts # state → JSON views; requestSchemas.ts validates bodies
dashboard.html # self-contained live ops page (served by GET /dashboard)
graph/ # LangGraph state, nodes, edges, routing
nodes/ # router, quote_agent, finalize_quote, approval_gate, revise_quote, send_quote, …
tools/ # tool registry: Zod schema + impl
models.ts # createChatModel(role) — swappable provider
checkpointer.ts # PostgresSaver wiring
db/ # Drizzle schema + client
scripts/ # db/ (migrate, seed, reset) · dev/ (debug, viz, spike) · setup.sh, smoke.sh
test/ # Vitest specs + mock helpers
P0 (MVP) complete: router → quote ReAct loop → deterministic finalize → human approval/edit interrupt → idempotent send → Postgres checkpointer → Hono routes, covered by 97 tests.
Next (P1):
- Inbound email → HTTP adapter (today the HTTP route is the entry point; a mail webhook would map a real email to a thread).
- Free-text revisions — let operators describe an edit in natural language, not just a structured override (the deferred path in ADR-0005).
- Production RAG for the FAQ path; multi-channel adapters.
The interesting trade-offs are written up as ADRs:
- ADR-0001 — Tech stack & toolchain
- ADR-0002 — Swappable LLM provider
- ADR-0003 — Router structured output
- ADR-0004 — Deterministic finalize + idempotent delivery
- ADR-0005 — Human approval edit loop
The full build spec is in docs/spec/quote-agent-langgraph-spec.md.
The domain type / "class" diagram is in docs/diagrams/class-diagram.md.
Contributions: see CONTRIBUTING.md.
MIT © 2026 Nilushan Silva
