Persistence
Start a conversation, switch away, then load its saved messages.
Recorded in the real example with fixture responses paced for readability. No live model requests or narration. Actual-speed footage with reading pauses. Capture details
Read the walkthrough
- Send a message in the first conversation.
- Start a new conversation. The first remains in the picker.
- Select the first conversation to load its saved messages. The picker is local to this page; this recording does not demonstrate browser-refresh recovery.
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.
Make sure you have completed the Installation guide first.
Save the conversation and remember its address
A traveler should be able to close the Portland itinerary and return to the same plan. The server must retain the messages and agent state; the application must remember which thread to load. These are separate responsibilities.
Application reference → one confirmed thread
Thread 1 → 0…many runs over its lifetime
Thread 1 → 0…many checkpoints as graph state is saved
Checkpoint → saved messages and application state
Read diagram description
The application stores a reference to a confirmed thread ID. One thread can have many runs and many checkpoints. A run advances graph execution and can produce multiple checkpoints. A checkpoint stores graph state, including messages, and may point to a parent checkpoint. Reloading with a confirmed thread ID restores its saved state. Saving that identifier in browser storage alone does not persist messages or guarantee recovery of an interrupted run.
The thread ID is an address. A checkpoint is a saved version of the agent state at a step in that thread. The sidebar is an application view of available threads. Keeping one of these does not automatically keep the others: this demo's page-local list disappears on reload even though the server still holds its threads. The reload example later in this guide adds the missing address retention.
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.
"""
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.
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.
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.
// 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.
// 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.
Use a thread that the backend has confirmed exists. This Angular provider supplies its ID through onThreadId. An application using the Threads API may request its own UUID, but must create that thread and confirm the returned identity before submitting a run; generating a string alone creates no saved conversation.
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.
<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.
/**
* 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.
injectAgent() must run inside an Angular injection context: a field initializer, as it is here, or a constructor body.
"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())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 checkpointUse 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 history, and rebuilds the derived signals from it. The example calls switchThread() instead because it wants the switch to happen exactly when the button is clicked.
What a reloaded thread shows
A reloaded thread is rebuilt from checkpoint history, not from the stream events the live view saw, so it shows what history carries and no more:
- Messages, values, tool calls and pending interrupts come from the latest checkpoint.
- Reasoning is restored from the
reasoningandthinkingcontent blocks the checkpoint saved with each assistant message. Its duration is not: history records no start or end time for reasoning, so a reloaded message shows its reasoning without the "Thought for" duration label. - Subagent cards for delegation tool calls (the
subagentToolNamesoption,taskby default) are rebuilt from the parent's tool calls and their results, so each card sits under its tool call with its final status and result. The parent checkpoints also link each call to the child's checkpoint namespace; the default transport reads the child's latest checkpoint there to restore the card's transcript, and a run that resumes after the reload streams into the same card instead of opening a second one. - Not rebuilt: cards for compiled subgraphs added as plain graph nodes, nested delegations (a subagent that delegates again), and tool progress events. History does not tie these to a parent tool call, so they appear only while a run is streaming.
A custom transport restores child transcripts only if it implements the optional getChildState() method; without it, subagent cards come back with their status and result but no messages.
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.
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 executionIn 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
injectAgent() reads the threadId signal. If it contains a value, the existing thread's checkpoint is fetched from the server.
If threadId is null, injectAgent() creates a new thread via the LangGraph API and fires onThreadId with the new ID.
Each super-step is checkpointed server-side. The messages() signal updates in real time as events arrive.
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.
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.
Wiring a thread list
<chat-thread-list> renders the rows; the application owns the active-thread signal. That signal is the same one handed to provideAgent({ threadId }), which is what makes selecting a row switch the conversation: the agent watches the signal, resets its derived state, and re-fetches that thread's server-side history.
import { Component, inject, signal } from '@angular/core';
import { ChatThreadListComponent, type ThreadActionAdapter } from '@threadplane/chat';
import {
injectAgent,
provideAgent,
LangGraphThreadsAdapter,
LANGGRAPH_THREADS_CONFIG,
refreshOnRunEnd,
} from '@threadplane/langgraph';
/** Source of truth for the active thread. Module scope, because the
* `provideAgent()` config below is evaluated at provider-registration
* time and has to close over it. */
const activeThreadIdState = signal<string | null>(null);
@Component({
selector: 'app-threads-panel',
standalone: true,
imports: [ChatThreadListComponent],
providers: [
LangGraphThreadsAdapter,
{ provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://localhost:2024' } },
provideAgent({
apiUrl: 'http://localhost:2024',
assistantId: 'agent',
threadId: activeThreadIdState,
onThreadId: (id: string) => activeThreadIdState.set(id),
}),
],
template: `
<chat-thread-list
[threads]="threads.threads()"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="actions"
[showNewThreadButton]="true"
(threadSelected)="onThreadSelected($event)"
(newThreadRequested)="onNewThread()"
/>
`,
})
export class ThreadsPanel {
protected readonly agent = injectAgent();
protected readonly threads = inject(LangGraphThreadsAdapter);
protected readonly activeThreadId = activeThreadIdState;
protected readonly actions: ThreadActionAdapter = {
rename: (id, title) => this.threads.rename(id, title),
pin: (id) => this.threads.pin(id),
unpin: (id) => this.threads.unpin(id),
archive: async (id) => {
await this.threads.archive(id);
if (activeThreadIdState() === id) activeThreadIdState.set(null);
},
delete: async (id) => {
await this.threads.delete(id);
if (activeThreadIdState() === id) activeThreadIdState.set(null);
},
};
constructor() {
void this.threads.refresh();
refreshOnRunEnd(this.agent, () => this.threads.refresh());
}
protected onThreadSelected(id: string): void {
activeThreadIdState.set(id);
}
protected async onNewThread(): Promise<void> {
const id = await this.threads.create();
if (id !== null) activeThreadIdState.set(id);
}
}The example scopes both the thread adapter and its required LANGGRAPH_THREADS_CONFIG to the panel. To share a root-provided thread adapter across the app, provide that configuration at the application root instead.
Three details in that template are easy to get wrong:
activeThreadIdisinput<string>(''), not a nullable input, so anullactive thread has to become''. Passingnullis a template type error.- The selection output is
threadSelected, and<chat>re-emits an output of the same name, so either surface can drive the switch. - The agent has no
threadIdmember of its own. The id lives in the application's signal; reading it back off the agent is not possible.
An application that does not bind a threadId signal can call agent.switchThread(id) from the same handler instead. Binding the signal is the better default, because it also survives a provideAgent() factory that reads the id from the router.
<chat-thread-list> builds its row menu from the keys the adapter actually defines, and from [mode]. A list in the default active mode offers Rename, Pin or Unpin, Move up and Move down for a pinned row with reorderPinned, Move to project when [projects] is bound, Archive, and Delete. A list rendered with [mode]="'archived'" — the natural home for archivedThreads() — offers only Unarchive and Delete.
Keeping the list fresh
The store is a snapshot, not a subscription, so something has to re-fetch it. refreshOnRunEnd() fires on every transition out of the running status, which is precisely when a title-writing node has finished.
refreshOnRunEnd(this.agent, () => this.threads.refresh());refreshOnTransition(watch, isActive, fn) is the generic form for any other signal that has an active state: it calls fn whenever isActive(watch()) goes from true to false.
Both helpers use an Angular effect() under the hood, so both must be called from an injection context — a constructor body or a field initializer. refresh() itself is a plain asynchronous call with no such requirement.
Shared SDK client
Provide LANGGRAPH_CLIENT to hand the adapter an SDK Client you already own, or a test double; when the token is absent the adapter builds its own client from apiUrl. Provide LANGGRAPH_CLIENT_OPTIONS once at the application root to tune the SDK retry budget and auth headers shared by FetchStreamTransport and this adapter.
Use a confirmed backend thread ID: the value create() resolves, the one handed to onThreadId, or one returned by the Threads API. A caller can request a UUID during thread creation, but must confirm the returned identity before using it for a run. Generating an ID alone creates no backend thread.
What's Next
Pause agent execution and wait for human approval before continuing.
Preserve long-term context across sessions with LangGraph's memory store.
Stream token-by-token responses and tool progress in real time.
Test thread persistence and switching deterministically with MockAgentTransport.