How to build AI agents with LangChain and LangGraph in TypeScript

By

Updated

Tagged: AI

Learn LangChain and LangGraph with TypeScript: models, prompts, tools, agents, memory, streaming, RAG, graphs, persistence, and human approval.

LangChain gives us model integrations, messages, prompts, tools, agents, middleware, retrieval, and a common interface across providers. LangGraph is a runtime for stateful workflows: we define steps, connect them, save their state, pause them, and resume them later.

LangChain agents run on LangGraph internally, but you can use either library alone. A LangGraph node can call any API or run plain TypeScript.

In this guide we’ll start with one model call and end with a durable workflow that stops for human approval, using TypeScript and the LangChain v1+ API. If models, prompts, tokens, and tools are new to you, start with my free AI Fundamentals course.

The short version

Here is the mental model I use:

Part What it does
Model provider Runs the language model
LangChain model Gives us one interface for different providers
Tool Lets the model read data or perform an action
LangChain agent Runs the model-tool-model loop
Middleware Adds rules around that loop
LangGraph state Holds the current data for a workflow
LangGraph node Performs one step
LangGraph edge Chooses the next step
Checkpointer Saves state after steps
Store Saves information across different conversations
LangSmith Records traces and helps us evaluate behavior

My advice is to start at the top of this table and move down only when you can name the problem the next row solves. If one model call does the job, you don’t need a graph, and if a fixed sequence of functions works, you don’t need an agent.

Where the libraries sit

LangChain doesn’t run a model. It connects to a provider such as OpenAI, Anthropic, Google, or a local Ollama server, which handles inference and bills you for it. If you call Claude through the official SDK rather than only through LangChain, check the Anthropic SDK upgrade notes before a major bump.

LangChain sits between our application and that provider:

our app -> LangChain -> model provider

When tools are involved, the flow becomes a loop:

user message
  -> model
  -> tool request
  -> our tool code
  -> tool result
  -> model
  -> final answer

LangGraph gives that work an explicit structure, which can branch, loop, pause, resume, and save state between steps:

input -> classify -> retrieve -> draft -> review -> output
                         ^          |
                         |----------|

Use a provider SDK directly for one or two model calls, LangChain when you want tools, an agent loop, structured output, or middleware, and LangGraph when a flowchart explains your application better than a single function does. Often you’ll use both:

LangGraph controls the workflow
LangChain handles models, messages, and tools inside its nodes

A note about old LangChain tutorials

LangChain changed a lot before version 1. You may find tutorials using names such as:

  • LLMChain
  • ConversationChain
  • initializeAgentExecutorWithOptions()
  • createReactAgent() from LangGraph prebuilts
  • imports from langchain/chains

Modern LangChain centers its API around createAgent(), tools, messages, models, and middleware. Legacy chains moved to @langchain/classic, and the LangChain v1 migration guide lists the changes, so check the imports before copying older code.

For LangGraph state, this guide uses the Zod-based StateSchema. You’ll also see Annotation.Root(), which still exists.

Create the project

LangChain’s current JavaScript packages require Node.js 22 or newer. Create a new project:

mkdir bookstore-agent
cd bookstore-agent
npm init -y

Install the core packages:

npm install langchain @langchain/core @langchain/openai @langchain/langgraph zod dotenv

Terminal showing the bookstore-agent project creation and LangChain package installation

We’ll also build a small RAG example later, which needs these:

npm install @langchain/classic @langchain/textsplitters

Install TypeScript and tsx:

npm install -D typescript tsx @types/node

Create a src directory:

mkdir src

Add this script to package.json:

{
  "scripts": {
    "dev": "tsx src/index.ts"
  }
}

Package.json open in the editor with the tsx development script highlighted

Create a .env file:

OPENAI_API_KEY=your_key

Add it to .gitignore, so you never commit the key:

.env

I use OpenAI in the examples, but the ideas are provider-independent. You can replace @langchain/openai with another LangChain model integration.

Make your first model call

Let’s start with the smallest useful program. Create src/index.ts:

import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'

const model = new ChatOpenAI({
  model: 'gpt-5.4-mini',
})

const response = await model.invoke(
  'Explain what an embedding is in 2 short sentences',
)

console.log(response.text)

gpt-5.4-mini is the model used in the current LangChain examples. Model names change, so replace it with one available in your account if needed.

Run it:

npm run dev

invoke() sends one input and waits for the complete output, an AIMessage whose text property holds the text. The message can also carry tool calls, reasoning blocks, citations, usage data, and provider metadata.

Models have a common interface

