Skip to content

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:

kindWritten byWhen
inputappendMessages with a run start (the run-start commit)Every execute(), resume() and retry() entry, with that entry's input rows (possibly none)
appendappendMessages without a run startRows added inside a run, e.g. a client-tool result or a finishWith result appended after its step
startsaveStateAndPromoteStaging(…, { 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)
stepsaveStateAndPromoteStaging / promoteStagingAfter each LLM step, including the commit a run suspends with
statecommitStateThe entry state of some runs, and the terminal commit of every run (below)
truncatetruncateMessages, or a run-start commit that rewinds (truncateTo)A from_checkpoint rewind, a retry(), an interrupt that drops an unfinished step
clonecloneSessionA 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 compatibility
  • sessionId - The session this checkpoint belongs to
  • s5 - Step count when created
  • t... - Timestamp in milliseconds
  • Random suffix for uniqueness

Listing Checkpoints ​

Get all checkpoints for a session:

typescript
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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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.

typescript
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
  },
});
OptionCopies
checkpointIdThe first checkpoint.messageCount messages and the checkpoint's customState
messageIndexThe first messageIndex messages (a count) and the source's current customState
neitherAll 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 ​

  1. Debugging - Replay from a specific step to understand behavior
  2. Branching - Fork execution from a historical point
  3. Rollback - Undo recent steps if something went wrong
  4. What-if analysis - Try different inputs from the same state

Checkpoint Metadata ​

CheckpointMeta is a lightweight view for listing:

typescript
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:

typescript
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 retryable state_checkpoint_sequence_unavailable instead 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:

  1. Truncate messages beyond messageCount inside the new run's run-start commit
  2. Clean up stream chunks beyond the checkpoint's step, scoped to the resumed run (runtime-js, both Cloudflare runtimes and DBOS)
  3. 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:

typescript
// 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:

typescript
// Delete a session's data (including checkpoints)
await stateStore.deleteSession(sessionId);

Checkpoint Parsing ​

Parse checkpoint IDs to extract components:

typescript
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:

typescript
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:

MethodDescription
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:

typescript
// 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:

typescript
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:

typescript
const checkpoints = await stateStore.listCheckpoints(sessionId);
console.log(`Session has ${checkpoints.total} checkpoints`);

// For Redis, check memory usage
// For D1, check row counts

Next Steps ​

Released under the MIT License.