Skip to main content
GoldenPassport
← All writing
Unlisted
Structure-first agent orchestration, open source

Review Summary

LangGraph

A low-level orchestration framework for building controllable agents.

Stars
33.2k
Licence
MIT
LLM
Agnostic

Monthly downloads*

LangGraph62M
Pydantic AI41M
CrewAI12.3M

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
TechMay 28, 2026 · 16 min read

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-review branch (the hook a production system would wire to LangGraph's interrupt() + 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:

ToolVersionWhy
Node.js20 or laterLangGraph 1.x targets modern Node; older versions miss the Annotation types
pnpm9 or later (npm / yarn also fine)Faster installs; commands below assume pnpm but translate one-for-one
TypeScript editorVS Code, Cursor, WebStorm, anythingThe whole walkthrough is TypeScript
TerminalAnyAll 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

STARTclassifyextractvalidatedecideauto-processhuman-reviewrejectEND

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.Root is 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. extracted merges new fields into the existing object; validationErrors appends. The default reducer (used for inputText, category, decision) just replaces.
  • TriageStateType is 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 (category unknown)
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.git
cd automation-review-examples/langgraph-triage-demo-answer
pnpm install
pnpm 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 .docxmammoth.js extracts plain text in the browser, no server needed
  • LangGraph runs in the browser → same StateGraph from 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/* to http://localhost:11434 (Ollama's port) so the browser does not hit CORS

Install Ollama and pull the model:

# macOS
brew install ollama
ollama 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.git
cd automation-review-examples/langgraph-triage-demo-ui-answer
pnpm install
pnpm generate-samples
pnpm 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:

  • classify lights up gold, then green (decision: claim)
  • extract lights up, then green (policy + date pulled out of the document)
  • validate runs, passes (no errors)
  • decide resolves to auto-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-review actually pause. The demo's queueForReview node returns immediately. In production it should call LangGraph's interrupt(), persist state to a checkpointer (MemorySaver in the browser, SqliteSaver or PostgresSaver server-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.ts currently 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, no N/A parsing edge cases.
  • Swap the model. MODEL_NAME in nodes.ts is the only thing tying the demo to llama3.2:3b. Try qwen2.5:7b for better extraction, gpt-oss:20b for a serious local model, or replace ChatOllama with ChatOpenAI / ChatAnthropic for a hosted endpoint.
  • Replace upload with a real queue. Swap the FileUpload handler for a poller / WebSocket / SSE feed pulling messages from SQS, Kafka, or a Postgres LISTEN. 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 test against 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.