LangChain wraps different providers behind a similar interface. The methods you’ll use most are:

  • invoke() for one complete result
  • stream() for incremental output
  • batch() for multiple independent inputs
  • bindTools() to make tools available to a model
  • withStructuredOutput() to request validated data

Providers still differ in reasoning controls, image support, caching, and built-in tools, so my advice is to keep provider-specific configuration in one file.

Messages

Chat models receive a list of messages. The common roles are system for instructions, user for the user’s input, assistant for a previous model response, and tool for the result of a tool call.

We can pass message objects directly:

import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { HumanMessage, SystemMessage } from 'langchain'

const model = new ChatOpenAI({
  model: 'gpt-5.4-mini',
})

const response = await model.invoke([
  new SystemMessage('You teach TypeScript using simple language.'),
  new HumanMessage('What is a union type?'),
])

console.log(response.text)

Or plain objects:

const response = await model.invoke([
  { role: 'system', content: 'You teach TypeScript using simple language.' },
  { role: 'user', content: 'What is a union type?' },
])

I use plain objects when the code is simple and switch to the message classes when I need stronger types or a message-specific feature.

A modern model can also return reasoning, an image, audio, a citation, or a tool call. LangChain exposes these as normalized contentBlocks:

const response = await model.invoke('Explain TypeScript interfaces')

for (const block of response.contentBlocks) {
  if (block.type === 'text') {
    console.log(block.text)
  }
}

For a text-only response, response.text is enough.

Create a reusable prompt

Prompt templates help once we have several messages or reusable variables. Here’s a small teaching chain:

import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { ChatPromptTemplate } from '@langchain/core/prompts'

const model = new ChatOpenAI({
  model: 'gpt-5.4-mini',
})

const prompt = ChatPromptTemplate.fromMessages([
  ['system', 'You teach programming using simple language.'],
  ['user', 'Explain {topic} with one small example.'],
])

const chain = prompt.pipe(model)

const response = await chain.invoke({
  topic: 'JavaScript closures',
})

console.log(response.text)

pipe() connects two runnable components: the prompt turns an object into messages, and the model turns messages into an AIMessage. This style is called the LangChain Expression Language, or LCEL. You don’t have to use it, and for a case like this I find a normal function easier to read:

async function explain(topic: string) {
  const messages = await prompt.invoke({ topic })
  return model.invoke(messages)
}

Ask for structured output

Text is fine when a person reads the answer. When code has to consume it, we need data, and we can describe the shape we expect with Zod:

import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { z } from 'zod'

const Lesson = z.object({
  title: z.string(),
  summary: z.string(),
  prerequisites: z.array(z.string()),
})

const model = new ChatOpenAI({
  model: 'gpt-5.4-mini',
})

const structuredModel = model.withStructuredOutput(Lesson)

const lesson = await structuredModel.invoke(
  'Plan a beginner lesson about JavaScript promises',
)

console.log(lesson.title)
console.log(lesson.prerequisites)

The result is an object validated against the schema, which beats asking for JSON in the prompt and calling JSON.parse() yourself. I use it for classification, extraction, routing decisions, and database records. Keep the schema small, because every extra field is another place the model can get wrong.

Tools

A tool is a function the model can ask our application to run, for example to read an order from a database or send an email. The model only produces a request with a name and arguments. Our application validates the arguments and runs the function, and the result goes back to the model.

Let’s create an order lookup tool:

import { tool } from 'langchain'
import { z } from 'zod'

const getOrder = tool(
  async ({ orderId }) => {
    const orders = {
      'BOOK-2048': {
        status: 'shipped',
        expectedDelivery: 'September 14',
      },
    }

    return orders[orderId as keyof typeof orders] ?? {
      status: 'not_found',
    }
  },
  {
    name: 'get_order',
    description: 'Get the current status of a bookstore order',
    schema: z.object({
      orderId: z.string().describe('The order ID, such as BOOK-2048'),
    }),
  },
)

The name and description end up in the model’s context, so write them for the model. The schema validates the input, but authorization still happens inside your application.

Test the tool directly before putting a model in front of it, because an agent on top only makes a failure harder to understand:

const result = await getOrder.invoke({
  orderId: 'BOOK-2048',
})

console.log(result)

Create a LangChain agent

Now we can let a model decide when to call the tool. Create src/agent.ts:

import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { createAgent, tool } from 'langchain'
import { z } from 'zod'

const getOrder = tool(
  async ({ orderId }) => {
    if (orderId === 'BOOK-2048') {
      return {
        orderId,
        status: 'shipped',
        expectedDelivery: 'September 14',
      }
    }

    return {
      orderId,
      status: 'not_found',
    }
  },
  {
    name: 'get_order',
    description: 'Get the current status of a bookstore order',
    schema: z.object({
      orderId: z.string().describe('The order ID, such as BOOK-2048'),
    }),
  },
)

const model = new ChatOpenAI({
  model: 'gpt-5.4-mini',
})

const agent = createAgent({
  model,
  tools: [getOrder],
  systemPrompt: `
You help customers with bookstore orders.
Use the order tool when an order ID is available.
Never invent an order status.
Keep answers short.
  `.trim(),
})

const result = await agent.invoke({
  messages: [
    {
      role: 'user',
      content: 'Where is order BOOK-2048?',
    },
  ],
})

console.log(result.messages.at(-1)?.text)

Run it:

npx tsx src/agent.ts

The agent sends the conversation and tool descriptions to the model, reads the tool request, validates the input, runs getOrder, adds the result to the conversation, and calls the model again until it has a final answer. createAgent() builds that loop on LangGraph, without us defining the graph.

Fig 1 · 1/4The model and tool loop
Ask the model

The word agent makes this sound more intelligent than it is. The core loop is small:

call model
if the model asks for tools:
  run tools
  call model again
otherwise:
  finish

The hard work is around it: context, permissions, failures, cost limits, saved state, and approval before risky actions.

Give the agent structured output

An agent can also return validated data. Define a response schema:

const SupportAnswer = z.object({
  answer: z.string(),
  orderFound: z.boolean(),
  needsHuman: z.boolean(),
})

Pass it to createAgent():

const agent = createAgent({
  model,
  tools: [getOrder],
  responseFormat: SupportAnswer,
  systemPrompt: 'Help customers with bookstore orders.',
})

Read the result:

const result = await agent.invoke({
  messages: [
    {
      role: 'user',
      content: 'Where is order BOOK-2048?',
    },
  ],
})

console.log(result.structuredResponse)

The validated value lives in structuredResponse.

Add short-term memory

Our agent forgets everything between calls. Short-term memory fixes that for one conversation thread. Add a MemorySaver checkpointer:

import { MemorySaver } from '@langchain/langgraph'

const checkpointer = new MemorySaver()

const agent = createAgent({
  model,
  tools: [getOrder],
  checkpointer,
})

Give the conversation a thread ID:

const config = {
  configurable: {
    thread_id: 'support-42',
  },
}

Invoke the agent twice with the same thread:

await agent.invoke(
  {
    messages: [
      {
        role: 'user',
        content: 'My order is BOOK-2048',
      },
    ],
  },
  config,
)

const result = await agent.invoke(
  {
    messages: [
      {
        role: 'user',
        content: 'When will it arrive?',
      },
    ],
  },
  config,
)

console.log(result.messages.at(-1)?.text)

The second call can use the order ID from the first. MemorySaver keeps everything in the current process, which is fine for development. In production, use a database-backed checkpointer, or every restart erases the threads.

Long-term memory with a store

Short-term memory belongs to one thread and holds the conversation and workflow state. Long-term memory is shared across threads, for user preferences, application facts, or other durable data, and LangGraph calls its interface a store:

import { InMemoryStore } from '@langchain/langgraph'

const store = new InMemoryStore()

await store.put(
  ['users', 'user-42'],
  'preferences',
  {
    answerLength: 'short',
    language: 'Italian',
  },
)

const preferences = await store.get(
  ['users', 'user-42'],
  'preferences',
)

console.log(preferences?.value)

Pass the store to the agent:

const agent = createAgent({
  model,
  tools: [getOrder],
  store,
})

The model won’t use the store on its own. A tool or a middleware decides what to save and when to load it into context. I prefer it this way, because an agent that remembers everything gets expensive and raises privacy questions.

Keep identity out of model-controlled arguments

Suppose a tool needs the current user ID. Don’t let the model choose it like this, because it could request another user’s ID:

schema: z.object({
  userId: z.string(),
})

Pass trusted data through runtime context instead:

const Context = z.object({
  userId: z.string(),
})

const agent = createAgent({
  model,
  tools: [getOrder],
  contextSchema: Context,
})

await agent.invoke(
  {
    messages: [
      {
        role: 'user',
        content: 'Show my latest order',
      },
    ],
  },
  {
    context: {
      userId: 'user-42',
    },
  },
)

Tools read that context through ToolRuntime, and the model never sees the user ID.

Stream an agent response

Streaming lets the user see progress before the run finishes. LangChain’s event streaming has separate streams for messages, tool calls, state values, and other events. Here’s the text stream:

const stream = await agent.streamEvents(
  {
    messages: [
      {
        role: 'user',
        content: 'Where is order BOOK-2048?',
      },
    ],
  },
  {
    version: 'v3',
  },
)

for await (const message of stream.messages) {
  for await (const token of message.text) {
    process.stdout.write(token)
  }
}

await stream.output

stream.output resolves to the final agent state. You still need to get those chunks to the browser with a streaming HTTP response, server-sent events, or WebSockets, which I explain in How to stream LLM responses with server-sent events.

Use middleware to control the agent loop

Middleware runs around model and tool calls, so it’s the place for rules that apply to the whole agent, like retries, call limits, logging, summarization, guardrails, and human approval:

import {
  createAgent,
  summarizationMiddleware,
  toolRetryMiddleware,
} from 'langchain'

const agent = createAgent({
  model,
  tools: [getOrder],
  middleware: [
    toolRetryMiddleware({
      maxRetries: 2,
    }),
    summarizationMiddleware({
      model: 'openai:gpt-5.4-mini',
      trigger: {
        tokens: 4000,
      },
      keep: {
        messages: 20,
      },
    }),
  ],
})

Retries help with temporary failures such as a network blip, not with invalid input. Summarization shrinks old history and can drop details you needed, so test it with real conversations.

Require approval for risky tools

Reading an order is low risk. Issuing a refund moves money, so it shouldn’t run under the same policy. LangChain provides human-in-the-loop middleware for this:

import {
  createAgent,
  humanInTheLoopMiddleware,
} from 'langchain'
import { MemorySaver } from '@langchain/langgraph'

const agent = createAgent({
  model,
  tools: [getOrder, issueRefund],
  middleware: [
    humanInTheLoopMiddleware({
      interruptOn: {
        get_order: false,
        issue_refund: {
          allowedDecisions: ['approve', 'edit', 'reject'],
        },
      },
    }),
  ],
  checkpointer: new MemorySaver(),
})

The checkpointer is required because the agent saves its place while it waits. The approval screen should show the exact target, amount, and effect. For high-impact operations I also cover quotes, expiring approvals, idempotency, ambiguous failures, and audit evidence in How to let an AI agent perform irreversible actions safely.

Build a small RAG tool

Retrieval-augmented generation, or RAG, finds relevant documents and places them in the model’s context. We split documents into chunks, turn the chunks into embeddings, and store them so similar chunks can be retrieved. Here’s a tiny in-memory version:

import { Document } from '@langchain/core/documents'
import { OpenAIEmbeddings } from '@langchain/openai'
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'

const documents = [
  new Document({
    pageContent: `
Orders can be cancelled before they ship.
Shipped orders cannot be cancelled.
Customers can return shipped books within 30 days.
    `.trim(),
    metadata: {
      source: 'returns-policy',
    },
  }),
  new Document({
    pageContent: `
Standard delivery takes 3 to 5 business days.
Express delivery takes 1 to 2 business days.
    `.trim(),
    metadata: {
      source: 'delivery-policy',
    },
  }),
]

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 400,
  chunkOverlap: 80,
})

const chunks = await splitter.splitDocuments(documents)

const embeddings = new OpenAIEmbeddings({
  model: 'text-embedding-3-small',
})

const vectorStore = await MemoryVectorStore.fromDocuments(
  chunks,
  embeddings,
)

const retriever = vectorStore.asRetriever({
  k: 3,
})

MemoryVectorStore comes from @langchain/classic, where the JavaScript integration still lives. It does a linear search in memory and loses everything when the process stops, so use a persistent vector database in production.

Now expose retrieval as a tool:

const searchPolicies = tool(
  async ({ query }) => {
    const documents = await retriever.invoke(query)

    return documents
      .map(document => document.pageContent)
      .join('\n\n')
  },
  {
    name: 'search_policies',
    description: 'Search the bookstore delivery and returns policies',
    schema: z.object({
      query: z.string(),
    }),
  },
)

Add it to the agent:

const agent = createAgent({
  model,
  tools: [getOrder, searchPolicies],
  systemPrompt: `
Answer bookstore support questions.
Search policies before answering policy questions.
If the retrieved text does not answer the question, say you do not know.
  `.trim(),
})

This is agentic RAG: the model decides when to retrieve. It can also choose the wrong tool or skip retrieval. For a predictable documentation bot I often prefer 2-step RAG:

always retrieve -> then generate

There’s no decision for the model to get wrong, and latency stays predictable. I’d save the agent for cases with several data sources where the model has to pick one. Chunk size, overlap, and retrieval count have a large effect on quality and cost, and I explain those choices in RAG chunking explained.

What LangGraph adds

Sometimes the application needs a shape other than the prebuilt loop: classify a request before choosing an agent, run searches in parallel, or wait hours for a person and resume after a restart. LangGraph lets us define that shape with state (the current data), nodes (functions that read state and return updates), and edges (connections that choose the next node).

Build your first graph

Let’s build a graph without a model first, so the mechanics are easier to see. Create src/graph.ts:

import { z } from 'zod'
import {
  END,
  START,
  StateGraph,
  StateSchema,
  type GraphNode,
} from '@langchain/langgraph'

const SupportState = new StateSchema({
  message: z.string(),
  normalizedMessage: z.string().optional(),
  reply: z.string().optional(),
})

const normalize: GraphNode<typeof SupportState> = state => {
  return {
    normalizedMessage: state.message.trim().toLowerCase(),
  }
}

const answer: GraphNode<typeof SupportState> = state => {
  return {
    reply: `You wrote: ${state.normalizedMessage}`,
  }
}

const graph = new StateGraph(SupportState)
  .addNode('normalize', normalize)
  .addNode('answer', answer)
  .addEdge(START, 'normalize')
  .addEdge('normalize', 'answer')
  .addEdge('answer', END)
  .compile()

const result = await graph.invoke({
  message: '  Where is my order?  ',
})

console.log(result.reply)

Run it:

npx tsx src/graph.ts

The graph follows this path, where START and END are special virtual nodes:

START -> normalize -> answer -> END

compile() checks the graph structure and creates the runnable object we can invoke or stream.

Notice that normalize() only returns normalizedMessage. LangGraph merges the returned update into the existing state, replacing the old value of each returned field, so a node updates only the fields it owns.

Add a model to a node

A node is a function, so it can call LangChain models. Replace the answer node with this version:

import { ChatOpenAI } from '@langchain/openai'

const model = new ChatOpenAI({
  model: 'gpt-5.4-mini',
})

const answer: GraphNode<typeof SupportState> = async state => {
  const response = await model.invoke([
    {
      role: 'system',
      content: 'Answer bookstore support questions in one short paragraph.',
    },
    {
      role: 'user',
      content: state.normalizedMessage ?? state.message,
    },
  ])

  return {
    reply: response.text,
  }
}

LangGraph doesn’t care that this node calls an LLM. A node that queries PostgreSQL would run exactly the same way.

Add conditional edges

A fixed edge always goes to the same node, while a conditional edge calls a routing function. Suppose we classify requests as questions or refund requests:

const Classification = z.object({
  category: z.enum(['question', 'refund']),
})

const classifier = model.withStructuredOutput(Classification)

const classify: GraphNode<typeof SupportState> = async state => {
  const result = await classifier.invoke(
    `Classify this bookstore request: ${state.message}`,
  )

  return {
    category: result.category,
  }
}

Add category to the state schema:

const SupportState = new StateSchema({
  message: z.string(),
  category: z.enum(['question', 'refund']).optional(),
  reply: z.string().optional(),
})

Now route from classify:

const graph = new StateGraph(SupportState)
  .addNode('classify', classify)
  .addNode('answer', answer)
  .addNode('prepareRefund', prepareRefund)
  .addEdge(START, 'classify')
  .addConditionalEdges(
    'classify',
    state => state.category ?? 'question',
    {
      question: 'answer',
      refund: 'prepareRefund',
    },
  )
  .addEdge('answer', END)
  .addEdge('prepareRefund', END)
  .compile()

The model only classifies the text. The schema limits it to question or refund, and the route map decides which node each one goes to, so the model can’t invent a destination.

Reducers

When we want to combine updates instead of replacing them, we use a reducer. Suppose every node adds an audit event. Use a ReducedValue:

import {
  ReducedValue,
  StateSchema,
} from '@langchain/langgraph'

const State = new StateSchema({
  message: z.string(),
  events: new ReducedValue(
    z.array(z.string()).default(() => []),
    {
      reducer: (current, update) => [
        ...current,
        ...update,
      ],
    },
  ),
})

A node can now return:

return {
  events: ['request_classified'],
}

The reducer appends that event to the array. Reducers become essential when parallel nodes update the same field, because without one LangGraph can’t safely combine those writes.

Conversation state needs its own merge behavior, and LangGraph provides MessagesValue for it:

import {
  MessagesValue,
  StateSchema,
} from '@langchain/langgraph'

const ChatState = new StateSchema({
  messages: MessagesValue,
})

Nodes return only the new messages:

const callModel: GraphNode<typeof ChatState> = async state => {
  const response = await model.invoke(state.messages)

  return {
    messages: [response],
  }
}

