Skip to content

nilushan/langgraph-frontdesk-agent

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

28 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SmartSMB — AI Quoting Agent

A stateful, human-in-the-loop agentic workflow that turns an inbound customer message into a priced, human-approved quote — built on LangGraph.js.

TypeScript LangGraph.js Postgres Hono Tests Node License

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.

Operator edits a pending quote on the live dashboard, it re-prices and re-awaits approval, then sends
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.


What it does

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
Loading

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.

Highlights

  • Stateful multi-turn agent — a typed, checkpointed State is 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_quote node prices in code. (ADR-0004)
  • Idempotent deliverysend_quote is 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.

How it works — the LangGraph graph

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
Loading

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 with bun run viz:graph.

The nodes (src/graph/nodes/):

Node Role
router Cheap model classifies intent + extracts entities (structured output).
quote_agentquote_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.

LangGraph concepts, in practice

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
Loading
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.

Human-in-the-loop approval & edit

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
Loading

The same flow on the live dashboard (GET /dashboard):

SmartSMB live thread dashboard with a quote awaiting approval

The /approve endpoint takes a discriminated decision:

// approve as-is
{ "action": "approve", "decidedBy": "alice" }

// edit, then re-submit for approval (structured override — admin supplies the figures)
{ "action": "revise", "decidedBy": "alice",
  "quote": { "amount": 1755, "summary": "40 sqm driveway (loyalty discount)" },
  "note": "loyalty discount" }

// reject → human handoff
{ "action": "reject", "decidedBy": "alice" }

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.


Tech stack

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)
Email Resend (send_quote side-effect)
Toolchain bun (install/scripts), Node 22 (runtime), Vitest, ESLint, Prettier

Quick start

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 test

Try 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"}'

Common commands

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)

Project structure

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

Status & roadmap

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.

Design decisions

The interesting trade-offs are written up as ADRs:

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.

License

MIT © 2026 Nilushan Silva

About

AI "front desk" for small businesses: a stateful LangGraph.js agent that triages inbound customer messages (quotes, complaints, FAQ) and pauses for human approval. Postgres-checkpointed, fully tested.

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors