LangGraph.js stateful agent workflows with checkpoints
Clone → npm install → npm run dev → graph-based orchestration.
langgraph-ts-workflow is a production-ready TypeScript starter for building stateful, multi-step AI agents using LangGraph.js — a low-level orchestration framework for controllable, graph-based AI workflows.
Unlike simple function chains, real-world agents need branching logic, persistent memory, and human oversight. LangGraph models your agent as a directed graph: each node performs work (call an LLM, validate a plan, execute), edges define flow, and state accumulates at every step.
Good fits:
- Multi-step research pipelines (plan → review → execute)
- Human-in-the-loop approval (plan → approve → run)
- Long-running tasks with checkpoint persistence across invocations
- Prototypes that must run without an API key (deterministic mock LLM)
| Feature | Description |
|---|---|
| Graph state machine | Nodes: planning → approval → execution with conditional routing to END until approved. |
| Checkpoint persistence | MemorySaver in development; swap for Sqlite/Postgres/Redis in production. |
| Two-turn demo | First message creates a plan; second message with approve (or standalone yes) resumes the same thread_id and runs execution. |
| Full TypeScript | Annotation.Root state with typed reducers. |
| Offline-friendly | Without OPENAI_API_KEY, planning and execution use clear mock outputs. |
cd langgraph-ts-workflow
npm installRequires Node.js 20+.
cp .env.example .env.local
# Edit .env.local — set OPENAI_API_KEY for live LLM callsnpm run devOptional: npm run dev -- --thread my-user-id to set the checkpoint thread_id.
The demo sends two turns automatically: a planning request, then an approval message.
npm run build
npm startnpm start runs node dist/index.js. A prestart hook runs npm run build automatically, so a plain npm start after clone still works (omit prestart in your fork if you prefer not to rebuild each time).
| Script | Purpose |
|---|---|
npm run dev |
Run the CLI with tsx (no build). |
npm run build |
Emit dist/ with tsc. |
npm start |
prestart → build, then node dist/index.js. |
npm test |
Vitest: utils, graph, checkpoint (no API key). |
npm run test:watch |
Vitest in watch mode. |
npm run test:integration |
Live OpenAI path (skipped without OPENAI_API_KEY). |
npm run test:checkpoint |
Checkpoint listing smoke test only. |
User message
│
▼
┌─────────────────────────────────────────────────────────────┐
│ StateGraph (LangGraph) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │
│ │ Planning │───▶│ Approval │───▶│ Execution│───▶│ END │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────┘ │
│ │ (conditional) │
│ └──────────▶ END (not approved) │
└─────────────────────────────────────────────────────────────┘
planning— Produces a markdown plan (OpenAI if configured, else mock). If a plan already exists from a prior checkpoint and the new user message is clearly an approval, it marks the plan approved without re-planning.approval— Routes toexecutiononly whenpendingApproval.approvedis true.execution— Final response; clearspendingApproval(the reducer treatsnullas an explicit clear, not “keep previous”).
Checkpoints are keyed by configurable.thread_id so the same conversation can continue across process restarts when using a durable checkpointer.
import { MemorySaver } from "@langchain/langgraph";
import { agentGraph } from "./workflows/agent.js";
const agent = agentGraph.compile({ checkpointer: new MemorySaver() });
await agent.invoke(input, { configurable: { thread_id: "user-123" } });messages— Appended via reducer (HumanMessage/AIMessage).pendingApproval—{ approved, planText, message? } | null; returnnullfrom a node to clear pending approval (the channel reducer usesundefinedto mean “no update” andnullto mean “clear”).currentStep—"planning" | "approval" | "execution" | "done"for observability.
OpenAI is configured in src/nodes/model.ts (getChatModel). Planning uses temperature 0.2, execution 0.3. Without OPENAI_API_KEY, nodes use deterministic mock text.
Execution passes the first human message in state as the original user goal (and the latest human message separately), so a second turn that only says “approve” does not replace the task for the model or the mock output.
docker compose up -d ollamaThis exposes Ollama on http://localhost:11434. To use it from this repo, add ChatOllama in src/nodes/planning.ts / execution.ts (optional extension — not wired by default).
Vitest loads .env.local / .env via tests/setup.ts (same idea as the CLI), so OPENAI_API_KEY in a local env file is visible to npm run test:integration.
npm test # unit + graph + checkpoint (no API key)
npm run test:integration # live OpenAI test (skipped if OPENAI_API_KEY unset)
npm run test:checkpoint # checkpoint listing smoke testlanggraph-ts-workflow/
├── src/
│ ├── state.ts # Annotation.Root state
│ ├── nodes/ # planning, approval, execution, model, utils
│ ├── workflows/
│ │ └── agent.ts # StateGraph definition
│ ├── checkpointers/
│ │ └── memory.ts # MemorySaver factory
│ └── index.ts # CLI demo
├── tests/
│ ├── setup.ts # loads .env.local / .env for vitest
│ ├── utils.test.ts
│ ├── graph.test.ts
│ ├── checkpoint.test.ts
│ └── integration.test.ts # live OpenAI (skipped without key)
├── .env.example
├── .gitignore
├── docker-compose.yml
├── LICENSE
├── package.json
├── tsconfig.json
└── vitest.config.ts
| Issue | What to try |
|---|---|
npm start fails with missing dist/ |
Run npm run build, or use plain npm start (runs prestart → build). |
| Integration test always skipped | Set OPENAI_API_KEY in .env.local / .env or the shell; vitest loads those files in tests/setup.ts. |
| Approval ignored on first message | Long prompts that contain “yes” are intentionally not treated as approval; use approve / proceed or a standalone yes. |
MIT — see LICENSE.