The chatbot can now use tools to answer user questions, but it does not remember the context of previous interactions. This limits its ability to have coherent, multi-turn conversations. LangGraph solves this problem through persistent checkpointing. If you provide a checkpointer when compiling the graph and a thread_id when calling your graph, LangGraph automatically saves the state after each step. When you invoke the graph again using the same thread_id, the graph loads its saved state, allowing the chatbot to pick up where it left off. We will see later that checkpointing is much more powerful than simple chat memory - it lets you save and resume complex state at any time for error recovery, human-in-the-loop workflows, time travel interactions, and more. But first, let’s add checkpointing to enable multi-turn conversations.
This tutorial builds on Add tools.

1. Create a MemorySaver checkpointer

Create a MemorySaver checkpointer:
import { MemorySaver } from "@langchain/langgraph";

const memory = new MemorySaver();
This is in-memory checkpointer, which is convenient for the tutorial. However, in a production application, you would likely change this to use SqliteSaver or PostgresSaver and connect a database.

2. Compile the graph

Compile the graph with the provided checkpointer, which will checkpoint the State as the graph works through each node:
const graph = new StateGraph(State)
  .addNode("chatbot", chatbot)
  .addNode("tools", new ToolNode(tools))
  .addConditionalEdges("chatbot", toolsCondition, ["tools", END])
  .addEdge("tools", "chatbot")
  .addEdge(START, "chatbot")
  .compile({ checkpointer: memory });

3. Interact with your chatbot

Now you can interact with your bot!
  1. Pick a thread to use as the key for this conversation.
const config = { configurable: { thread_id: "1" } };
  1. Call your chatbot:
const userInput = "Hi there! My name is Will.";

const events = await graph.stream(
  { messages: [{ type: "human", content: userInput }] },
  { configurable: { thread_id: "1" }, streamMode: "values" }
);

for await (const event of events) {
  const lastMessage = event.messages.at(-1);
  console.log(`${lastMessage?.getType()}: ${lastMessage?.text}`);
}
human: Hi there! My name is Will.
ai: Hello Will! It's nice to meet you. How can I assist you today? Is there anything specific you'd like to know or discuss?
The config was provided as the second parameter when calling our graph. It importantly is not nested within the graph inputs ({"messages": []}).

4. Ask a follow up question

Ask a follow up question:
const userInput2 = "Remember my name?";

const events2 = await graph.stream(
  { messages: [{ type: "human", content: userInput2 }] },
  { configurable: { thread_id: "1" }, streamMode: "values" }
);

for await (const event of events2) {
  const lastMessage = event.messages.at(-1);
  console.log(`${lastMessage?.getType()}: ${lastMessage?.text}`);
}
human: Remember my name?
ai: Yes, your name is Will. How can I help you today?
Notice that we aren’t using an external list for memory: it’s all handled by the checkpointer! You can inspect the full execution in this LangSmith trace to see what’s going on. Don’t believe me? Try this using a different config.
const events3 = await graph.stream(
  { messages: [{ type: "human", content: userInput2 }] },
  // The only difference is we change the `thread_id` here to "2" instead of "1"
  { configurable: { thread_id: "2" }, streamMode: "values" }
);

for await (const event of events3) {
  const lastMessage = event.messages.at(-1);
  console.log(`${lastMessage?.getType()}: ${lastMessage?.text}`);
}
human: Remember my name?
ai: I don't have the ability to remember personal information about users between interactions. However, I'm here to help you with any questions or topics you want to discuss!
Notice that the only change we’ve made is to modify the thread_id in the config. See this call’s LangSmith trace for comparison.

5. Inspect the state

By now, we have made a few checkpoints across two different threads. But what goes into a checkpoint? To inspect a graph’s state for a given config at any time, call getState(config).
await graph.getState({ configurable: { thread_id: "1" } });
{
  values: {
    messages: [
      HumanMessage {
        "id": "32fabcef-b3b8-481f-8bcb-fd83399a5f8d",
        "content": "Hi there! My name is Will.",
        "additional_kwargs": {},
        "response_metadata": {}
      },
      AIMessage {
        "id": "chatcmpl-BrPbTsCJbVqBvXWySlYoTJvM75Kv8",
        "content": "Hello Will! How can I assist you today?",
        "additional_kwargs": {},
        "response_metadata": {},
        "tool_calls": [],
        "invalid_tool_calls": []
      },
      HumanMessage {
        "id": "561c3aad-f8fc-4fac-94a6-54269a220856",
        "content": "Remember my name?",
        "additional_kwargs": {},
        "response_metadata": {}
      },
      AIMessage {
        "id": "chatcmpl-BrPbU4BhhsUikGbW37hYuF5vvnnE2",
        "content": "Yes, I remember your name, Will! How can I help you today?",
        "additional_kwargs": {},
        "response_metadata": {},
        "tool_calls": [],
        "invalid_tool_calls": []
      }
    ]
  },
  next: [],
  tasks: [],
  metadata: {
    source: 'loop',
    step: 4,
    parents: {},
    thread_id: '1'
  },
  config: {
    configurable: {
      thread_id: '1',
      checkpoint_id: '1f05cccc-9bb6-6270-8004-1d2108bcec77',
      checkpoint_ns: ''
    }
  },
  createdAt: '2025-07-09T13:58:27.607Z',
  parentConfig: {
    configurable: {
      thread_id: '1',
      checkpoint_ns: '',
      checkpoint_id: '1f05cccc-78fa-68d0-8003-ffb01a76b599'
    }
  }
}
import * as assert from "node:assert";

// Since the graph ended this turn, `next` is empty.
// If you fetch a state from within a graph invocation, next tells which node will execute next)
assert.deepEqual(snapshot.next, []);
The snapshot above contains the current state values, corresponding config, and the next node to process. In our case, the graph has reached an END state, so next is empty. Congratulations! Your chatbot can now maintain conversation state across sessions thanks to LangGraph’s checkpointing system. This opens up exciting possibilities for more natural, contextual interactions. LangGraph’s checkpointing even handles arbitrarily complex graph states, which is much more expressive and powerful than simple chat memory. Check out the code snippet below to review the graph from this tutorial:
import { END, MessagesZodState, START } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { TavilySearch } from "@langchain/tavily";

import { MemorySaver } from "@langchain/langgraph";
import { StateGraph } from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { z } from "zod";

const State = z.object({
  messages: MessagesZodState.shape.messages,
});

const tools = [new TavilySearch({ maxResults: 2 })];
const llm = new ChatOpenAI({ model: "gpt-4o-mini" }).bindTools(tools);
const memory = new MemorySaver();

async function generateText(content: string) {

const graph = new StateGraph(State)
  .addNode("chatbot", async (state) => ({
    messages: [await llm.invoke(state.messages)],
  }))
  .addNode("tools", new ToolNode(tools))
  .addConditionalEdges("chatbot", toolsCondition, ["tools", END])
  .addEdge("tools", "chatbot")
  .addEdge(START, "chatbot")
  .compile({ checkpointer: memory });

Next steps

In the next tutorial, you will add human-in-the-loop to the chatbot to handle situations where it may need guidance or verification before proceeding.