Sitelet https://threadplane.ai/docs/langgraph/guides/persistence
Page actions

Persistence

Thread persistence keeps conversations alive across page refreshes, browser restarts, and server deployments. LangGraph checkpoints agent state at every super-step, keyed by a thread ID, and injectAgent() connects to those checkpoints for you. The running example is a chat with a thread sidebar, and this guide walks the four files that make it work.

Tip: Prerequisites

Make sure you have completed the Installation guide first.

What the demo does

The Run tab shows the prebuilt <chat> composition next to a thread sidebar. Send a message and the backend assigns a thread ID, which appears in the sidebar as "Thread 1". Click "+ New Thread" and send another message, and you have two conversations you can move between.

Switching back to an earlier thread replays its stored history: the messages come back from the server checkpoint, not from anything the browser kept. The welcome suggestion, "Start a saved thread", asks the agent to draft a project brief you can revisit, which gives each thread enough content to recognize in the sidebar.

How it is built

Four pieces carry the whole feature: a graph that leaves checkpointing to the platform, an application config that deliberately does not register the agent, a component-scoped provider that captures thread IDs, and a sidebar that switches between them. Open the Code tab to read them in place.

The graph and its checkpointer

The backend is a single node. MessagesState gives the LangGraph SDK a message list it already understands, the node prepends a system prompt read from the capability's prompt file, and the compiled graph is exported as graph, which is the symbol langgraph.json points at.

graph.py
"""
LangGraph Persistence Graph
 
Demonstrates thread persistence with checkpointing. Each thread's
conversation history is saved and can be resumed by providing the
same thread_id. The LangGraph API server provides checkpointing
automatically — no custom checkpointer needed.
"""
 
from pathlib import Path
from langgraph.graph import StateGraph, MessagesState, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage
 
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
def build_persistence_graph():
    """
    Constructs a StateGraph with checkpointing enabled.
 
    The LangGraph API server provides checkpointing automatically,
    allowing conversations to be resumed with the same thread_id.
    """
    llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
 
    async def generate(state: MessagesState) -> dict:
        """Generate a response with conversation history preserved."""
        system_prompt = (PROMPTS_DIR / "persistence.md").read_text()
        messages = [SystemMessage(content=system_prompt)] + state["messages"]
        response = await llm.ainvoke(messages)
        return {"messages": [response]}
 
    graph = StateGraph(MessagesState)
    graph.add_node("generate", generate)
    graph.set_entry_point("generate")
    graph.add_edge("generate", END)
    return graph.compile()
 
 
# The graph instance — referenced by langgraph.json
graph = build_persistence_graph()

Look at the last line of build_persistence_graph: it calls compile() on the StateGraph with no checkpointer at all. Persistence here comes entirely from the LangGraph API server, which is the case whenever you serve a graph with langgraph dev or on LangGraph Platform.

Warning: Serving through langgraph dev or LangGraph Platform? Do not compile a checkpointer.

The platform provides persistence itself. langgraph dev refuses to load a graph that compiles its own saver, and a deployment ignores one, so leave it off in both cases. Call compile() on the StateGraph with no argument, as the example does.

Passing one is not a soft warning — langgraph dev fails to load the graph and exits:

ValueError: Heads up! Your graph 'graph' from './graph.py' includes a custom
checkpointer (type <class 'langgraph.checkpoint.memory.InMemorySaver'>). With
LangGraph API, persistence is handled automatically by the platform…
Application startup failed. Exiting.

To point the platform at your own database, set the POSTGRES_URI environment variable rather than constructing a saver in code.

Why the app config is almost empty

Most of the LangGraph examples register their agent in app.config.ts. This one does not, and the comment in the file says why.

app.config.ts
import { ApplicationConfig } from '@angular/core';
 
export const appConfig: ApplicationConfig = {
  providers: [
    // The agent is provided at the component (PersistenceComponent) because
    // its onThreadId callback is per-instance — see persistence.component.ts.
  ],
};

The onThreadId callback is per-instance state, so the agent is provided at the component instead, and the application root carries no chat providers at all.

The thread bookkeeping the sidebar reads

Two signals hold everything the sidebar needs: the list of threads the user has created and the ID of the active one. A counter supplies the human-readable labels, because a thread ID from the server is a long opaque string.

persistence.component.ts — thread state
// Per-instance thread bookkeeping shared between the component-scoped
// provideAgent() config (which owns the onThreadId callback) and the
// component itself. Module scope is safe here: each demo app bootstraps a
// single PersistenceComponent instance.
const threadsState = signal<Thread[]>([]);
const activeThreadIdState = signal<string | null>(null);
let threadCounter = 0;

Module scope works here because the demo bootstraps exactly one component instance. In an application that mounts several, move these into a service and inject it.

Capturing thread IDs with onThreadId

provideAgent() sits in the component's providers array, so the agent and its callback are created with the component. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo; your own application passes apiUrl and assistantId directly.

persistence.component.ts — the agent provider
// Scoped agent: the onThreadId callback tracks new thread ids into the
// module-scoped signals the sidebar reads. Provided at the component (Option
// B) because the config is genuinely per-instance.
providers: [
  provideAgent(() => {
    const connection = injectCockpitRuntimeConnection();
    if (connection.adapter !== 'langgraph') {
      throw new Error('incompatible runtime');
    }
    return {
      apiUrl: connection.apiUrl,
      assistantId: connection.assistantId,
      clientOptions: connection.clientOptions,
      onThreadId: (id: string) => {
        activeThreadIdState.set(id);
 
        // Only add if not already tracked
        const existing = threadsState();
        if (!existing.some((t) => t.id === id)) {
          threadCounter++;
          threadsState.set([
            ...existing,
            { id, label: `Thread ${threadCounter}` },
          ]);
        }
      },
    };
  }),
],

onThreadId fires when the backend creates a thread and reports its ID, which is why the callback checks whether it already knows the ID before adding it. The callback records it as the active thread and appends it to the list if it is new, which is the only bookkeeping the sidebar needs.

Warning: Thread IDs come from the server

Never generate a thread ID client-side. Always use the value handed to onThreadId, or a value the LangGraph Threads API returned earlier.

Warning: Keep the API key on the server

Never expose a LangSmith API key in client-side code. Point apiUrl at a deployment that authenticates the browser another way, or proxy the requests through your own server and attach the key there.

The sidebar

The <chat> composition owns message rendering, the input, loading states, and error display, so the template only has to add the picker. The sidebar renders one button per tracked thread, marks the active one, and ends with a "+ New Thread" button in the footer.

persistence.component.ts — the sidebar
<div sidebar class="sidebar">
  <div class="cap">Threads</div>
 
  <div class="thread-list">
    @for (thread of threads(); track thread.id) {
      <button
        type="button"
        class="thread"
        [class.thread--active]="thread.id === activeThreadId()"
        (click)="switchThread(thread.id)"
      >
        {{ thread.label }}
      </button>
    }
  </div>
 
  <div class="footer">
    <button type="button" class="btn--primary" (click)="newThread()">+ New Thread</button>
  </div>
</div>

Switching and starting threads

The class itself is small. It injects the agent registered above, forwards a welcome suggestion into submit(), and exposes the two thread actions the sidebar calls.

persistence.component.ts — agent and thread actions
/**
 * The streaming resource with thread persistence.
 *
 * The `onThreadId` callback (wired at the component-scoped provideAgent in
 * the decorator) fires when a new thread is created, tracking thread IDs for
 * the sidebar picker.
 */
protected readonly agent = injectAgent();
 
protected send(text: string): void {
  void this.agent.submit({ message: text });
}
 
/** Switch to an existing thread by ID. */
switchThread(id: string): void {
  this.activeThreadId.set(id);
  this.agent.switchThread(id);
}
 
/** Start a brand-new thread. */
newThread(): void {
  this.activeThreadId.set(null);
  this.agent.switchThread(null);
}

switchThread(id) loads that thread's latest checkpoint and repopulates every signal the composition reads. switchThread(null) clears the conversation; the backend assigns a new ID on the next submit, and onThreadId adds it to the sidebar.

Note: Injection context

injectAgent() must run inside an Angular injection context: a field initializer, as it is here, or a constructor body.

Note: Adapter-defined behavior

"Restore a prior thread's messages when the user switches to it" is a behavior the @threadplane/langgraph adapter implements because the LangGraph protocol exposes per-thread checkpoint history. The runtime-neutral Agent contract in @threadplane/chat does not require this — adapters built on event-stream protocols (like @threadplane/ag-ui) typically cannot offer it. If you are writing your own adapter, the Writing an Adapter guide covers the design choice.

Choosing a checkpointer

The example leaves checkpointing to the platform, and that is the right default. The checkpointers below apply when you embed the graph in your own process — a FastAPI app calling graph.ainvoke(), a worker, a script, or an AG-UI server built with ag-ui-langgraph (which needs a checkpointer to read state via aget_state).

@threadplane/langgraph connects to a LangGraph server, so if you are following the Quick Start and running langgraph dev, you are in the first case and can skip this section.

from langgraph.checkpoint.memory import MemorySaver
 
# MemorySaver stores checkpoints in-process memory
# Fast for development — lost when the process restarts
graph = builder.compile(checkpointer=MemorySaver())
Warning: Production checkpointers

MemorySaver is for development only — all state vanishes when the process exits. For anything users depend on, use PostgresSaver. SqliteSaver is a middle ground for prototypes and single-server deployments where you need persistence without a database.

Thread IDs in graph invocation

When you invoke an embedded graph yourself, the thread ID is how LangGraph associates a conversation with its checkpoint history. Pass it in the configurable dict on every call:

# First message creates the thread
result = graph.invoke(
    {"messages": [{"role": "user", "content": "What is LangGraph?"}]},
    config={"configurable": {"thread_id": "user_123"}}
)
 
# Second message continues the same conversation
result = graph.invoke(
    {"messages": [{"role": "user", "content": "How does it handle state?"}]},
    config={"configurable": {"thread_id": "user_123"}}
)
# The agent sees both messages — the full history is restored from the checkpoint
Tip: Thread ID strategy

Use stable, user-scoped identifiers for thread IDs. A common pattern is f"{user_id}_{session_id}" — this prevents cross-user data leaks and lets one user have multiple conversations.

Surviving a full page reload

The example keeps its thread list in memory, so a browser refresh starts it over. An application that should survive a reload writes the IDs somewhere durable and reads them back at startup:

provideAgent({
  apiUrl: 'https://your-deployment.langgraph.app',
  assistantId: 'persistence',
  // Restore the last thread on app start
  threadId: signal(localStorage.getItem('threadId')),
  // Persist the ID whenever the backend assigns one
  onThreadId: (id) => localStorage.setItem('threadId', id),
});

threadId accepts a Signal, which is the other way to change threads: set the signal and injectAgent() reacts to it, fetches that thread's checkpoint, and updates every derived signal. The example calls switchThread() instead because it wants the switch to happen exactly when the button is clicked.

Tip: Thread loading state

Use the isThreadLoading() signal to show a skeleton UI while injectAgent() fetches checkpoint state from the server. This avoids a flash of empty content when switching threads.

Note: LANGGRAPH_CLIENT

LANGGRAPH_CLIENT is the DI token that holds the shared LangGraph SDK Client used by the threads adapter (LangGraphThreadsAdapter). The adapter injects it optionally and, when no client is provided, constructs one via createLangGraphClient(apiUrl). Provide your own client through this token to share a single SDK instance — or to inject an explicit client in tests.

Forking a conversation

To fork, capture agent.messages() first, start a fresh thread with switchThread(null), and resubmit the captured history as a state patch. For a fork that branches from an earlier checkpoint, use the Time Travel guide.

Checkpoint recovery

When a connection drops mid-stream, joinStream() reconnects to an in-progress run without restarting the agent. That prevents duplicate work and lost tokens.

// Rejoin a running stream after a network interruption
await this.agent.joinStream(runId, lastEventId);
// Picks up from the last event — no duplicate agent execution
Note: Automatic recovery

In most cases injectAgent() handles reconnection internally. Use joinStream() directly only when you need explicit control — for example, when restoring a run ID from a URL parameter after a full page reload.

Thread lifecycle

1
Component mounts

injectAgent() reads the threadId signal. If it contains a value, the existing thread's checkpoint is fetched from the server.

2
User sends first message

If threadId is null, injectAgent() creates a new thread via the LangGraph API and fires onThreadId with the new ID.

3
Agent streams response

Each super-step is checkpointed server-side. The messages() signal updates in real time as events arrive.

4
User switches threads

Setting the threadId signal (or calling switchThread(), as the example does) loads the target thread's latest checkpoint. All signals update to reflect the restored state.

5
Connection drops

joinStream() reconnects to the in-progress run. The agent does not restart — streaming resumes from the last received event.

Where threads and checkpoints live

In your backend's persistence layer. Threadplane exposes thread, history, and resume behavior in the UI; durability comes from the runtime you operate.

What's Next

Looking for something specific?