Rung 3 — code frameworks (LangChain, LangGraph)
Rebuild the lead router as a five-node state graph in code, read its state field by field, and price the eight extra build hours against the tests they buy.
On this page
On rung 3 the graph is code. The nodes are functions, the state is a declared type, a saved checkpoint lets a run wait for a person and survive a restart, and every path through the graph can be tested before a change ships. The router is the same nine-node job from rung 2 in five nodes, at the same $2 a month in model calls; what changes is the build cost and what you can prove about it.
Two libraries, not one
The two names are often used as one product. They are two libraries with different jobs.
LangChain is the integration layer: one interface to many model providers, plus tool bindings and structured output. Its init_chat_model creates a model from a provider and a name, and bind_tools attaches tool schemas to it (Models (opens in a new tab)). It has no opinion about the order your steps run in.
LangGraph is the runtime for the graph itself. A graph has a state schema, usually a TypedDict; nodes are plain functions that read the state and return updates; edges fix what runs next; a conditional edge calls a routing function on the state to pick the next node; and the graph is compiled before it runs, which catches a node nothing reaches (Graph API overview (opens in a new tab)). A checkpointer saves the state per thread, keyed by a thread_id; an in-memory saver loses every checkpoint when the process restarts, so for production the documentation points to a persistent one such as the Postgres saver (Persistence (opens in a new tab)). An interrupt() inside a node pauses the run until the caller resumes it with a value (Interrupts (opens in a new tab)).
The router installs LangGraph for the graph and uses LangChain for one line, the model binding in the draft node (Models (opens in a new tab)). Every other node is ordinary Python.
The mistake: a chain with if-statements
A common first code version is a chain, prompt into model into parser, with the routing done around it:
lead = normalise(payload)
matches = crm.find(lead["email"])
if len(matches) == 1:
send(lead, chain.invoke({"lead": lead, "record": matches[0]}))
else:
post_to_queue(lead)
while not claimed(lead): # wait for a person, in memory
time.sleep(60)
It works on the founder's laptop. In production it fails in two ways that no single lead reveals. A deploy restarts the process and every lead waiting in that while loop is gone, with no record of which step it reached. And the routing lives in if statements spread over a file, so "what happens to a lead with two matches?" has no answer short of reading the code. The fix is not tidier Python; it is to make the steps nodes, the routing edges and the waiting a checkpoint.
The router as five nodes
class Lead(TypedDict):
lead_id: str # also the checkpointer's thread_id
source: Literal["site", "inbox", "partner"]
email: str # lower-cased by normalise
message: str
matches: list[str] # CRM ids found by email: 0, 1 or several
crm_id: str | None # the one match, or the record a person picked
picked_by: str | None # who resumed the run, if anyone
draft: str | None # about 250 tokens
sent_at: str | None
def ask_person(state: Lead) -> dict:
# Nothing above interrupt(): on resume this node runs again from its first line.
answer = interrupt({"lead_id": state["lead_id"], "matches": state["matches"]})
return {"crm_id": answer["crm_id"], "picked_by": answer["by"]}
g = StateGraph(Lead)
for name, fn in [("normalise", normalise), ("lookup", lookup),
("ask_person", ask_person), ("draft", draft), ("send", send)]:
g.add_node(name, fn)
g.add_edge(START, "normalise")
g.add_edge("normalise", "lookup")
g.add_conditional_edges("lookup", lambda s: "draft" if len(s["matches"]) == 1 else "ask_person")
g.add_conditional_edges("ask_person", lambda s: "draft" if s["crm_id"] else END)
g.add_edge("draft", "send")
g.add_edge("send", END)
router = g.compile(checkpointer=postgres_saver)
The comment in ask_person follows the documentation's warning that a resumed node restarts from its first line (Interrupts (opens in a new tab)), so any code before interrupt() runs twice. A post to the account managers' channel placed there would post every lead twice. The post belongs to the code that receives the interrupt, which also owns the 15-minute clock from the role and re-posts if nobody answers.
A person resumes the run with one call, the lead's id and an answer: router.invoke(Command(resume={"crm_id": "c_118", "by": "asha"}), config). On rung 2 a person in the queue handled the lead by hand. Here the person who recognises the customer picks the record, and the graph drafts and sends. If half the 40 queued leads are customers known under another address, as module 0 assumed, 20 of them drop from 6 minutes of a person to about 1: 100 minutes, $50 a month. A Wait node that resumes on a form submission could do the same on rung 2; what rung 3 adds is a test that proves it.
The state, field by field
Every field has one writer. matches is written only by lookup, crm_id by lookup on one match or by ask_person on a resume, draft only by draft. When a reply goes to the wrong customer, the checkpoint history for that thread shows which node wrote the wrong crm_id and from what input. On rung 2 the same question means opening an execution and reading JSON node by node.
The type matters too. source allows three values, and a type checker in CI rejects code that writes a fourth, before it becomes a silent new branch. Rung 1's text fields and rung 2's JSON items give no such check.
What the step up buys, and what it costs
The tests run in CI against a stub model, as testing a harness without a model builds them, in milliseconds and with no API key:
lookupwith 0, 1 and 2 matches routes toask_person,draftandask_person.- A resume carrying a
crm_idreachesdraft; a resume with none ends the run. - A restart between
ask_personand the resume loses no lead: the thread is still there.
One more test calls the real model, on every prompt change: 20 saved leads through draft, failing if any reply contains a price or a delivery date. At $0.0054 a lead that is 11 cents a run, and it would have stopped the rung 2 incident, the deleted "never promise a date" line, before one reply went out.
The price is build time. Assume 20 founder hours, $2,000, against the 12 hours, $1,200, of the rung 2 graph. The model calls stay at about $2 a month, because the graph still makes one call per matched lead. So the step up is $800 once, recovered in 16 months by the $50 a month alone, which is past a 12-month life. It pays only if the tests prevent incidents: one like rung 2's, 48 replies sent on a broken prompt, costs more than $800 if one of those replies loses a deal. Whether the router should move is a question of how often it changes and who changes it. One idea, two skins compares the two for a task where the choice is closer.
In practice
Say this to the developer or contractor: LangGraph for the graph, LangChain only where it binds a model, every node a function with a test, the checkpointer on Postgres from day one (Persistence (opens in a new tab)), and nothing with a side effect above an interrupt(). Ask to see the routing functions and the tests before the prompts.
The mistake that costs money is moving a graph to code without writing the tests. The rebuild costs $800 more than the canvas and buys nothing unless the tests run on every change. Code with no tests is rung 2 that only one person can edit.
Check yourself
1. The router changes about once a quarter and only the founder edits it. Rung 2 or rung 3?
Rung 2. Four changes a year, each reviewed by the founder who made it, is the case the canvas handles well. The $800 extra build would be recovered only if a test caught a broken change, and at four changes a year that might happen once, if ever. Revisit when a second person edits it or changes go monthly.
2. A process restart drops every lead that was waiting for a person. At 40 queued leads a month and a 15-minute average wait, how many leads does one weekly deploy lose on the in-memory version?
Little's law: 40 leads over about 160 working hours is 0.25 an hour, and at 0.25 hours each about 0.06 leads are waiting at any moment. One deploy a week loses about one lead every four months. Small, which is why nobody notices; but a lost lead has no record at all, and a checkpointer makes the number zero for no extra cost per lead.
3. A teammate wants to skip lookup and let the draft node's model call a CRM tool itself, "since it is code now". Allow it?
No. That moves the next step from an edge to the model, which is rung 4 inside a rung 3 graph: the lookup becomes a loop the model controls, with turns and spend to cap. Keep the lookup as code, so a wrong match is a bug in a function a test covers, and give the model one job, the reply.