MessagesValue appends messages and updates the ones with matching IDs, which a plain array with replacement behavior would get wrong.

Loops

An agent loop is a cycle, and LangGraph supports cycles directly:

model -> tools -> model -> tools -> model -> END

A common custom agent shape is:

START -> model
           |
           | tool call
           v
         tools
           |
           +------> model

model with no tool call -> END

LangGraph provides a prebuilt ToolNode that executes tool calls and a toolsCondition router that checks whether the model requested one. Build this yourself only when createAgent() doesn’t give you the routing or state you need, and set model-call and recursion limits so a cycle can’t become an expensive infinite loop.

Run nodes in parallel

A node can have several outgoing edges, and LangGraph runs those destinations in the next super-step, potentially in parallel:

graph
  .addEdge('plan', 'searchDocs')
  .addEdge('plan', 'searchOrders')

Both searches can start after plan. Connect them to a later node:

graph
  .addEdge('searchDocs', 'combine')
  .addEdge('searchOrders', 'combine')

Use reducers for fields both searches update.

When we don’t know the number of tasks while building the graph, like a planner that produces five research questions, LangGraph’s Send primitive creates the calls dynamically:

import { Send } from '@langchain/langgraph'

graph.addConditionalEdges('plan', state => {
  return state.questions.map(question => {
    return new Send('research', { question })
  })
})

This is the map-reduce shape:

plan -> map research tasks -> reduce results

Cap the list before creating the Send objects, because a model-generated fan-out with no hard budget can turn five questions into fifty.

Use Command when a node updates and routes

A conditional edge only chooses the next node. When a node needs to update state and choose the destination together, return a Command:

import { Command } from '@langchain/langgraph'

const checkRisk = state => {
  if (state.amount > 100) {
    return new Command({
      update: {
        risk: 'high',
      },
      goto: 'manualReview',
    })
  }

  return new Command({
    update: {
      risk: 'low',
    },
    goto: 'automaticReview',
  })
}

When adding this node, list its possible destinations:

graph.addNode('checkRisk', checkRisk, {
  ends: ['manualReview', 'automaticReview'],
})

For one node, use either static edges or Command routing. Mixing both can execute both paths.

Save graph state with a checkpointer

A compiled graph is stateless unless we give it a checkpointer:

import { MemorySaver } from '@langchain/langgraph'

const checkpointer = new MemorySaver()

const graph = builder.compile({
  checkpointer,
})

Invoke it with a thread ID:

const config = {
  configurable: {
    thread_id: 'refund-BOOK-2048',
  },
}

await graph.invoke(
  {
    message: 'Refund order BOOK-2048',
  },
  config,
)

The checkpointer saves a state snapshot at each super-step under that thread ID, which is what makes memory, human-in-the-loop pauses, fault recovery, and replay possible. In production, use a persistent checkpointer such as PostgreSQL or MongoDB.

Pause a graph for human input

interrupt() stops a graph and returns a value to the caller, and the graph waits until we resume it with a Command. Here’s the smallest approval node:

import { interrupt } from '@langchain/langgraph'

const requestApproval: GraphNode<typeof RefundState> = state => {
  const approved = interrupt({
    action: 'issue_refund',
    orderId: state.orderId,
    amount: state.amount,
  })

  return {
    approved: Boolean(approved),
  }
}

The value passed to interrupt() must be JSON-serializable. The first invocation pauses:

const config = {
  configurable: {
    thread_id: 'refund-BOOK-2048',
  },
}

const paused = await graph.invoke(
  {
    orderId: 'BOOK-2048',
    amount: 29,
  },
  config,
)

console.log(paused.__interrupt__)

Resume the same thread:

import { Command } from '@langchain/langgraph'

const completed = await graph.invoke(
  new Command({
    resume: true,
  }),
  config,
)

console.log(completed)

The resume value becomes the return value of interrupt(). It has to be the same thread ID, since a new one would start a different workflow.

A complete approval graph

This graph prepares a refund, asks for approval, then either applies or rejects it:

import {
  Command,
  END,
  MemorySaver,
  START,
  StateGraph,
  StateSchema,
  interrupt,
  type GraphNode,
} from '@langchain/langgraph'
import { z } from 'zod'

const RefundState = new StateSchema({
  orderId: z.string(),
  amount: z.number(),
  approved: z.boolean().optional(),
  status: z.enum([
    'prepared',
    'refunded',
    'rejected',
  ]).optional(),
})

const prepare: GraphNode<typeof RefundState> = state => {
  if (state.orderId !== 'BOOK-2048') {
    throw new Error('Order not found')
  }

  if (state.amount !== 29) {
    throw new Error('Refund amount does not match the order')
  }

  return {
    status: 'prepared',
  }
}

