Review Summary
LangGraph
A low-level orchestration framework for building controllable agents.
- Stars
- 33.2k
- Licence
- MIT
- LLM
- Agnostic
The problem it solves
- Typed state across LLM calls
- Long-running, pausable workflows
- Branching with auditability
What is good
- Right primitive at the right level (typed StateGraph)
- Production extras free: streaming, checkpointing, human-in-the-loop
- Cross-runtime API parity (Python + JS)
- Vendor-neutral models via the LangChain ecosystem
Where the gaps are
- Security posture needs active management (2025 CVEs in checkpointers)
- Docs uneven across runtimes
- LangChain peer-dependency is real
- Conditional-edge ergonomics are verbose
- Pre-1.0 versioning was spiky; 1.x stabilising
Automation Review: LangGraph
Follow-along LangGraph demo: build a document-intake triage agent end to end, with human-in-the-loop, from an empty folder to a working CLI in under thirty minutes.
Reading the Article tab first? Switch back if you haven't yet; this page assumes you know what LangGraph is and why you would pick it. The Article tab covers positioning, the competitive landscape, what is good, and where the gaps are. This page is the hands-on.
What you'll build
A document-intake triage agent built on LangGraph's StateGraph. By the end you'll have a runnable CLI that:
Reads an incoming document (text in, for the demo) and classifies it as a claim, query, complaint, or unknown.
Extracts structured fields from the body and validates them against the category's rules.
Decides whether to auto-process, queue for human review, or reject, using a typed state object that every node reads from and writes to.
Routes anything that fails validation to a
human-reviewbranch (the hook a production system would wire to LangGraph'sinterrupt()+ checkpointer for a real reviewer approval flow; see "Where to next" at the bottom).Streams state diffs to the terminal as each node completes, so you can watch the graph execute in real time.
You will walk away with: a working LangGraph project, a feel for the StateGraph / Annotation / addEdge API surface, and a template you can adapt to any document-intake or multi-step decision flow at work. Step 9 then puts a React UI and a real local model in front of the same graph.
Time: around 30 minutes for the CLI walkthrough (Steps 1 to 8). Add 15 to 20 minutes for the UI extension in Step 9. Longer if you have not used Node / TypeScript before.
Prerequisites
Have these ready before you start:
| Tool | Version | Why |
|---|---|---|
| Node.js | 20 or later | LangGraph 1.x targets modern Node; older versions miss the Annotation types |
| pnpm | 9 or later (npm / yarn also fine) | Faster installs; commands below assume pnpm but translate one-for-one |
| TypeScript editor | VS Code, Cursor, WebStorm, anything | The whole walkthrough is TypeScript |
| Terminal | Any | All commands below are copy-pasteable |
No API keys required. Steps 1 to 8 use deterministic mock implementations of the model calls, so the CLI runs offline. Step 9 swaps in a local Ollama model, also offline (a one-time install + model pull is covered there).
The scenario
You work for an insurance company. A shared mailbox receives a mix of incoming documents from customers: new claims, policy queries, complaints, and the occasional spam. Today, a human triages every one. You want to put a structured agent in front of the queue so:
- Clean claims with all required fields go straight to auto-processing
- Anything malformed or ambiguous lands in front of a human reviewer with the validation errors pre-attached
- Spam / unknown documents are rejected with a logged reason
The flow is graph-shaped: classify → extract → validate → decide, with a branch at the end. Every step writes into a shared typed state object so an auditor can read what the agent did at each node.
That is exactly the shape LangGraph is built for.
The graph we'll build
Classify → extract → validate → decide, with a conditional branch routing each claim to auto-process, human review, or reject.
Now build it.
Step 1: Create the project
Open a terminal in the folder you keep code in and run:
mkdir langgraph-triage-demo
cd langgraph-triage-demo
pnpm init
Accept the defaults. You should now have a folder with a package.json.
Step 2: Install dependencies
Install the LangGraph runtime and the TypeScript tooling. pnpm add installs and registers the packages, so you do not need a separate pnpm install.
pnpm add @langchain/langgraph @langchain/core
pnpm add -D typescript tsx @types/node
Create a new file at the project root called tsconfig.json (e.g. touch tsconfig.json in the terminal, or use your editor's New File command). Paste this in:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
Open the existing package.json (created by pnpm init in Step 1) and add the "type": "module" line and a scripts.dev entry. Your file should end up looking roughly like this (other fields generated by pnpm init can stay):
{
"name": "langgraph-triage-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/main.ts"
}
}
Create the source folder where the rest of the files will live:
mkdir src
Sanity check. Your project layout should now look like this:
langgraph-triage-demo/
├── node_modules/
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
└── src/ ← empty for now
Step 3: Define the shared state
Create a new file at src/state.ts (e.g. touch src/state.ts, or use your editor). The state is what flows through every node in the graph.
import { Annotation } from "@langchain/langgraph";
export const TriageState = Annotation.Root({
inputText: Annotation<string>(),
category: Annotation<"claim" | "query" | "complaint" | "unknown">(),
extracted: Annotation<Record<string, string>>({
reducer: (a, b) => ({ ...a, ...b }),
default: () => ({}),
}),
validationErrors: Annotation<string[]>({
reducer: (a, b) => [...a, ...b],
default: () => [],
}),
decision: Annotation<"auto-process" | "human-review" | "reject">(),
});
export type TriageStateType = typeof TriageState.State;
A few things to notice:
Annotation.Rootis LangGraph 1.x's typed-state declaration. Every field becomes a typed slot in the state object.- Reducers decide what happens when a node returns an update.
extractedmerges new fields into the existing object;validationErrorsappends. The default reducer (used forinputText,category,decision) just replaces. TriageStateTypeis the inferred TypeScript type. We'll use it in node signatures.
Step 4: Write the nodes
Create a new file at src/nodes.ts (touch src/nodes.ts or use your editor). Each node is a plain async function that takes the state and returns a partial update.
For the demo we use deterministic mock implementations so you can run the whole thing without an API key. Swapping in real model calls is shown later.
import type { TriageStateType } from "./state.js";
export async function classify(state: TriageStateType) {
const text = state.inputText.toLowerCase();
let category: TriageStateType["category"] = "unknown";
if (text.includes("claim") || text.includes("incident")) category = "claim";
else if (text.includes("question") || text.includes("query")) category = "query";
else if (text.includes("complaint") || text.includes("disappointed")) category = "complaint";
return { category };
}
export async function extract(state: TriageStateType) {
// Pretend a structured-output model pulled these out of the body.
const fields: Record<string, string> = {};
const policyMatch = state.inputText.match(/policy[:\s#]*([A-Z0-9-]+)/i);
if (policyMatch) fields.policyNumber = policyMatch[1];
const dateMatch = state.inputText.match(/\b(\d{1,2}\/\d{1,2}\/\d{2,4})\b/);
if (dateMatch) fields.incidentDate = dateMatch[1];
return { extracted: fields };
}
export async function validate(state: TriageStateType) {
const errors: string[] = [];
if (state.category === "claim") {
if (!state.extracted.policyNumber) errors.push("Missing policy number");
if (!state.extracted.incidentDate) errors.push("Missing incident date");
}
return { validationErrors: errors };
}
export async function decide(state: TriageStateType) {
if (state.category === "unknown") return { decision: "reject" as const };
if (state.validationErrors.length > 0) return { decision: "human-review" as const };
if (state.category === "claim") return { decision: "auto-process" as const };
return { decision: "human-review" as const };
}
Notice every node is testable in isolation: pass it a state, get a partial update back, no graph required.
Step 5: Wire the graph
Create a new file at src/graph.ts (touch src/graph.ts or use your editor). This is where LangGraph earns its keep: the shape of the workflow lives in one place, separate from the node implementations.
import { StateGraph, START, END } from "@langchain/langgraph";
import { TriageState } from "./state.js";
import { classify, extract, validate, decide } from "./nodes.js";
export function buildGraph() {
return new StateGraph(TriageState)
.addNode("classify", classify)
.addNode("extract", extract)
.addNode("validate", validate)
.addNode("decide", decide)
.addEdge(START, "classify")
.addEdge("classify", "extract")
.addEdge("extract", "validate")
.addEdge("validate", "decide")
.addEdge("decide", END)
.compile();
}
That compile() call returns a runnable with invoke, stream, and the rest of the LangGraph runtime surface. Nothing else needs to know about the graph's shape.
Step 6: Run it
Create a new file at src/main.ts (touch src/main.ts or use your editor). This is the file pnpm dev runs, so it must exist before the next command works. It streams state diffs to the terminal as each node finishes, so you can watch the graph execute.
import { buildGraph } from "./graph.js";
const graph = buildGraph();
const inputText = `
Hi, I want to file a claim for incident on 12/04/2026
under policy POL-554821. My car was rear-ended at the lights.
`;
console.log("Running graph...\n");
for await (const chunk of await graph.stream({ inputText })) {
for (const [nodeName, update] of Object.entries(chunk)) {
console.log(`[${nodeName}]`, update);
}
}
Run it:
pnpm dev
You should see something like:
Running graph...
[classify] { category: 'claim' }
[extract] { extracted: { policyNumber: 'POL-554821', incidentDate: '12/04/2026' } }
[validate] { validationErrors: [] }
[decide] { decision: 'auto-process' }
Four log lines, one per node, in execution order. The graph ran end to end and reached auto-process because the input had a policy number and an incident date.
Hit an error? Check here first
ERR_MODULE_NOT_FOUND: Cannot find module '.../src/main.ts'
The file does not exist. Make sure you actually saved src/main.ts (Step 6) and that it lives in the src folder, not the project root. From the project root, ls src/ should list state.ts, nodes.ts, graph.ts, and main.ts.
Cannot find module '@langchain/langgraph' or '@langchain/core'
Dependencies did not install. Run pnpm add @langchain/langgraph @langchain/core again from the project root.
Cannot find module './state.js' (or similar relative import errors)
The imports in the demo use .js suffixes even on .ts files because Node's ESM resolver looks for the compiled extension. Keep the .js suffixes exactly as written; tsx rewrites them at runtime.
Type errors on Annotation<...> declarations
LangGraph 1.x requires @langchain/langgraph ≥ 0.1, which ships the typed Annotation API. If pnpm list @langchain/langgraph shows an older version, run pnpm add @langchain/langgraph@latest.
Step 7: Add the human-in-the-loop branch
A for loop could have done what we have so far. The reason to use LangGraph is the moment you need a pause for human review.
Open the existing src/graph.ts and replace its entire contents with the version below. This swaps the simple .addEdge("decide", END) for a conditional edge that routes based on the decision field, plus three small terminal nodes:
import { StateGraph, START, END } from "@langchain/langgraph";
import { TriageState } from "./state.js";
import { classify, extract, validate, decide } from "./nodes.js";
async function autoProcess() {
console.log(" → auto-processed");
return {};
}
async function queueForReview() {
console.log(" → queued for human review");
return {};
}
async function reject() {
console.log(" → rejected");
return {};
}
export function buildGraph() {
return new StateGraph(TriageState)
.addNode("classify", classify)
.addNode("extract", extract)
.addNode("validate", validate)
.addNode("decide", decide)
.addNode("autoProcess", autoProcess)
.addNode("queueForReview", queueForReview)
.addNode("reject", reject)
.addEdge(START, "classify")
.addEdge("classify", "extract")
.addEdge("extract", "validate")
.addEdge("validate", "decide")
.addConditionalEdges("decide", (state) => state.decision, {
"auto-process": "autoProcess",
"human-review": "queueForReview",
"reject": "reject",
})
.addEdge("autoProcess", END)
.addEdge("queueForReview", END)
.addEdge("reject", END)
.compile();
}
Re-run pnpm dev. You should now see a fifth line:
[autoProcess] {}
→ auto-processed
In a production system, queueForReview would call LangGraph's interrupt() to pause the graph, persist its state to a checkpointer, and exit. When a reviewer clicks Approve, your application resumes the graph from the same node with the reviewer's decision injected. The full version of that pattern, with a SQLite checkpointer, is in the companion repo.
Step 8: Test the edge cases
Try three different inputs to see the branch decisions in action. Open the existing src/main.ts and replace its contents with this version, which loops over three sample inputs:
const inputs = [
// Clean claim → auto-process
"Filing a claim for an incident on 12/04/2026 under policy POL-554821.",
// Malformed claim → human-review
"I want to file a claim, my car was rear-ended yesterday.",
// Unknown → reject
"Are you hiring? I'd love to work with you.",
];
for (const inputText of inputs) {
console.log(`\n=== Input: "${inputText.slice(0, 60)}..." ===`);
for await (const chunk of await graph.stream({ inputText })) {
for (const [nodeName, update] of Object.entries(chunk)) {
console.log(`[${nodeName}]`, update);
}
}
}
Re-run. You should see three distinct paths:
- Input 1 reaches
autoProcess - Input 2 reaches
queueForReview(missing policy number and date) - Input 3 reaches
reject(categoryunknown)
Answers: run the finished project
If you got stuck partway through and want a working reference, the final-state version of this demo lives in the companion repo under langgraph-triage-demo-answer/. Clone, install, run:
git clone https://github.com/GoldenPassport/automation-review-examples.gitcd automation-review-examples/langgraph-triage-demo-answerpnpm installpnpm dev
The output should match what you saw in Step 8: three sample inputs, three different terminal branches (autoProcess, queueForReview, reject). Full expected output and a "where to take it next" guide are in the project's README.
To diff your local version against the answers, point your editor's "compare folders" feature at both project roots. The src/ files should match line for line.
That is the whole point of LangGraph: one graph, one typed state, three deterministic outcomes, every node auditable.
Step 9: Real models, real documents, real UI
Steps 1 to 8 use mocked classifiers and a CLI runner so the graph shape stays the focus. The natural next move is to put a real model behind the nodes and a real UI in front of them. That last step is in a separate companion project (langgraph-triage-demo-ui-answer/) rather than continued in the same folder, because the dependency footprint changes a lot: React, Vite, an Ollama runtime, a .docx parser. Keeping it separate means Steps 1 to 8 stay a tight CLI exercise.
The companion project is a single-page React app built with Vite + TypeScript. It lets you upload a .docx, watch the graph execute in real time, and see the final report with a colour-coded decision. Same graph, same state, just real model calls and a browser UI.
Architecture:
- Browser uploads
.docx→ mammoth.js extracts plain text in the browser, no server needed - LangGraph runs in the browser → same
StateGraphfrom Step 5, classify + extract now call a real model - Model = local Ollama running llama3.2:3b (3B params, ~2GB, runs comfortably on a laptop CPU)
- Vite dev-server proxy forwards
/api/ollama/*tohttp://localhost:11434(Ollama's port) so the browser does not hit CORS
Install Ollama and pull the model:
# macOSbrew install ollamaollama serve# Then pull the model (any OS)ollama pull llama3.2:3b
Clone the project, generate samples, run the dev server:
git clone https://github.com/GoldenPassport/automation-review-examples.gitcd automation-review-examples/langgraph-triage-demo-ui-answerpnpm installpnpm generate-samplespnpm dev
pnpm generate-samples writes three .docx files into samples/ (one per branch outcome). Open http://localhost:5173, click Choose .docx file, and upload one of them. The graph view animates as each node runs; the report panel appears once the graph reaches END.
Key wiring. The nodes look almost identical to Step 4's mocks, with one change: classify and extract now call Ollama. The graph shape itself does not change.
import { ChatOllama } from "@langchain/ollama";
const model = new ChatOllama({
baseUrl: "/api/ollama", // proxied by Vite to localhost:11434
model: "llama3.2:3b",
temperature: 0,
});
export async function classify(state: TriageStateType) {
const response = await model.invoke(`
Classify this document as claim / query / complaint / unknown.
Respond with ONLY one of these four words.
Document: ${state.inputText}
`);
const text = response.content.toString().trim().toLowerCase();
const category = CATEGORIES.find((c) => text.includes(c)) ?? "unknown";
return { category };
}
The point worth flagging: validate and decide are still pure deterministic functions. The model only judges category and extracts fields. Everything that touches the routing decision is rule-based, so the audit trail stays clean even though there's a non-deterministic LLM in the pipeline. This is the regulated-industry hybrid pattern from the Article tab, in 50 lines of code.
What you see when you upload clean-claim.docx:
classifylights up gold, then green (decision:claim)extractlights up, then green (policy + date pulled out of the document)validateruns, passes (no errors)decideresolves toauto-process- The auto-process branch (left of the graph) goes green; the other two stay grey
- Report panel: "Auto-process" in green, with the extracted fields listed
Upload malformed-claim.docx (no policy number, no date) and the flow is identical until decide, which routes to queueForReview. Report panel: "Queue for human review" in amber, with the validation errors listed.
Upload unknown.docx (a job application) and classify returns unknown, which short-circuits the validation step and routes to reject. Report panel: "Reject" in red.
malformed-claim.docx uploaded: pipeline runs end-to-end, the graph routes through human-review, the report panel shows the missing field that triggered the routing.
The full source (Vite config, the proxy, the React components, the workflow visualisation SVG, the sample-generator script) is in the companion project's README. Diff it against your own attempt to compare line by line.
Where to next
Steps 1 to 8 ship a typed StateGraph with deterministic routing. Step 9 puts a real model and a real UI in front of it. The natural extensions from here:
- Make
human-reviewactually pause. The demo'squeueForReviewnode returns immediately. In production it should call LangGraph'sinterrupt(), persist state to a checkpointer (MemorySaverin the browser,SqliteSaverorPostgresSaverserver-side), and resume only after a reviewer approves via a separate UI. The graph shape does not change; only the node body and the.compile({ checkpointer })call do. - Upgrade to structured output.
nodes.tscurrently prompts the model and parses the response with regex, which is robust on small models but fragile. With a 7B+ model,model.withStructuredOutput(zodSchema)returns a typed object directly: no regex, noN/Aparsing edge cases. - Swap the model.
MODEL_NAMEinnodes.tsis the only thing tying the demo tollama3.2:3b. Tryqwen2.5:7bfor better extraction,gpt-oss:20bfor a serious local model, or replaceChatOllamawithChatOpenAI/ChatAnthropicfor a hosted endpoint. - Replace upload with a real queue. Swap the
FileUploadhandler for a poller / WebSocket / SSE feed pulling messages from SQS, Kafka, or a PostgresLISTEN. Same graph, same nodes; only the entry point changes. - Generate the Mermaid diagram.
graph.getGraph().drawMermaid()returns a string you can paste into any markdown viewer. Or render it alongside the SVG view for an auto-updating system diagram. - Stand up CI.
pnpm testagainst three fixed inputs (one per branch) catches breaking changes when LangGraph upgrades. Worth setting up before the graph is mission-critical.
Both projects live in the companion monorepo:
Suggest a review
If there is a tool or concept you would like reviewed next in this format, drop me a message. The series runs on what readers actually want covered.