AI agents as controlled workflows, not chatbots.
A customer submits a complaint. A LangGraph workflow classifies it with an LLM, runs a deterministic risk check, routes it to one of 7 departments, creates a ticket in SQLite, mock-sends a department email, and returns a confirmation to the customer.
The key idea: only one step uses AI. The LLM classifies the complaint — and that's it. Every other step (risk rules, routing, ticketing, email, the customer reply) is plain, predictable Python. The AI advises; the workflow decides. That's what makes this an agent you can trust in production rather than a chatbot you hope behaves.
- How it works (the big picture)
- The workflow
- Request lifecycle
- The 7 departments
- The code, file by file
- The classification contract
- The risk rules
- Setup
- Running it
- The demo script
- API reference
- Tech stack
flowchart LR
User([Customer]) -->|fills form| UI[frontend/index.html]
UI -->|POST /tickets JSON| API[FastAPI<br/>backend/main.py]
API -->|run_workflow| G[LangGraph workflow<br/>backend/graph.py]
subgraph Graph [The workflow - backend/nodes.py]
direction TB
N[7 nodes run in order]
end
G --> Graph
Graph -->|one LLM call| LLM[Gemini 2.5 Flash<br/>structured output]
Graph -->|save ticket| DB[(SQLite<br/>tickets.db)]
Graph -->|mock send| Mail[Console email<br/>backend/email_sender.py]
Graph -->|TicketOutput JSON| API
API -->|result card| UI
There are three moving parts:
- Frontend — one static HTML file with a form and a result card. No build step.
- API — a FastAPI app with a single meaningful endpoint,
POST /tickets. - Workflow — a LangGraph state machine of 7 nodes. It calls Gemini once (to classify), writes to SQLite, and "sends" an email by printing it.
The heart of the project. Seven nodes run in a fixed order, with exactly one branch — after the risk check, the complaint is routed either normally or down a human-review path. Both paths converge again on ticket creation.
flowchart TD
START([START]) --> A[receive_complaint<br/><i>log the incoming complaint</i>]
A --> B[classify_complaint<br/><b>the only LLM call</b><br/>structured output]
B --> C{risk_check<br/><i>apply deterministic rules</i>}
C -->|needs_human_review| D2[route_human_review<br/><i>flag + route</i>]
C -->|normal| D1[route_department<br/><i>look up dept email</i>]
D1 --> E[create_ticket<br/><i>TCK-XXXXX, save to SQLite</i>]
D2 --> E
E --> F[send_email<br/><i>mock: print payload</i>]
F --> G[customer_response<br/><i>compose reply</i>]
G --> END([END])
style B fill:#6366f1,color:#fff
style C fill:#f59e0b,color:#000
| # | Node | What it does | Uses AI? |
|---|---|---|---|
| 1 | receive_complaint |
Logs the incoming complaint | — |
| 2 | classify_complaint |
Asks Gemini to classify it into a department, type, priority, confidence + required info | ✅ |
| 3 | risk_check |
Deterministic rules: low confidence → fallback; fraud → escalate | — |
| 4 | route_department / route_human_review |
Looks up the department email (the one conditional branch) | — |
| 5 | create_ticket |
Generates TCK-XXXXX, saves to SQLite |
— |
| 6 | send_email |
Mock-sends the department email (prints the payload) | — |
| 7 | customer_response |
Composes the confirmation message back to the customer | — |
Every node prints one trace line when it runs, so the whole decision is visible live in the terminal — ideal for a talk or a screen-share.
What happens end-to-end when a customer hits "Submit":
sequenceDiagram
actor C as Customer
participant UI as index.html
participant API as FastAPI
participant WF as LangGraph
participant AI as Gemini 2.5 Flash
participant DB as SQLite
C->>UI: fill form + submit
UI->>API: POST /tickets (JSON)
API->>WF: run_workflow(complaint)
WF->>WF: receive_complaint
WF->>AI: classify_complaint (prompt + schema)
AI-->>WF: {department, priority, confidence, ...}
WF->>WF: risk_check (rules)
WF->>WF: route (normal | human review)
WF->>DB: create_ticket → save TCK-XXXXX
WF->>WF: send_email (mock, prints payload)
WF->>WF: customer_response
WF-->>API: TicketOutput
API-->>UI: JSON result
UI-->>C: render result card
Defined once in backend/departments.py — the single
source of truth for routing. The scopes are also injected into the LLM prompt so
the model knows what each team covers.
| Department | Handles | |
|---|---|---|
| Payments & Settlement | payments@example.com | failed transfers, refunds, reversals, duplicate charges, debited-but-merchant-not-credited |
| Technical Support | technical@example.com | app errors, API errors, failed webhooks, dashboard bugs, downtime |
| Account Access | account@example.com | login, password reset, OTP, locked accounts, 2FA |
| Merchant Support | merchant@example.com | merchant onboarding, POS, settlement reports, merchant dashboard, payment acceptance |
| Risk & Security | risk@example.com | suspicious transactions, unauthorized debits, suspected fraud, account compromise — always High + human review |
| Customer Success | success@example.com | product questions, how-to, usage guidance, education |
| General Support | general@example.com | unclear/incomplete — the fallback |
backend/
main.py FastAPI app, CORS, the POST /tickets endpoint
graph.py wires the 7 nodes into a LangGraph + run_workflow()
nodes.py the 7 node functions (classify_complaint calls the LLM)
models.py Pydantic models + the GraphState TypedDict
departments.py the 7 departments: names, emails, scopes
email_sender.py mock sender — pretty-prints the email payload
storage.py SQLite: create table, generate TCK ids, save/read tickets
test_complaints.py fires 4 sample complaints for a live demo
frontend/
index.html the Create Ticket form + result card (vanilla JS)
requirements.txt pinned dependencies
.env.example template for your GOOGLE_API_KEY
-
main.py— Creates the FastAPI app, enables CORS for all origins (so the static HTML can call it fromfile://), and exposesPOST /tickets. It loads.envbefore importing the LLM, and wraps the workflow in a try/except so any failure returns a clean500with the message. -
graph.py— Builds theStateGraph, adds the 7 nodes, and wires the edges. The singleadd_conditional_edgesafterrisk_checkchoosesroute_departmentvsroute_human_review.run_workflow()invokes the compiled graph and maps the final state into aTicketOutput. -
nodes.py— Each node takes theGraphStateand returns a partial update.classify_complaintbuilds the prompt (including the department scopes), callsChatGoogleGenerativeAI(...).with_structured_output(...), and is defensive: an unrecognized department name falls back to General Support. -
models.py—ComplaintInput(the form),ComplaintClassification(what the LLM must return),TicketOutput(the API response), andGraphState(the data passed between nodes). -
storage.py— A flatticketstable.next_ticket_id()producesTCK-00001,TCK-00002, … from the row count.save_ticket()stores the record (therequired_informationlist is JSON-encoded). -
email_sender.py—send_department_email()builds the payload, pretty-prints it to the console, and returnsTrue. Swap this one function for a real provider and the rest of the app is unchanged. -
departments.py— The departments dict plus helpers:get_email(),department_names(),scopes_for_prompt().
classify_complaint is the only place AI is used, and it's constrained to a
strict Pydantic schema via structured output — the model must return exactly
these fields, validated before anything downstream runs:
class ComplaintClassification(BaseModel):
department: str # one of the 7 names, exactly
complaint_type: str # short label
priority: str # Low | Medium | High
confidence: float # 0.0 - 1.0
required_information: list # what the dept still needs
reason: str # one-sentence justificationThis is why the agent is reliable: the LLM can't return free-form prose or call arbitrary tools. It fills in a form. The deterministic code does the rest.
After classification, risk_check applies two hard rules in
plain Python — the LLM does not get to override these:
flowchart TD
A[classification result] --> B{confidence < 0.5?}
B -->|yes| C[force department =<br/>General Support]
B -->|no| D[keep department]
C --> E{Risk & Security<br/>OR fraud keywords?}
D --> E
E -->|yes| F[priority = High<br/>needs_human_review = true]
E -->|no| G[keep priority<br/>no review needed]
- Low confidence (< 0.5) → forced to General Support. If the AI isn't sure, a human triages it rather than mis-routing.
- Risk & Security department, or fraud/unauthorized keywords → priority
becomes High and
needs_human_review = true. Security never gets silently auto-handled.
Requires Python 3.11+.
# 1. clone
git clone https://github.com/prewsh/customer-routing-agent.git
cd customer-routing-agent
# 2. virtual environment
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. dependencies
pip install -r requirements.txt
# 4. API key
cp .env.example .env # then edit .env and paste your keyGet a free Gemini API key from
Google AI Studio and put it in .env:
GOOGLE_API_KEY=your_key_here
.envis gitignored — your key stays on your machine and is never committed.
Start the backend (from the project root, not inside backend/):
uvicorn backend.main:app --reloadThe API runs at http://localhost:8000. Then open the frontend:
open frontend/index.html # macOS — or just double-click itFill in the form and submit. The result card fills in, and the terminal shows the live node trace:
----- workflow start: 'Debited but merchant not credited' -----
[receive_complaint] -> 'Debited but merchant not credited' from ada@example.com
[classify_complaint] -> Payments & Settlement, High (conf 0.95)
[risk_check] no escalation -> Payments & Settlement, High
[route_department] -> Payments & Settlement (payments@example.com)
[create_ticket] -> TCK-00001 saved
📧 MOCK EMAIL -> payments@example.com { ...full payload... }
[send_email] -> Payments & Settlement email_sent=True
[customer_response] -> ticket TCK-00001 ready for ada@example.com
----- workflow end -----
For a live talk, skip the browser and fire four representative complaints at once:
python backend/test_complaints.pyIt runs: (1) a payments dispute, (2) a fraud case (→ flags human review), (3) a login/OTP issue, and (4) a vague "your app is bad" (→ falls back to General Support). The output is color-coded per complaint and ends with a routing scoreboard:
#1 TCK-00001 -> Payments & Settlement
#2 TCK-00002 -> Risk & Security ⚠ human review
#3 TCK-00003 -> Account Access
#4 TCK-00004 -> General Support
Request body:
{
"name": "Ada Okafor",
"email": "ada@example.com",
"title": "Debited but merchant not credited",
"description": "I was debited but the merchant got nothing.",
"reference_id": "TXN-4412"
}reference_id is optional.
Response:
{
"ticket_id": "TCK-00001",
"department": "Payments & Settlement",
"priority": "High",
"complaint_type": "failed transfer",
"reason": "Customer was debited but the merchant was not credited.",
"required_information": ["transaction reference", "amount", "date"],
"needs_human_review": false,
"department_email_sent": true,
"customer_response": "Hi Ada Okafor, thanks for reaching out..."
}Health check — returns {"status": "ok"}.
| Layer | Choice |
|---|---|
| Language | Python 3.11+ |
| API | FastAPI + uvicorn |
| Workflow | LangGraph |
| LLM | Gemini 2.5 Flash via langchain-google-genai |
| Validation | Pydantic |
| Storage | SQLite (stdlib) |
| Frontend | Plain HTML + vanilla JS (no build step) |