DEV Community

Atlas Whoff
Atlas Whoff

Posted on

LangGraph for Stateful AI Agents: When Your Claude App Needs a State Machine

Most Claude integrations follow the same pattern: user sends message, LLM responds, done. That works until your agent needs to:

  • Pause mid-task and wait for human approval
  • Branch into parallel sub-tasks
  • Recover from a failed tool call without restarting
  • Remember what it decided 12 steps ago

This is where LangGraph comes in. It's a state machine framework for LLM agents — and it solves a genuinely hard problem.

The Problem With Linear Agent Chains

Consider a research agent that:

  1. Searches the web
  2. Reads 5 articles
  3. Synthesizes a report
  4. Asks a human to verify before publishing

With a simple loop, if step 3 fails partway through, you start over. If the human says "redo step 2 with different search terms", you have no clean way to re-enter at step 2.

LangGraph models your agent as a directed graph where:

  • Nodes = functions that transform state
  • Edges = conditional logic that decides what runs next
  • State = a typed object that persists across the entire run

Basic Setup (TypeScript)

npm install @langchain/langgraph @langchain/anthropic
Enter fullscreen mode Exit fullscreen mode
import { StateGraph, END } from '@langchain/langgraph';
import { Annotation } from '@langchain/langgraph';

// Define your agent's state
const AgentState = Annotation.Root({
  query: Annotation<string>,
  searchResults: Annotation<string[]>({
    reducer: (existing, update) => [...existing, ...update],
    default: () => [],
  }),
  report: Annotation<string | null>({
    default: () => null,
  }),
  humanApproved: Annotation<boolean>({
    default: () => false,
  }),
  messages: Annotation<BaseMessage[]>({
    reducer: (existing, update) => [...existing, ...update],
    default: () => [],
  }),
});
Enter fullscreen mode Exit fullscreen mode

Building the Graph

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

async function searchNode(state: typeof AgentState.State) {
  // Your search logic
  const results = await webSearch(state.query);
  return { searchResults: results };
}

async function synthesizeNode(state: typeof AgentState.State) {
  const response = await client.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 2000,
    messages: [{
      role: 'user',
      content: `Synthesize these search results into a report:\n\n${state.searchResults.join('\n\n')}`
    }]
  });

  return {
    report: response.content[0].type === 'text' ? response.content[0].text : null
  };
}

async function humanReviewNode(state: typeof AgentState.State) {
  // In production, this would send an email/Slack message and pause
  // For demo: auto-approve
  console.log('Report ready for review:', state.report?.slice(0, 200));
  return { humanApproved: true };
}

// Conditional edge: should we publish or request revision?
function shouldPublish(state: typeof AgentState.State): 'publish' | 'revise' {
  if (state.humanApproved) return 'publish';
  return 'revise';
}

async function publishNode(state: typeof AgentState.State) {
  console.log('Publishing:', state.report);
  return {};
}

// Build the graph
const graph = new StateGraph(AgentState)
  .addNode('search', searchNode)
  .addNode('synthesize', synthesizeNode)
  .addNode('human_review', humanReviewNode)
  .addNode('publish', publishNode)
  .addEdge('__start__', 'search')
  .addEdge('search', 'synthesize')
  .addEdge('synthesize', 'human_review')
  .addConditionalEdges(
    'human_review',
    shouldPublish,
    { publish: 'publish', revise: 'search' } // loop back on revise
  )
  .addEdge('publish', '__end__');

const app = graph.compile();
Enter fullscreen mode Exit fullscreen mode

Running the Agent

const result = await app.invoke({
  query: 'Latest AI agent frameworks 2026',
  searchResults: [],
  report: null,
  humanApproved: false,
  messages: [],
});

console.log(result.report);
Enter fullscreen mode Exit fullscreen mode

Human-in-the-Loop Interrupts

This is where LangGraph gets powerful. You can pause execution at any node and resume later:

import { MemorySaver } from '@langchain/langgraph';

const checkpointer = new MemorySaver();

// Compile with checkpointing + interrupt before human_review
const appWithHuman = graph.compile({
  checkpointer,
  interruptBefore: ['human_review'],
});

const threadId = 'run-' + Date.now();

// First invocation: runs until human_review, then pauses
const firstRun = await appWithHuman.invoke(
  { query: 'Latest AI agent frameworks 2026' },
  { configurable: { thread_id: threadId } }
);

console.log('Paused for review. Report so far:', firstRun.report?.slice(0, 100));

// Later — after human reviews and approves:
const finalResult = await appWithHuman.invoke(
  { humanApproved: true }, // inject approval into state
  { configurable: { thread_id: threadId } } // resume same thread
);
Enter fullscreen mode Exit fullscreen mode

The agent picks up exactly where it left off. State is persisted between invocations.

Parallel Sub-Agents

For complex research tasks, run sub-tasks in parallel:

async function parallelResearchNode(state: typeof AgentState.State) {
  // Fan out to multiple Claude instances in parallel
  const [techResults, marketResults, competitorResults] = await Promise.all([
    searchNode({ ...state, query: state.query + ' technical implementation' }),
    searchNode({ ...state, query: state.query + ' market analysis' }),
    searchNode({ ...state, query: state.query + ' competitor landscape' }),
  ]);

  return {
    searchResults: [
      ...techResults.searchResults,
      ...marketResults.searchResults,
      ...competitorResults.searchResults,
    ]
  };
}
Enter fullscreen mode Exit fullscreen mode

When to Use LangGraph vs. a Simple Loop

Use LangGraph when:

  • Your agent has conditional branching (different paths based on output)
  • You need human-in-the-loop at specific steps
  • You need to retry specific steps without restarting
  • Your agent runs long enough that state persistence matters
  • Multiple sub-agents need to coordinate through shared state

Skip LangGraph when:

  • Single-shot queries (question → answer)
  • Simple tool-use pipelines with no branching
  • You control the full loop yourself and don't need the overhead

Production Considerations

  1. Persistence: MemorySaver is in-memory only. For production, use PostgresSaver or SqliteSaver from @langchain/langgraph-checkpoint-postgres

  2. Observability: LangGraph integrates with LangSmith for tracing — every node execution is logged with inputs/outputs

  3. Error handling: Use RetryPolicy on nodes that call external APIs:

graph.addNode('search', searchNode, {
  retryPolicy: { maxAttempts: 3, initialInterval: 1000 }
});
Enter fullscreen mode Exit fullscreen mode
  1. Streaming: Stream intermediate state updates to your UI:
for await (const chunk of await app.stream(inputs)) {
  console.log('State update:', chunk);
}
Enter fullscreen mode Exit fullscreen mode

The Honest Tradeoff

LangGraph adds complexity. For a simple Q&A bot, it's overkill. But for production AI applications that need auditability, human oversight, and recovery from partial failures — the state machine model is the right abstraction.

The alternative is reimplementing all of this yourself with ad-hoc state objects and manual retry logic. Most teams underestimate how quickly that becomes unmaintainable.


Building a production AI agent? The AI SaaS Starter Kit at whoffagents.com includes agent scaffolding, tool definitions, and a structured approach to human-in-the-loop workflows — without the boilerplate.

Top comments (0)