Checkpoints
Checkpoints are complete snapshots of agent state, written by every commit to the session's message log or state. They enable time-travel debugging, crash recovery, consistent snapshots and branching execution.
What are Checkpoints?
A checkpoint contains:
- Complete agent state (messages, custom state, step count)
- Agent status at that point
- The message count and the stream position it covers (
messageCount,streamSequence) - The commit that wrote it (
kind) and the run it belongs to (runId) - Timestamp of creation
- Unique versioned ID
Every commit writes one
The runtimes change the message log and the snapshot-visible state only through commits. Each commit writes its rows or state and a checkpoint in one atomic step, and moves the session's latest-checkpoint pointer to it. A checkpoint's kind names the commit:
kind | Written by | When |
|---|---|---|
input | appendMessages with a run start (the run-start commit) | Every execute(), resume() and retry() entry, with that entry's input rows (possibly none) |
append | appendMessages without a run start | Rows added inside a run, e.g. a client-tool result or a finishWith result appended after its step |
start | saveStateAndPromoteStaging(…, { kind: 'start' }) | Once per turn, before its first LLM call: the writes of onAgentStart and the initial onMessage (#177; runtime-js, CF DO, CF Workflows) |
step | saveStateAndPromoteStaging / promoteStaging | After each LLM step, including the commit a run suspends with |
state | commitState | The entry state of some runs, and the terminal commit of every run (below) |
truncate | truncateMessages, or a run-start commit that rewinds (truncateTo) | A from_checkpoint rewind, a retry(), an interrupt that drops an unfinished step |
clone | cloneSession | A branch's copy of its source session |
saveState and createCheckpoint are not commits. saveState never writes a checkpoint and never moves the pointer. createCheckpoint writes a row (with kind: null) without moving the pointer. A checkpoint written before this release also reads kind: null and runId: null. This is a breaking change for custom state stores; see State Stores and the upgrade guide.
The terminal commit
Every run ends with a state commit that carries its terminal status (completed, failed or interrupted) and its error. It is written on completion, on failure and on stop, after the agent's terminal hooks and before the run's terminal status and the stream's end, so the latest checkpoint always reflects the turn that just ended. It carries the terminal hooks' state writes on completion; which runtimes keep onAgentFail's writes is listed in Hooks → onAgentComplete.
retry() never restores this terminal commit. By default it targets the failed run's last step commit, or its run-start commit when the run failed on its first step. See Interrupt and Resume → Retrying Failed Agents.
Temporal checkpoints a finishWith step before its result (RM-52)
On Temporal, a step that calls a finishWith tool is committed before that call's result is appended, so the step's step checkpoint ends on a tool_use with no tool_result. The result follows in a separate append commit. A default retry() targets that later append commit, but a retry({ checkpointId }) or resume({ mode: 'from_checkpoint' }) to the step's own checkpoint truncates the transcript to the unpaired prefix. The JS runtime commits the step with the result included. On Temporal, do not rewind to the checkpoint of a finishWith step. See RM-52 in the conformance findings.
Checkpoint IDs
Checkpoint IDs follow a versioned format:
cpv1-{sessionId}-s{stepCount}-t{timestamp}-{random6hex}Example: cpv1-session-abc123-s5-t1703123456789-a1b2c3
The format includes:
cpv1- Version prefix for forward compatibilitysessionId- The session this checkpoint belongs tos5- Step count when createdt...- Timestamp in milliseconds- Random suffix for uniqueness
Listing Checkpoints
Get all checkpoints for a session:
const checkpoints = await stateStore.listCheckpoints(sessionId);
for (const meta of checkpoints.checkpoints) {
console.log(`Step ${meta.stepCount}: ${meta.id}`);
console.log(` Status: ${meta.status}`);
console.log(` Created: ${new Date(meta.timestamp)}`);
}Pagination
For sessions with many steps, use pagination:
// First page
const page1 = await stateStore.listCheckpoints(sessionId, {
limit: 10,
offset: 0,
});
console.log(`Showing ${page1.checkpoints.length} of ${page1.total}`);
// Next page
if (page1.hasMore) {
const page2 = await stateStore.listCheckpoints(sessionId, {
limit: 10,
offset: 10,
});
}The Postgres, D1 and Durable Object stores order a page by stepCount, then write time, then checkpoint id; Redis orders by stepCount, then checkpoint id; the memory store lists in write order. Each order is total, so a page query returns the same order every time. Every built-in store reads total and the page in one consistent read, so total, hasMore and the page agree (RM-53, RM-54).
stepCount restarts on every turn, so a new commit can land on an earlier page while you walk a live session. For a complete list, read it once the session is idle, and de-duplicate by checkpoint id.
Retrieving a Checkpoint
Get a specific checkpoint with full state:
// Get latest checkpoint
const latest = await stateStore.getLatestCheckpoint(sessionId);
if (latest) {
console.log(`Latest at step ${latest.stepCount}`);
console.log('Messages:', latest.state.messages.length);
console.log('Custom state:', latest.state.customState);
}
// Get specific checkpoint
const checkpoint = await stateStore.getCheckpoint(checkpointId);
if (checkpoint) {
console.log('State:', checkpoint.state);
}"Latest" means most-recently-written, not highest stepCount
getLatestCheckpoint returns the checkpoint that was written most recently, which is the one the session's checkpoint pointer references. It does not return the checkpoint with the highest stepCount.
This distinction matters because stepCount is not monotonic across turns: each new turn (a fresh execute()/resume() on the same session) restarts its own step counter, so a later turn's first checkpoint can have a lowerstepCount than the previous turn's final checkpoint. The runtime relies on getLatestCheckpoint to find the boundary of committed state when cleaning up orphaned messages on resume — returning a stale higher-stepCount checkpoint would truncate the current turn's messages (this was a real bug for client-executed-tool flows on the Postgres, Redis, D1, and Durable Object stores). Every built-in store reads only the pointer, which only a commit moves: saveState and createCheckpoint never move it (RM-37, RM-38).
Time-Travel
Resume from any checkpoint to "time-travel" to that state:
// List checkpoints
const checkpoints = await stateStore.listCheckpoints(sessionId);
// Pick an earlier checkpoint (e.g., step 3)
const targetCheckpoint = checkpoints.checkpoints.find((c) => c.stepCount === 3);
if (targetCheckpoint) {
// Resume from that checkpoint
const newHandle = await handle.resume({
mode: 'from_checkpoint',
checkpointId: targetCheckpoint.id,
});
// Agent continues from step 3
const result = await newHandle.result();
}A time-travel resume rewinds the model context and the durable message log to the target checkpoint on every runtime. The rewind happens inside the new run's run-start commit (StartRun.truncateTo), which writes a truncate checkpoint attributed to the new run, so a rewind and the run it starts are one atomic step: a caller that loses the run start rewinds nothing. Messages written after the target are deleted, pending client-tool calls made after it are discarded (each gets an aborted tool_end; a late submit returns unknown_tool_call), and the next model call sees exactly the checkpoint's history plus any new input. See Interrupt and Resume → from_checkpoint.
Checkpoints are scoped to their session
retry({ checkpointId }), resume({ mode: 'from_checkpoint', checkpointId }) and branch.checkpointId only accept a checkpoint of the session they act on (for a branch, the source session). The check happens before anything is written:
import { HelixError } from '@helix-agents/core';
try {
await executor.resume(agent, sessionA, { mode: 'from_checkpoint', checkpointId: checkpointOfB });
} catch (err) {
if (HelixError.isInstance(err) && err.code === 'state_checkpoint_foreign') {
// checkpointOfB belongs to another session; sessionA and B are unchanged
}
}An id that does not exist throws state_checkpoint_not_found.
Branching from a Checkpoint
execute() with a branch option starts a NEW session from a point in an existing one. The source session is not modified.
const branched = await executor.execute(agent, 'Try a different approach', {
sessionId: 'session-branch-1', // must be a new session id
branch: {
fromSessionId: 'session-original',
checkpointId: checkpoint.id, // or messageIndex: 10
},
});| Option | Copies |
|---|---|
checkpointId | The first checkpoint.messageCount messages and the checkpoint's customState |
messageIndex | The first messageIndex messages (a count) and the source's current customState |
| neither | All messages and the source's current customState |
checkpointId wins when both are set. The branch behaves the same on JS, Temporal, DBOS and Cloudflare Workflows. The Cloudflare Durable Object runtime cannot branch, because a Durable Object can only read its own database.
A branch never inherits what happened in the source AFTER its boundary: not the source's later status, output or error (nor the forced-completion phase of its live row). Its history and customState come from the boundary and its first turn is a new turn. The same holds for stateStore.cloneSession called directly (RM-72): a checkpoint clone takes its status, customState, step count and forced phase from the checkpoint (and, at a completed or failed checkpoint, that checkpoint's result or error); a message-index or whole-log clone keeps the source's current customState and step count and starts active with nothing else inherited. Its forced-completion phase: a whole-log clone takes the latest committed checkpoint's, never the live row (user ruling 2026-10-08); a message-index clone carries no forced phase (pending FU-CLONE-INDEX-BOUNDARY-FORCED-PHASE). On Temporal and Cloudflare Workflows a branch carries no forced phase on any branch kind and starts at step count 0 (FU-BRANCH-SOURCE-FORCED-PHASE-PARITY).
Use Cases
- Debugging - Replay from a specific step to understand behavior
- Branching - Fork execution from a historical point
- Rollback - Undo recent steps if something went wrong
- What-if analysis - Try different inputs from the same state
Checkpoint Metadata
CheckpointMeta is a lightweight view for listing:
interface CheckpointMeta {
id: string; // Checkpoint ID
sessionId: string; // Session this checkpoint belongs to
stepCount: number; // Step count when created
timestamp: number; // Creation time (ms since epoch)
status: AgentStatus; // Status at checkpoint time
}The full Checkpoint includes the complete state plus recovery coordination fields:
interface Checkpoint<TState, TOutput> {
id: string;
sessionId: string; // Session this checkpoint belongs to
stepCount: number;
timestamp: number;
state: AgentState<TState, TOutput>; // Full agent state
messageCount: number; // Message count at checkpoint (for recovery coordination)
streamSequence: number; // Stream head at the commit (for resumption)
kind: CommitKind | null; // The commit that wrote it; null for a legacy or createCheckpoint row
runId: string | null; // The run that wrote it; null for a legacy row and a clone
keepCount: number | null; // For a truncate checkpoint: the rows kept; otherwise null
}Recovery Coordination Fields
The messageCount and streamSequence fields enable coordinated recovery after crashes or interrupts, and anchor snapshots:
- messageCount: Number of messages at this checkpoint. It is the store's live row count when the commit landed, and the exact rewind / branch boundary.
- streamSequence: The live stream head when the commit landed (core
resolveCommitSequence). Everything committed up to it is in the checkpoint's messages, and nothing after it. A snapshot uses it as its cursor. It is-1(NO_STREAM_SEQUENCE) when no stream manager is wired or the stream does not exist yet. If reading the head fails, the commit fails with the retryablestate_checkpoint_sequence_unavailableinstead of recording a guess (DI-21).
Because every message row is written by a commit, the latest checkpoint always counts every committed message: an entry's input is part of its run-start commit, and a step's assistant and tool-result rows are part of its step commit (RM-40, RM-41, RM-42). When resuming from a checkpoint, the runtime uses these values to:
- Truncate messages beyond
messageCountinside the new run's run-start commit - Clean up stream chunks beyond the checkpoint's step, scoped to the resumed run (runtime-js, both Cloudflare runtimes and DBOS)
- Resume streaming from the correct sequence position
A checkpoint written before this release keeps the values it was written with. Old saveState checkpoints often record streamSequence 0, and their kind is null. A session whose latest checkpoint is such a row is served in legacy mode until its first new commit; see the upgrade guide.
Storage Considerations
Size
Each checkpoint stores the complete agent state, including all messages. For agents with:
- Long conversations
- Large custom state
- Many steps
Storage can grow significantly. Plan your retention accordingly.
Retention
Configure TTL for automatic cleanup:
// Redis store with 7-day retention
const stateStore = new RedisStateStore({
host: 'localhost',
ttl: 86400 * 7, // 7 days in seconds
});Cleanup
For manual cleanup, delete a session and its checkpoints:
// Delete a session's data (including checkpoints)
await stateStore.deleteSession(sessionId);Checkpoint Parsing
Parse checkpoint IDs to extract components:
import { parseCheckpointId, generateCheckpointId } from '@helix-agents/core';
// Parse an existing ID
const parsed = parseCheckpointId('cpv1-session-123-s5-t1703123456789-a1b2c3');
if (parsed) {
console.log(parsed.version); // 1
console.log(parsed.sessionId); // 'session-123'
console.log(parsed.stepCount); // 5
console.log(parsed.timestamp); // 1703123456789
console.log(parsed.random); // 'a1b2c3'
}
// Generate a new ID
const newId = generateCheckpointId('session-456', 10);
// Returns: 'cpv1-session-456-s10-t{timestamp}-{random}'Stream Events
The stream schema defines a checkpoint_created chunk, but no runtime writes it today, so a loop that waits for it never fires. To find the latest checkpoint, read the store:
const cp = await stateStore.getLatestCheckpoint(sessionId);
// or: const { checkpoints } = await stateStore.listCheckpoints(sessionId);Every runtime writes a step_committed chunk after each step commit, including a suspension commit and a terminal step. It carries the run's runId, the new checkpointId and committedAttemptId, the model attempt whose streamed content the step kept. The write is best-effort: a failed chunk write does not undo the commit. A rewind (from_checkpoint, retry()) writes one stream_resync chunk; when the rewind truncated the log, its truncateCheckpointId names the truncate checkpoint it wrote. See Streaming for the chunk shapes.
StateStore Methods
Checkpoint-related StateStore methods:
| Method | Description |
|---|---|
getCheckpoint(checkpointId) | Get full checkpoint by ID |
getLatestCheckpoint(sessionId) | Get most recent checkpoint |
listCheckpoints(sessionId, options?) | List checkpoint metadata with pagination |
These methods are implemented by all state stores (Memory, Redis, Postgres, Cloudflare D1 and Durable Object SQLite).
Checkpoints are written by the commit methods (appendMessages, saveStateAndPromoteStaging / promoteStaging, commitState, truncateMessages, cloneSession), never by saveState. createCheckpoint writes a row without moving the latest pointer. See State Stores for the commit contract a custom store implements.
Best Practices
1. Use Checkpoints for Debugging
When an agent behaves unexpectedly:
// List checkpoints to find where things went wrong
const checkpoints = await stateStore.listCheckpoints(sessionId);
for (const cp of checkpoints.checkpoints) {
const full = await stateStore.getCheckpoint(cp.id);
console.log(`Step ${cp.stepCount}: ${full?.state.messages.length} messages`);
}2. Implement Rollback UI
Let users undo agent actions:
async function rollbackToStep(sessionId: string, targetStep: number) {
const checkpoints = await stateStore.listCheckpoints(sessionId);
const target = checkpoints.checkpoints.find((c) => c.stepCount === targetStep);
if (!target) {
throw new Error(`No checkpoint at step ${targetStep}`);
}
const handle = await executor.getHandle(agent, sessionId);
return handle?.resume({
mode: 'from_checkpoint',
checkpointId: target.id,
});
}3. Monitor Storage Growth
Track checkpoint storage for capacity planning:
const checkpoints = await stateStore.listCheckpoints(sessionId);
console.log(`Session has ${checkpoints.total} checkpoints`);
// For Redis, check memory usage
// For D1, check row countsNext Steps
- Interrupt and Resume - Use checkpoints for pause/resume
- Distributed Coordination - Checkpoints in multi-process deployments
- State Management - Understanding agent state