A deep dive into LangChain and LangGraph
By Flavio Copes
Learn LangChain and LangGraph with TypeScript: models, prompts, tools, agents, memory, streaming, RAG, graphs, persistence, and human approval.
LangChain and LangGraph help us build applications around language models.
They are related, but they solve different problems.
LangChain gives us model integrations, messages, prompts, tools, agents, middleware, retrieval, and a common interface across providers.
LangGraph gives us a runtime for stateful workflows. We define steps, connect them, save their state, pause them, and resume them later.
You can use LangChain without writing a LangGraph workflow. LangChain agents already run on LangGraph internally.
You can also use LangGraph without LangChain. A graph node can call any API or run normal TypeScript code.
This guide explains both libraries using TypeScript. We will start with one model call and end with a durable workflow that can stop for human approval.
The examples use the modern LangChain v1+ API. This matters because many older tutorials use APIs that have since moved or changed.
If models, prompts, tokens, and tools are new to you, start with my free AI Fundamentals course. Then come back here and build the examples.
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.
LangChain and LangGraph are not model providers
LangChain does not run a language model by itself.
It connects to a provider such as OpenAI, Anthropic, Google, or a local Ollama server. The provider still handles inference and bills you for model usage.
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:
input -> classify -> retrieve -> draft -> review -> output
^ |
|----------|
The graph can branch, loop, pause, and resume. It can also save state between those steps.
When to use each library
Use a provider SDK directly when you need one or two model calls.
Use LangChain when you want a common model interface, tools, an agent loop, structured output, middleware, or retrieval integrations.
Use LangGraph when your workflow has explicit stages, branches, loops, parallel work, saved state, long-running jobs, or human approval.
You will often use both, and the split looks like this:
LangGraph controls the workflow
LangChain handles models, messages, and tools inside its nodes
A good test for reaching for LangGraph is whether a flowchart explains your application better than a single function does.
A note about old LangChain tutorials
LangChain changed a lot before version 1.
You may find tutorials using names such as:
LLMChainConversationChaininitializeAgentExecutorWithOptions()createReactAgent()from LangGraph prebuilts- imports from
langchain/chains
Modern LangChain centers its API around createAgent(), tools, messages, models, and middleware.
Legacy chains and several older components moved to @langchain/classic. The official LangChain v1 migration guide lists the changes.
The old concepts still make sense, so those tutorials are not useless, but check the date and the package imports before copying code from them.
The examples in this guide use StateSchema for LangGraph state. You may also see Annotation.Root() in older and current examples. Both APIs exist, but StateSchema gives us a clear Zod-based approach for new TypeScript code.
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

We will also build a small RAG example later. Install these packages for that section:
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"
}
}

Create a .env file:
OPENAI_API_KEY=your_key
Add it to .gitignore:
.env
Never commit an API key.
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 a model available in your account when needed.
Run it:
npm run dev
invoke() sends one input and waits for one complete output.
The result is an AIMessage, not a plain string. Its text property gives us the text output.
The message can also contain tool calls, reasoning blocks, citations, usage data, and provider metadata.
Models have a common interface
LangChain wraps different providers behind a similar interface.
The common methods you will use most are:
invoke()for one complete resultstream()for incremental outputbatch()for multiple independent inputsbindTools()to make tools available to a modelwithStructuredOutput()to request validated data
A common interface does not make every provider identical, though. Model names, reasoning controls, image support, cache behavior, and built-in tools still vary from one provider to the next.
My advice is to keep provider-specific configuration in one file, so the rest of the application depends on the behavior you need rather than on a model name scattered across the codebase.
Understand messages
Chat models receive a list of messages.
The most common roles are:
system: instructions for the modeluser: input from the userassistant: a previous model responsetool: 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)
You can also use 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.
Message content is not always a string
A modern model can return more than text.
It may return reasoning, an image, audio, a citation, or a tool call. LangChain exposes normalized contentBlocks so we can inspect those values through a common structure.
Example:
const response = await model.invoke('Explain TypeScript interfaces')
for (const block of response.contentBlocks) {
if (block.type === 'text') {
console.log(block.text)
}
}
For a simple text-only response, response.text is enough.
Use content blocks when your application supports multimodal input, reasoning summaries, citations, or provider-independent rendering.
Create a reusable prompt
String interpolation works for tiny prompts. Prompt templates help once we have several messages or reusable variables.
Let’s create 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 takes an object and produces messages, and the model takes those messages and produces an AIMessage. This composition style is often called the LangChain Expression Language, or LCEL.
You don’t have to use LCEL everywhere. 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)
}
Pick whichever version the people on your team read fastest.
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 a JavaScript object validated against the schema.
This is better than asking for JSON in the prompt and calling JSON.parse() yourself.
Structured output is useful for:
- classification
- extraction
- routing decisions
- database records
- UI data
- testable responses
Keep the schema small, because every extra field is another place where the model can get it wrong.
What is a tool?
A model only knows the context we send it. A tool is a function the model can ask our application to run to get more.
Tools can:
- read an order from a database
- search product documentation
- fetch current weather
- send an email
- create a calendar event
- update a record
The model never runs the function itself. It produces a tool-call request with a name and arguments, our application validates those arguments and runs the function, and the result goes back to the model.
Create a tool
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'),
}),
},
)
A tool has three important parts:
- A name
- A description
- An input schema
The name and description end up in the model’s context, so write them for the model rather than for your internal naming conventions.
The schema protects the function boundary, but it says nothing about whether the requested action is allowed. Authorization still has to happen inside your application.
Test tools without a model
A tool is still normal application code, so test it directly before putting a model in front of it:
const result = await getOrder.invoke({
orderId: 'BOOK-2048',
})
console.log(result)
If the tool fails here, adding an agent on top will only make the failure harder to understand.
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 performs the loop for us:
- Send the conversation and tool descriptions to the model
- Read the model’s tool request
- Validate the tool input
- Run
getOrder - Add the tool result to the conversation
- Call the model again
- Return the final state
createAgent() creates a common tool-calling loop. It is built on LangGraph, but we do not need to define the graph ourselves.
An agent is a loop, not a magic worker
The word agent makes the system 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 that loop:
- giving it the right context
- exposing the right tools
- checking permissions
- handling failures
- limiting cost
- saving state
- testing behavior
- asking for approval before risky actions
LangChain gives us a good harness for those concerns.
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. I use this when another part of the application consumes the result, and leave plain text for the answers a person reads.
Add short-term memory
Our agent currently 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,
})
Now 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 call.
MemorySaver stores everything in the current process. It is useful for local development and tests.
Use a database-backed checkpointer in production. Otherwise, every restart erases the threads, and multiple server instances do not share state.
Thread memory is not long-term memory
Short-term memory belongs to one thread. It contains the conversation and workflow state.
Long-term memory can be shared across threads. It stores user preferences, application facts, or other durable data.
LangGraph calls the long-term memory interface a store.
Example:
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,
})
Passing a store does not make the model use it on its own. A tool or a middleware has to decide what to save, what to load, and when to put it in the model’s context. I prefer it this way. An agent that remembers everything gets expensive and starts surfacing details that have nothing to do with the current question, and it also raises privacy questions you then have to answer.
Keep identity out of model-controlled arguments
Suppose a tool needs the current user ID. Don’t let the model choose it:
schema: z.object({
userId: z.string(),
})
The model could request another user’s ID.
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 at all.
Stream an agent response
Models take time. Streaming lets the user see progress before the complete run finishes.
Modern LangChain offers event streaming with separate streams for messages, tool calls, state values, and other events.
The simplest text example looks like this:
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.
Streaming inside your server is only half of the path. You still need to send those chunks to the browser using a streaming HTTP response, server-sent events, or WebSockets.
I explain that network layer in How to stream LLM responses with server-sent events.
Use middleware to control the agent loop
Middleware runs around model and tool calls.
It is useful for concerns that should apply across the whole agent:
- retries
- call limits
- logging
- prompt changes
- message trimming
- summarization
- guardrails
- tool selection
- human approval
Here is a small example:
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 or broken business logic. Summarization shrinks old conversation history, and since it can also drop details you needed, test it with real conversations before relying on it.
Require approval for risky tools
Reading an order is low risk. Issuing a refund moves money, so it should not run under the same policy as a read. 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 must save its place while it waits.
Approval must happen before the tool performs the action. The approval screen should show the exact target, amount, and effect.
For high-impact operations, approval is only one layer. I 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
A model does not know our private documentation or current business data. Retrieval-augmented generation, or RAG, finds the relevant documents and places them in the model’s context. A typical pipeline has four steps:
- Load documents
- Split them into chunks
- Turn chunks into embeddings
- Store and retrieve similar chunks
Let’s build a tiny in-memory example.
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, because the current JavaScript integration still lives there. It’s good for learning and tests: it does a linear search in memory and loses everything when the process stops, so use a persistent vector database for production data.
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.
For a predictable documentation bot, I often prefer 2-step RAG:
always retrieve -> then generate
There is no decision for the model to get wrong, and the number of model calls has a fixed maximum, which also makes latency predictable.
Chunk size, overlap, and retrieval count have a large effect on quality and cost. I explain those choices in RAG chunking explained.
Do not use an agent for every RAG application
An agent can choose the wrong tool or skip retrieval. If every answer must come from the knowledge base, make retrieval a deterministic step, and save the agent for the case where there are several data sources and the model has to decide which one applies.
This is a pattern we will see again with LangGraph:
Use code for decisions you already know how to make. Use the model for decisions that require language understanding.
What LangGraph adds
createAgent() gives us a prebuilt model-tool loop.
Sometimes our application needs a different shape:
- classify a request before choosing an agent
- run several searches in parallel
- loop until a result passes validation
- pause before a write operation
- wait hours or days for a person
- resume after a process restart
- keep an exact history of state changes
LangGraph lets us define that shape ourselves.
The three core concepts are:
- State: the current data
- Nodes: functions that read state and return updates
- Edges: connections that choose the next node
Build your first graph
Let’s create 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:
START -> normalize -> answer -> END
START and END are special virtual nodes.
compile() checks the graph structure and creates the runnable object we can invoke or stream.
Nodes return updates, not complete state
Notice that normalize() only returns normalizedMessage. It does not copy message or set reply, because LangGraph merges the returned update into the existing state. By default a new value replaces the previous value for that field.
So each node reads what it needs and updates only the fields it owns.
Add a model to a node
A node is just a function. 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 does not care that this node uses an LLM. The next node could query PostgreSQL or call a plain REST API, and the graph would treat it the same way.
Add conditional edges
A fixed edge always goes to the same node.
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 of those goes to, so the model has no way to invent a destination.
Understand reducers
By default, state updates replace old values.
Sometimes we want to combine them.
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 instead of replacing the array.
Reducers become essential when parallel nodes update the same field. Without a reducer, LangGraph cannot safely decide how to combine those writes.
Use MessagesValue for conversations
Conversation state needs special merge behavior.
LangGraph provides MessagesValue:
import {
MessagesValue,
StateSchema,
} from '@langchain/langgraph'
const ChatState = new StateSchema({
messages: MessagesValue,
})
Nodes return only new messages:
const callModel: GraphNode<typeof ChatState> = async state => {
const response = await model.invoke(state.messages)
return {
messages: [response],
}
}
MessagesValue handles appending messages and updating messages with matching IDs.
Do not build conversation history using a plain array with replacement behavior.
Loops are normal in a graph
An agent loop is a cycle:
model -> tools -> model -> tools -> model -> END
LangGraph supports cycles directly.
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 decides whether the model requested a tool.
Use createAgent() for the standard loop. Build this graph yourself only when you need different routing, state, or control around it.
Always set reasonable model-call and recursion limits. A graph cycle can otherwise become an expensive infinite loop.
Run nodes in parallel
A node can have several outgoing edges.
LangGraph runs those destination nodes in the next super-step, potentially in parallel.
Example:
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 shared fields updated by both searches.
Parallel execution reduces latency when tasks are independent. It can increase cost and rate-limit pressure, so do not fan out without a limit.
Use Send for dynamic parallel work
Sometimes we do not know the number of tasks while building the graph.
For example, a planner may produce five research questions. We want to process each one in parallel.
LangGraph’s Send primitive creates those dynamic calls:
import { Send } from '@langchain/langgraph'
graph.addConditionalEdges('plan', state => {
return state.questions.map(question => {
return new Send('research', { question })
})
})
This is useful for map-reduce workflows:
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.
Sometimes a node needs to update state and choose the next destination together.
Use 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.
Add an in-memory 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.
This enables:
- conversation memory
- human-in-the-loop pauses
- fault recovery
- state inspection
- replay
- time-travel debugging
The thread ID is the pointer to that saved history.
Use a persistent checkpointer such as PostgreSQL or MongoDB in production.
Pause a graph for human input
interrupt() stops a graph and returns a value to the caller.
The graph can wait until we resume it with a Command.
Here is 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 thread ID would start a different workflow instead of continuing this one.
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)
This example validates the amount before asking for approval.
In a real system, prepare would load the order from a trusted database. The model should never decide the authoritative refund amount.
The final write would also use an idempotency key.
Be careful with code before interrupt()
When a graph resumes, LangGraph re-enters the node containing the interrupt.
Code before interrupt() can run again.
Do not send an email, charge a card, or update a database before the interrupt unless the operation is idempotent.
The safest 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.
Tasks save their results in checkpoints. They still need idempotency because a failure can happen after the remote system accepts a request but before the task records success.
Durable execution does not mean exactly once
Durable execution means the workflow can recover and continue from saved progress.
It does not turn an external API into an exactly-once system.
Consider this failure:
- The refund provider accepts the refund
- The network connection drops
- Our task never receives the success response
- The workflow retries
Without an idempotency key or a status check, we may issue the refund twice.
Design write tools and tasks for retries:
- use provider idempotency keys
- store operation IDs
- check existing state before writing
- separate preparation from execution
- treat timeouts as unknown, not failed
- reconcile ambiguous results
Persistence gets our workflow back on its feet after a crash, but only idempotency at the business level stops the refund provider from seeing the same request twice.
Inspect current and historical state
A checkpointer gives us more than resumption.
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.
This is useful for debugging a workflow that took the wrong branch.
Replay and fork workflows
LangGraph can resume from an earlier checkpoint.
There are two related ideas:
- Replay: run later nodes again from a saved checkpoint
- Fork: change state at a checkpoint and explore a different path
This is often called time travel, and the name promises more than it delivers, because LangGraph does not undo external side effects.
If an earlier run sent an email or issued a refund, replaying from a checkpoint does not reverse it. Later nodes may execute again.
Time travel is safest for pure computation and read operations. Write operations still need idempotency and explicit review.
Compose graphs with subgraphs
A node can be another compiled graph.
This lets us 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 a part of the workflow has its own clear state and lifecycle, not for every function, since wrapping simple code in a graph only hides it behind more structure.
Graph API or Functional API?
LangGraph has two ways to build workflows.
The Graph API uses state, nodes, and edges. This guide uses it because the control flow is visible and easy to draw.
The Functional API uses normal TypeScript control flow with entrypoint() and task().
Choose the Graph API when:
- the workflow has meaningful states and branches
- you want a visual graph
- several nodes share state
- the topology helps people understand the system
Choose the Functional API when:
- you already have working procedural code
if,for, and function calls explain the flow well- you mainly want persistence, tasks, interrupts, and streaming
Both APIs use the same LangGraph runtime. You can combine them.
LangChain agents are LangGraph graphs
createAgent() returns a compiled LangGraph graph, which is why a LangChain agent supports:
- state
- checkpointers
- thread IDs
- interrupts
- stores
- streaming
- LangGraph composition
You can place a complete LangChain agent inside a larger custom graph as a node or subgraph.
This is a useful architecture:
deterministic router
-> support agent
-> sales agent
-> billing workflow with approval
The outer graph controls the business process. Each inner agent handles an open-ended language task.
Test the deterministic parts first
Most of a reliable agent application should be normal code.
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',
)
})
Also test:
- tool input validation
- authorization
- not-found results
- timeouts
- retry behavior
- approval rejection
- duplicate requests
- malformed model output
- maximum loop counts
These tests are fast because they do not need a model.
Test model behavior with datasets
Unit tests cannot prove that a model will behave well on real language.
Create 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:
- final answer
- selected tools
- tool arguments
- number of model calls
- latency
- token usage
- approval decisions
- errors
Run the same dataset when you change a prompt, model, tool description, middleware policy, or retrieval setup.
LangSmith can store traces and run evaluations, but the important idea is independent of LangSmith: keep examples, measure behavior, and compare changes.
Trace what the agent did
An agent can produce the right answer for the wrong reason, and you only find out by looking at the steps it took.
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
No extra tracing code is required for a standard LangChain agent.
Do not send secrets or unnecessary personal data to a tracing service. Decide what can be recorded before enabling production traces.
A production checklist
Getting a demo to work is the easy part. These are the areas I would check before shipping.
Model boundaries
- Set timeouts
- Limit model calls
- Limit loop depth
- Set token and cost budgets
- Handle provider rate limits
- Decide what happens when the model is unavailable
Tool boundaries
- Validate every input
- Authorize inside the tool
- Separate read and write tools
- Return small, clear results
- Add idempotency to writes
- Use timeouts and narrow retries
- Treat ambiguous failures carefully
Context boundaries
- Send only necessary data
- Keep trusted identity in runtime context
- Trim or summarize long threads
- Protect prompt instructions from untrusted retrieved text
- Separate user data by namespace and tenant
Workflow boundaries
- Persist threads in a real database
- Version state changes carefully
- Make side effects replay-safe
- Add approval before consequential actions
- Define cancellation and recovery behavior
- Put hard limits on parallel fan-out
Quality boundaries
- Keep a test dataset
- Trace failures
- Evaluate tool selection and arguments
- Check retrieval quality separately from answer quality
- Review real production examples
- Compare before changing models or prompts
Security boundaries
- Keep provider keys on the server
- Never put secrets in prompts
- Escape or sanitize rendered output
- Apply normal web security controls
- Log who approved sensitive actions
- Minimize stored conversation data
An agent framework does not replace application security, because what it adds is another input source that can make requests in ways you did not anticipate.
Common mistakes
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.
Start with one loop. Split it only when different responsibilities, permissions, or context make the boundary useful.
Giving the model too many tools
Large tool lists increase context size and selection mistakes.
Group tools by task. Use deterministic routing or middleware to expose only the relevant subset.
Putting business rules in the prompt
A prompt can guide behavior. It cannot enforce authorization, prices, balances, or ownership.
Put hard rules in code and databases.
Treating memory as a transcript dump
More context is not always better.
Old messages can distract the model, increase cost, and expose private data. Store only what has a clear purpose and retention policy.
Retrying every error
Retry temporary network failures.
Do not retry invalid arguments, denied permissions, or permanent business errors. Retrying write operations without idempotency is dangerous.
Confusing persistence with safety
A saved graph can resume after a failure, and it can just as happily resume a bad plan. Persistence only gives you continuity. Safety comes from validation, authorization, approval steps, and idempotent writes.
Hiding a fixed workflow inside an agent
If the steps are always the same, write the steps.
Use a graph for visible control flow. Use an agent only where the model needs to choose what to do.
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 HostingPicker that reads its hand-checked hosting data and explains why one platform fits a project. The tools would retrieve structured provider facts. The model would turn those facts into a clear answer.
I would start with createAgent(), one retrieval tool, structured output, and traces.
I would not start with a custom graph. The standard model-tool loop is enough until the product needs explicit stages or long-running work.
For a streaming TypeScript chat interface with a few tools, I would also compare LangChain with the Vercel AI SDK. The AI SDK can be a smaller fit when UI streaming and provider calls are the main problem.
When I would use LangGraph
I would use LangGraph when the application needs a durable business workflow.
For example, a domain registration flow has clear stages:
check availability
-> get an authoritative price
-> show the exact quote
-> wait for approval
-> register once
-> verify ownership
That is a graph, not an open-ended chat.
The model may help explain the quote or parse a request. Code should control the price check, approval, registration, and verification.
I would also use LangGraph for research work that fans out across several sources, saves intermediate results, and pauses when evidence is weak.
I would not use it for a single completion, a simple classifier, or a fixed two-function pipeline. Normal TypeScript is clearer there.
Choosing the smallest useful abstraction
Here is the order I would follow:
- Call the provider SDK or LangChain model
- Add a prompt template if the prompt is reused
- Add structured output if code consumes the result
- Add one tool if the model needs outside data
- Use
createAgent()if the model must choose and loop - Add middleware for cross-cutting controls
- Add a checkpointer when the thread must survive
- Add a store when information must cross threads
- Build a custom LangGraph when the workflow needs its own topology
Stop as soon as the application is clear and reliable.
Where to go from here
If you want one sentence to keep: LangChain is the agent harness (models, messages, tools, structured output, memory, middleware, retrieval), LangGraph is the runtime that gives a workflow its shape (state, nodes, edges, checkpoints, interrupts), and in both cases the model handles the language while your code handles permissions, money, identity, limits, and side effects.
Start with the bookstore agent from this guide and one tool. Watch what the loop does with a few real questions. Reach for LangGraph the day the workflow itself, not the model, becomes the thing you need to control.
Want me to talk about your product? You can sponsor this site.
Related posts about ai: