Page actions

Time Travel

Time travel is the ability to look at what an agent's state was a few steps ago, and to start a run from there instead of from the end of the conversation. LangGraph writes a checkpoint after every step of a run, and injectAgent() exposes that checkpoint history as Angular Signals. The running example is a chat with a checkpoint timeline beside it, and this guide walks the three files that make it work.

Note: Use cases

Debug agent decisions, explore alternate paths, and build undo/redo experiences for your users. Time travel works with any LangGraph agent that persists checkpoints to a thread.

What the demo does

The Run tab shows the prebuilt <chat> composition next to a Timeline sidebar. Before you send anything, the sidebar reads "No checkpoints yet. Send a message to begin." Ask the assistant a question and the sidebar fills with numbered rows, each carrying a label, the full checkpoint identifier, and two buttons, Select and Fork.

Send two or three messages so the timeline has entries to move between. Select highlights the row and records that checkpoint identifier as the active branch, which starts nothing. Fork highlights the row and submits a new turn from that checkpoint, so a second path grows out of an earlier point in the thread while the original stays intact.

How it is built

Three files carry the feature: a graph that leaves checkpointing to the platform, an application config that registers the agent, and a component that renders the checkpoint history. Open the Code tab to read them in place.

A graph the platform checkpoints

The backend is a single streaming node over MessagesState, with a system prompt read from the capability's prompt file. No executable line in it concerns time travel, because the checkpoints come from the server that runs it.

graph.py
"""
LangGraph Time Travel Graph
 
Demonstrates checkpoint-based time travel. Each message exchange is saved as
a checkpoint snapshot. The client reads the thread's checkpoint history and can
start a new run from any past checkpoint by submitting with that checkpoint.
 
The LangGraph API server provides checkpointing automatically.
"""
 
from pathlib import Path
from typing import NotRequired
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"
 
 
class TimeTravelState(MessagesState):
    """Canonical final identities let the React preview verify adopted history."""
 
    completed_turn_id: NotRequired[str]
    completed_answer_id: NotRequired[str]
    completed_message_ids: NotRequired[list[str]]
 
 
def build_time_travel_graph():
    """
    Constructs a StateGraph that the LangGraph API server checkpoints.
 
    The LangGraph API checkpointer saves a snapshot after each node execution,
    producing a history of ThreadState objects that the client can replay or
    branch from using checkpoint IDs.
    """
    llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
 
    async def generate(state: TimeTravelState) -> dict:
        """Generate a response, checkpointed for time travel."""
        system_prompt = (PROMPTS_DIR / "time-travel.md").read_text()
        messages = [SystemMessage(content=system_prompt)] + state["messages"]
        response = await llm.ainvoke(messages)
        canonical = [*state["messages"], response]
        ids = [getattr(message, "id", None) for message in canonical]
        completion = {}
        if (len(canonical) >= 2
                and getattr(canonical[-2], "type", None) == "human"
                and getattr(response, "type", None) == "ai"
                and all(isinstance(identity, str) and identity for identity in ids)
                and len(ids) == len(set(ids))):
            completion = {
                "completed_turn_id": ids[-2],
                "completed_answer_id": ids[-1],
                "completed_message_ids": ids,
            }
        return {"messages": [response], **completion}
 
    graph = StateGraph(TimeTravelState)
    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_time_travel_graph()

compile() is called with no checkpointer, since the LangGraph API server provides persistence itself; the Persistence guide covers the checkpointer choices for a graph you embed in your own process.

The provider

The example resolves its connection details at runtime from the host that serves it, which is why provideAgent() takes a factory. Your own application passes apiUrl and assistantId directly.

app.config.ts
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,
      };
    }),
  ],
};

Reading the checkpoint history

The component injects the agent and derives the timeline from it. langGraphHistory() is LangGraph's own view of the thread: an array of ThreadState snapshots. selectedCheckpointId is local interface state, holding the checkpoint the user clicked last. New checkpoints can move existing rows, so selection follows the identifier across history refreshes.

time-travel.component.ts — the history signals
protected readonly agent = injectAgent();
 
/** Selection survives new checkpoints being prepended to the history. */
protected readonly selectedCheckpointId = signal<string | null>(null);
 
/** Checkpoint history derived from the agent. */
protected readonly checkpoints = computed(
  (): ThreadState<any>[] => this.agent.langGraphHistory(),
);

Nothing in the component fetches that history: the framework loads it when a thread is selected and refreshes it when a run completes.

The timeline sidebar

The <chat> composition owns the transcript, the input, and the loading and error states, so the template only adds the sidebar. Each row renders its position in the history, a label, the checkpoint identifier, and the two action buttons.

time-travel.component.ts — the timeline sidebar
<!-- Checkpoint timeline sidebar -->
<div sidebar class="panel">
  <div class="head">
    <h2 class="cap">Timeline</h2>
    <p class="sub">{{ checkpoints().length }} checkpoint(s)</p>
  </div>
 
  <div class="list">
    @if (checkpoints().length === 0) {
      <p class="empty">No checkpoints yet. Send a message to begin.</p>
    }
 
    @for (state of checkpoints(); track state.checkpoint?.checkpoint_id ?? $index; let i = $index) {
      <div
        class="row"
        [class.row--active]="state.checkpoint?.checkpoint_id === selectedCheckpointId()"
      >
        <!-- Numbered badge -->
        <span
          class="badge"
          [class.badge--active]="state.checkpoint?.checkpoint_id === selectedCheckpointId()"
        >
          {{ i + 1 }}
        </span>
 
        <!-- Checkpoint info -->
        <div class="info">
          <p class="label">{{ checkpointLabel(state, i) }}</p>
          @if (state.checkpoint?.checkpoint_id) {
            <p class="id">{{ state.checkpoint.checkpoint_id }}</p>
          }
        </div>
 
        <!-- Action buttons -->
        <div class="actions">
          <button
            class="btn"
            title="Select this checkpoint as the active branch"
            (click)="select(state)"
          >
            Select
          </button>
          <button
            class="btn"
            title="Fork from this checkpoint"
            (click)="fork(state)"
          >
            Fork
          </button>
        </div>
      </div>
    }
  </div>
</div>

checkpoint_id is optional on the Checkpoint a ThreadState carries, which is why the identifier line and both handlers guard on it.

Selecting a checkpoint

The two handlers do different work. Select records the clicked row and hands the checkpoint identifier to setBranch(), which is a pointer and nothing more. Fork records the row and calls submit() with { checkpointId }, which is the call that actually runs from a past checkpoint.

time-travel.component.ts — select and fork
/**
 * Mark a checkpoint as the active branch.
 *
 * `setBranch()` records the identifier in the agent's `branch()` signal.
 * It starts no run: it is a pointer the interface can read back.
 */
protected select(state: ThreadState<any>): void {
  if (state.checkpoint?.checkpoint_id) {
    this.selectedCheckpointId.set(state.checkpoint.checkpoint_id);
    this.agent.setBranch(state.checkpoint.checkpoint_id);
  }
}
 
/**
 * Start a new run from the given checkpoint.
 *
 * Passing `checkpointId` to `submit()` resumes the thread at that point
 * rather than at its tip, so the new run hangs off the chosen checkpoint
 * and the original path stays intact.
 */
protected fork(state: ThreadState<any>): void {
  const checkpointId = state.checkpoint?.checkpoint_id;
  if (!checkpointId) return;
  this.selectedCheckpointId.set(checkpointId);
  void this.agent.submit(
    { message: 'Try a different approach from here.' },
    { checkpointId },
  );
}
Warning: setBranch() records a pointer; it does not start a run

setBranch(id) writes the identifier into the branch() signal, and that is all it does. No run starts, and the next submit() continues from the tip of the thread as usual. Running from a past checkpoint is a submit() option, as Running from a past checkpoint shows.

Reading the history two ways

history() and langGraphHistory() describe the same thread at two levels. The first is the runtime-neutral shape every adapter that keeps checkpoints can produce; the second is LangGraph's own.

const agent = injectAgent();
 
// Runtime-neutral: AgentCheckpoint[], each with id, label, and values.
const checkpoints = computed(() => agent.history());
 
// Raw LangGraph: ThreadState[], each with checkpoint, parent_checkpoint,
// metadata, created_at, next, tasks, and the full values snapshot.
const rawCheckpoints = computed(() => agent.langGraphHistory());

The example reads the raw signal because it renders LangGraph's checkpoint object directly; history() would hand it the same identifier as an adapter-opaque id.

Running from a past checkpoint

submit() accepts LangGraph's run options, and two of them address a checkpoint: checkpoint takes a ThreadState.checkpoint object, and checkpointId takes only its identifier. Passing either starts the run from that point in the thread rather than from its tip, which is what creates a second path out of one checkpoint.

protected forkFrom(index: number, message: string): void {
  const checkpoint = this.agent.langGraphHistory()[index]?.checkpoint;
  if (!checkpoint) return;
  void this.agent.submit({ message }, { checkpoint });
}

The original path is left intact, so the earlier checkpoints stay in the history and the new run hangs off the one you named.

branch() holds the identifier the application last set, and setBranch() writes it. experimentalBranchTree() turns the flat history into a tree by linking each ThreadState to its parent_checkpoint, so a checkpoint with two children becomes a fork with one sequence per alternate path.

// The identifier the application last selected.
const activeBranch = computed(() => agent.branch());
 
// A sequence of nodes and forks, for a custom time-travel interface.
const branchTree = computed(() => agent.experimentalBranchTree());

An AgentBranchTree is a sequence whose items are either a node, carrying one ThreadState and the path that reached it, or a fork, carrying a list of alternate sequences. The signal is named experimental deliberately: treat its shape as unsettled.

Reading history on the server

The signals above are the client's view. When you embed the graph in your own process rather than serving it through LangGraph Platform, the compiled graph exposes the same history in Python, and the Persistence guide covers the checkpointer that case needs.

config = {"configurable": {"thread_id": "user_123"}}
 
# Every checkpoint recorded for the thread.
for state in graph.get_state_history(config):
    print(state.config["configurable"]["checkpoint_id"], state.next)
 
# Read one past checkpoint by id.
past_state = graph.get_state(
    {
        "configurable": {
            "thread_id": "user_123",
            "checkpoint_id": "<checkpoint-id-from-history>",
        }
    }
)

Comparing checkpoints

Two checkpoints and a diff tell you what a step actually changed, which is often faster than reading the transcript for it. The runtime-neutral history() is enough here, because values is the whole state snapshot.

protected compare(indexA: number, indexB: number) {
  const history = this.agent.history();
  const before = history[indexA]?.values;
  const after = history[indexB]?.values;
  if (!before || !after) return null;
 
  const changedKeys = Object.keys({ ...before, ...after }).filter(
    (key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]),
  );
  return { changedKeys };
}

Use the result to render a diff view, highlight changed fields, or log what the agent touched during a step.

Tip: Debugging workflow

Time travel is most useful during development. Inspect why an agent chose a particular path by comparing adjacent checkpoints, then fork to test alternatives without restarting the conversation. Combine history() with Angular DevTools to watch checkpoint arrays update in real time as the agent streams.

What's Next

Looking for something specific?