const approve: GraphNode<typeof RefundState> = state => {
  const approved = interrupt({
    action: 'issue_refund',
    orderId: state.orderId,
    amount: state.amount,
  })

  return {
    approved: Boolean(approved),
  }
}

const issueRefund: GraphNode<typeof RefundState> = async state => {
  console.log(`Refunding ${state.amount} for ${state.orderId}`)

  return {
    status: 'refunded',
  }
}

const reject: GraphNode<typeof RefundState> = () => {
  return {
    status: 'rejected',
  }
}

const graph = new StateGraph(RefundState)
  .addNode('prepare', prepare)
  .addNode('approve', approve)
  .addNode('issueRefund', issueRefund)
  .addNode('reject', reject)
  .addEdge(START, 'prepare')
  .addEdge('prepare', 'approve')
  .addConditionalEdges(
    'approve',
    state => state.approved ? 'yes' : 'no',
    {
      yes: 'issueRefund',
      no: 'reject',
    },
  )
  .addEdge('issueRefund', END)
  .addEdge('reject', END)
  .compile({
    checkpointer: new MemorySaver(),
  })

const config = {
  configurable: {
    thread_id: 'refund-BOOK-2048',
  },
}

const paused = await graph.invoke(
  {
    orderId: 'BOOK-2048',
    amount: 29,
  },
  config,
)

console.log(paused.__interrupt__)

const result = await graph.invoke(
  new Command({
    resume: true,
  }),
  config,
)

console.log(result.status)

In a real system, prepare would load the order from a trusted database, because the model should never decide the refund amount, and the final write would use an idempotency key.

Fig 2 · 1/4Pause a refund for approval
Prepare

Be careful with code before interrupt()

When a graph resumes, LangGraph re-enters the node containing the interrupt, so code before interrupt() can run again. Don’t send an email, charge a card, or update a database before the interrupt unless the operation is idempotent. The safe order is:

read and validate
-> interrupt for approval
-> perform the side effect

For long-running or replayable work, wrap side effects and non-deterministic operations in LangGraph task() functions, which save their results in checkpoints. They still need idempotency, because a failure can happen after the remote system accepts a request and before the task records success.

Durable execution is not exactly once

Durable execution means the workflow can continue from saved progress, but an external API is still not exactly-once. If the refund provider accepts a refund and the connection drops before our task gets the response, the workflow retries, and without an idempotency key or a status check we refund twice. Use provider idempotency keys, store operation IDs, and treat a timeout as an unknown outcome to reconcile.

Recover an ambiguous refund resultA refund can complete remotely before the response is lost. Retrying requires idempotency or reconciliation rather than assuming the refund failed.YesNoRefund provideraccepts requestResponse is lostWorkflow has unknownoutcomeCheck operation IDor provider statusRefund alreadycompleted?Reuse completedresultRetry withidempotency key

Inspect, replay, and fork

A checkpointer also lets us look inside a run. Read the current snapshot:

const state = await graph.getState(config)

console.log(state.values)
console.log(state.next)

Read the history:

for await (const snapshot of graph.getStateHistory(config)) {
  console.log(snapshot.metadata.step)
  console.log(snapshot.values)
  console.log(snapshot.next)
}

Each snapshot shows the state at a super-step boundary and the nodes scheduled next, which is how you debug a wrong branch.

You can also replay the later nodes from a saved checkpoint, or fork by changing the state at a checkpoint and taking a different path. This “time travel” can’t undo an email or a refund an earlier run already sent, and those nodes may run again, so keep it to computation and reads.

Compose graphs with subgraphs

A node can be another compiled graph, so we can put a focused workflow inside a larger one:

support graph
  -> billing subgraph
  -> delivery subgraph
  -> returns subgraph

Example:

const billingGraph = new StateGraph(BillingState)
  .addNode('checkInvoice', checkInvoice)
  .addNode('prepareAnswer', prepareAnswer)
  .addEdge(START, 'checkInvoice')
  .addEdge('checkInvoice', 'prepareAnswer')
  .compile()

const supportGraph = new StateGraph(SupportState)
  .addNode('classify', classify)
  .addNode('billing', billingGraph)
  .addEdge(START, 'classify')

Use subgraphs when part of the workflow has its own clear state and lifecycle. Wrapping every function in a graph only hides simple code behind more structure.

Graph API or Functional API?

This guide uses the Graph API, with state, nodes, and edges, because the control flow is visible and easy to draw. The Functional API uses normal TypeScript control flow with entrypoint() and task(), and fits when you already have procedural code and mainly want persistence, interrupts, and streaming. Both run on the same runtime and can be combined.

LangChain agents are LangGraph graphs

createAgent() returns a compiled LangGraph graph. That’s why a LangChain agent supports state, checkpointers, thread IDs, interrupts, stores, and streaming, and why you can place a complete agent inside a larger custom graph as a node or subgraph:

deterministic router
  -> support agent
  -> sales agent
  -> billing workflow with approval

The outer graph controls the business process, and each inner agent handles an open-ended language task.

Test the deterministic parts first

Most of a reliable agent application is normal code, so test nodes and routing functions directly:

import assert from 'node:assert/strict'
import test from 'node:test'

function route(state: {
  category: 'question' | 'refund'
}) {
  return state.category === 'refund'
    ? 'prepareRefund'
    : 'answer'
}

test('refund requests go to approval flow', () => {
  assert.equal(
    route({ category: 'refund' }),
    'prepareRefund',
  )
})

The same fast, model-free tests can cover authorization, timeouts, approval rejection, duplicate requests, malformed model output, and loop limits.

Test model behavior with datasets

Unit tests can’t tell you how a model behaves on real language. For that, keep a dataset of representative inputs:

const cases = [
  {
    input: 'Where is BOOK-2048?',
    expectedTool: 'get_order',
  },
  {
    input: 'What is your return policy?',
    expectedTool: 'search_policies',
  },
  {
    input: 'Refund every order',
    expectedTool: null,
  },
]

For each case, record the answer, the tools and arguments, the number of model calls, latency, and token usage, and run the dataset again whenever you change a prompt, model, tool description, or retrieval setup. LangSmith can run these evaluations, but a script comparing runs works too.

Trace what the agent did

An agent can produce the right answer for the wrong reason, and you only see that in its steps. LangSmith tracing records model calls, tool calls, state transitions, timing, and errors. Enable it with environment variables:

LANGSMITH_TRACING=true
LANGSMITH_API_KEY=your_key
LANGSMITH_PROJECT=bookstore-agent

A standard agent needs no extra tracing code. Keep secrets and unnecessary personal data out of production traces.

Before you ship a graph

Most production work is the same as for any web app: server-side keys, validation, authorization inside each tool, timeouts, and treating retrieved text as untrusted. A few things are specific to LangGraph.

Move threads from MemorySaver to a persistent checkpointer, and treat a change to the state schema like a database migration, because saved threads still hold the old shape. Set recursion and model-call limits on every cycle and a hard cap on every Send fan-out. Keep trusted identity in runtime context and namespace store keys by user and tenant. Put side effects after interrupt() or inside task() with an idempotency key, and decide what cancelling a paused thread means for your business. Finally, log who resumed each approval and with what value.

Common mistakes

The most common one is starting with a multi-agent system. One agent with two good tools is easier to build and test than five agents talking to each other. A long tool list causes a smaller version of the same problem, with more context and more selection mistakes, so expose only the tools the current step needs.

Prices, balances, ownership, and authorization belong in code and databases, where a prompt can’t override them. Memory shouldn’t be a transcript dump, because old messages distract the model and expose private data. Retry network failures, but not denied permissions or business errors, and never a write without idempotency. And if the steps are always the same, write them as code or a graph instead of hiding them inside an agent that can get the order wrong.

When I would use LangChain

I would use LangChain for a focused agent feature with a small set of tools.

For example, I could add an assistant to my hosting picker that reads its hand-checked hosting data and explains why one platform fits a project. The tools would retrieve structured provider facts, and the model would turn them into an answer. I would start with createAgent(), one retrieval tool, structured output, and traces, with no custom graph until the product needs explicit stages.

For a streaming chat interface with a few tools, I would also compare it with the Vercel AI SDK, which can be a smaller fit when UI streaming is the main problem.

When I would use LangGraph

I would use LangGraph when the application needs a durable business workflow. A domain registration flow, for example, has clear stages:

check availability
-> get an authoritative price
-> show the exact quote
-> wait for approval
-> register once
-> verify ownership

The model may help explain the quote or parse a request, while code controls the price check, approval, registration, and verification. I would also use it for research that fans out across sources and pauses when the evidence is weak. For a single completion, a classifier, or a fixed two-function pipeline, plain TypeScript is clearer.

Where to go from here

Build the bookstore agent with the single get_order tool and ask it a few real questions with tracing on, so you see every model and tool call. Then add one piece at a time, like a checkpointer when the conversation has to survive or the RAG tool when it needs your documents. When you need to control the workflow itself, start from the approval graph above.

Want me to talk about your product? You can sponsor this site.

Related posts about ai: