Durable Execution
Persistence keeps a thread's history; durable execution keeps a run alive through failure. When a step crashes, the process restarts, or a deployment rolls, the checkpointer lets the run resume from its last completed super-step instead of starting over, and the UI can offer a retry rather than a blank transcript.
This page is the live example for durable execution over LangGraph. Use Run to watch one request move through a three-node graph that commits its state after every node, Code to read the Angular and Python sources, and API for the extracted reference. The checkpointer configuration it relies on is written up in the Persistence guide.
What the demo does
The Run tab shows the prebuilt <chat> composition beside a sidebar titled Pipeline, listing three steps: Analyze, Plan, and Generate. Send any question and the sidebar tracks the run. Each step shows the spinner once its own node has committed and the next one is running, and turns green when a later node commits. The graph writes the name of the node it just finished, so the indicator trails execution by one step, and nothing spins while the first node runs. The component reads that field back out of state.
Two things are worth watching. The first is how long each step holds: the graph makes a separate model call per node, so the answer you eventually read is the third of three passes rather than one long generation. The second is where the sidebar stops. The final step stays marked active rather than complete when the run ends, because generate is the last name the graph ever writes and there is no fourth step for the indicator to advance to.
Nothing in the demo fails on purpose. What durability buys you here is visible in the shape of the run rather than in a staged crash: three separately committed steps instead of one all-or-nothing call.
How it is built
Three files carry the feature: a graph that splits one answer into three checkpointed nodes, an application config that registers the agent, and a component that derives the sidebar from graph state. Open the Code tab to read them in place.
The state the run carries
The state schema is short on purpose. Alongside the message list it keeps a single step string, which is the entire progress protocol between the graph and the browser.
class DurableState(TypedDict):
messages: list
step: str # Most recently completed node
completed_turn_id: NotRequired[str] # Written only by the final node
completed_answer_id: NotRequired[str] # Paired with the final human identityNeither field declares a reducer, so whatever a node returns for a key replaces that key outright.
One node of the pipeline
Each node loads the shared system prompt, appends a line telling the model which phase it is in, calls the model, and returns the conversation with its own response appended plus the name of the step it just completed. analyze is the first of the three and plan is the same code with a different instruction.
async def analyze(state: DurableState) -> dict:
"""
Node 1: Analyze the user request.
Examines the incoming messages to understand intent and key details.
Checkpoints after completion so a failure in 'plan' doesn't rerun this.
"""
messages = [SystemMessage(content=system_prompt + "\n\nYou are in the ANALYZE step. Briefly identify the key aspects of the request in 1-2 sentences.")] + state["messages"]
response = await llm.ainvoke(messages)
return {
"messages": state["messages"] + [response],
"step": "analyze",
}The return value is the commit: LangGraph saves the state a node returns before the next node starts, which is what makes the run recoverable partway through.
The node that ends the run
generate is where the shape changes. The first two nodes append to the message list they received, so the intermediate analysis and outline accumulate in state. The last node returns two messages, and because messages has no reducer that return replaces the list. It scans backwards for the newest human message rather than reading state["messages"][-1], which by that point is the plan node's outline, so the pair it commits is the user's original question and the final answer.
async def generate(state: DurableState) -> dict:
"""
Node 3: Generate the final response.
Synthesises the analysis and plan into a complete, helpful reply.
This is the last node before END; its output is the user-visible answer.
"""
messages = [SystemMessage(content=system_prompt + "\n\nYou are in the GENERATE step. Synthesise the analysis and plan into a single, clear, helpful response for the user.")] + state["messages"]
response = await llm.ainvoke(messages)
# DurableState.messages has no reducer, so this return REPLACES the list.
# Keep the user's original question (the newest human message) alongside
# the final answer; the intermediate analyze/plan drafts are dropped.
question = next(
(m for m in reversed(convert_to_messages(state["messages"])) if m.type == "human"),
None,
)
result = {
"messages": ([question] if question is not None else []) + [response],
"step": "generate",
}
question_id = getattr(question, "id", None)
answer_id = getattr(response, "id", None)
if (
isinstance(question_id, str) and question_id
and isinstance(answer_id, str) and answer_id
and question_id != answer_id
):
result["completed_turn_id"] = question_id
result["completed_answer_id"] = answer_id
return resultThe scratch work exists for exactly as long as the run needs it: once generate commits, the thread holds the question and the answer, and the analysis and outline are gone.
Wiring the three nodes
The graph is a straight line. Three nodes, three edges, and an entry point.
graph = StateGraph(DurableState)
graph.add_node("analyze", analyze)
graph.add_node("plan", plan)
graph.add_node("generate", generate)
graph.set_entry_point("analyze")
graph.add_edge("analyze", "plan")
graph.add_edge("plan", "generate")
graph.add_edge("generate", END)
return graph.compile()compile() is called with no checkpointer. That is not a gap: the LangGraph API server provides persistence for every thread it serves and either rejects or ignores a graph that brings its own, depending on whether it is langgraph dev or a deployment. The Persistence guide covers the checkpointer choices for a graph you run inside your own process.
The agent provider
provideAgent() registers the agent once for the whole application, and it is the only provider <chat> requires. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
};
}),
],
};Your own application does not need the factory. Pass the two values directly:
provideAgent({
apiUrl: 'https://your-deployment.langgraph.app',
assistantId: 'durable-execution',
});assistantId must match the graph name in langgraph.json.
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.
Deriving the pipeline from state
The component holds the node order and the labels it renders for them. This list is the contract with the graph: the strings are the node names the Python side writes into state.step.
/**
* Pipeline step definition for the vertical progress indicator.
*/
interface PipelineStep {
id: string;
label: string;
status: 'pending' | 'active' | 'complete';
}
/** Ordered node names emitted by the Python graph's `state.step` field. */
const STEP_ORDER = ['analyze', 'plan', 'generate'] as const;
/** Human-readable labels for each pipeline step. */
const STEP_LABELS: Record<string, string> = {
analyze: 'Analyze',
plan: 'Plan',
generate: 'Generate',
};injectAgent() returns the agent registered above, and value() is the graph state as a signal. The steps computed reads step out of it, finds that name in the ordered list, and marks everything before it complete, that entry active, and everything after it pending. An unknown or missing step leaves every entry pending, which is the state before the first submission, and a second question in the same thread starts from the stale generate value, because nothing clears the field.
protected readonly agent = injectAgent();
/**
* Derives the 3-step pipeline status from the graph's `state.step` field.
*
* Steps before the current one are marked complete, the current step is
* active, and subsequent steps remain pending.
*/
protected readonly steps = computed<PipelineStep[]>(() => {
const val = this.agent.value() as Record<string, unknown> | undefined;
const currentStep = (val?.['step'] as string) ?? '';
const activeIndex = STEP_ORDER.indexOf(currentStep as (typeof STEP_ORDER)[number]);
return STEP_ORDER.map((id, i) => ({
id,
label: STEP_LABELS[id],
status: activeIndex < 0 ? 'pending' : i < activeIndex ? 'complete' : i === activeIndex ? 'active' : 'pending',
}));
});No polling and no subscription: the step field arrives with the state updates the run is already streaming, so the sidebar recomputes as each node commits.
The layout
<chat> takes the agent in the main slot and renders the conversation, the input, the loading state, and errors. The sidebar slot is the pipeline, rendered straight from the steps signal with a spinner, a check, or a pending circle per entry.
<chat main [agent]="agent" [views]="ui" [store]="uiStore" class="flex-1 min-w-0" />
<div sidebar class="panel">
<h3 class="cap">Pipeline</h3>
<div class="steps">
@for (step of steps(); track step.id; let last = $last) {
<div class="step">
<!-- Step indicator column -->
<div class="step__col">
<!-- Circle / icon -->
@switch (step.status) {
@case ('complete') {
<div class="circle circle--complete">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
}
@case ('active') {
<div class="circle circle--active">
<div class="dot dot--active"></div>
</div>
}
@default {
<div class="circle circle--pending">
<div class="dot dot--pending"></div>
</div>
}
}
<!-- Connecting line -->
@if (!last) {
<div class="line" [class.line--done]="step.status === 'complete'" [class.line--pending]="step.status !== 'complete'"></div>
}
</div>
<!-- Label -->
<span
class="label"
[class.label--active]="step.status === 'active'"
[class.label--complete]="step.status === 'complete'"
[class.label--pending]="step.status === 'pending'"
>
{{ step.label }}
</span>
</div>
}
</div>
</div>The views and store inputs register a generative-UI catalog with the chat composition; this graph emits no view specs, so the catalog goes unused here and the Generative UI guide is where it earns its place.
What durability covers
Splitting one answer across three nodes is what makes the run durable. Each node's return is committed before the next one starts, so a failure in generate does not throw away the analysis and the outline, and the thread you retry against is not empty.
What it does not cover is the request in the browser. A dropped connection, a restarted server, or an error from the model ends the run in the client with an error, and something has to start a new one. That is the UI's job, and it is one call — but only when starting a new one is safe.
Retrying a failed run
The <chat> composition already renders the retry. Its error banner shows a Retry button whenever the error it received is marked retryable — a connection failure is — and the button calls agent.retry().
A dropped stream is the case that does not qualify. When the stream closes without a terminal event the adapter reports an interrupted error, and the run may well have carried on and committed its next node on the server. Retrying it would run that work twice, so the banner does not offer Retry. Instead it offers Check status where the transport can read thread history — a read-only re-read that resubmits nothing and clears the error if the run turns out to have finished — and otherwise says plainly that the outcome could not be confirmed. Every route into an interruption here means the request was dispatched, so none of them offers Retry. The partial answer and any pending approval stay on the signals either way.
retry() clears the error and re-submits the last payload the agent sent, with the same options, against the same thread. Two guards matter when you call it yourself:
- It returns immediately while a run is in flight, so a double click cannot start a second run.
- It does nothing when there is no last payload: before the first submission, and after
switchThread(), which clears it. A Retry button on a thread the user just switched to is a button that does nothing.
reload() performs the same re-submission without clearing the error first. Reach for retry() in a UI and reload() when you want the previous error to stay on screen while the new attempt runs.
Re-submitting starts a fresh run against the thread, and the thread still holds everything the failed attempt committed. That is the point — the completed work survives — but it also means a retry does not undo those messages. When a failed attempt leaves state the next one should not see, regenerate(index) is the call that rolls back first: it removes everything after the user message that preceded that index — the assistant turn and any tool messages with it — then re-runs against the trimmed thread; it throws rather than no-ops while a run is loading or when the index is not an assistant message.
submit(null) resumes; it does not retry
submit(null) starts a run with no new input. LangGraph advances whatever the thread still has pending, which is why it is the resume call for a graph paused at an interrupt() — see the Interrupts guide.
It is not the retry. Once a run has reached the end of the graph the thread has nothing pending, and submit(null) does nothing at all: the run completes immediately without executing a node, the thread is unchanged and no error is raised. regenerate() is the one path that resumes a finished thread this way, and it can only do so because it first repositions the thread to the entry node — LangGraph's as_node parameter, applied through the transport's updateState — before submitting null.
Both retry() and reload() are silent no-ops when the agent has nothing to re-submit. Render the button from error() rather than unconditionally, as <chat> does, so the user is never offered a control that cannot do anything.
Where checkpointing comes from
Nothing in this example configures durability. The graph compiles without a checkpointer and the component asks for no recovery options, because the LangGraph API server checkpoints every thread it runs. Move the same graph into a process you host and the checkpointer becomes yours to choose — an in-memory saver for tests, Postgres for anything that has to survive a restart. The Persistence guide covers the choice and the warning that comes with it: a graph served by langgraph dev or LangGraph Platform must not compile a checkpointer of its own.