Checkpointing
Snapshot sessions, restore to any prior point, and time-travel through stuck conversations — the per-channel checkpoint API.
A checkpointer snapshots every channel in the session and lets you restore to any prior point. The substrate uses checkpoints for automatic rollback on error; consumers use them for time-travel debugging, eval, and replay.
Checkpoint is one of three concepts in the
state-and-persistence cluster —
alongside the storage adapter and the
sync version vector — that together carry session
state across restarts, rewinds, and concurrent writers. This page
covers the rewind axis: per-channel snapshots written at message
and event boundaries, restorable in place via
runtime.checkpoints.rollback or branchable via
TimeTravelAPI.fork.
import {
MemorySaver,
IndexedDBSaver,
SupabaseSaver,
createSupabaseSaver,
type SupabaseSaverConfig,
} from "@pleach/core/checkpointing";Prefer createSupabaseSaver(config) over new SupabaseSaver(config) —
the factory wires schema and RLS context correctly. The plain
constructor is fine for tests but skips that plumbing.
@pleach/core/checkpointingSourcesrc/checkpointer/Picking a checkpointer
| Checkpointer | Environment | Pairs naturally with |
|---|---|---|
MemorySaver | Any | MemoryAdapter (tests, dev) |
IndexedDBSaver | Browser | IndexedDBAdapter (offline-first) |
SupabaseSaver | Server | SupabaseAdapter (production) |
You can mix-and-match across storage and checkpointer types, but
the natural pairings above are what HARNESS_MOCK_MODE=true wires
and what production setups land on.
Provider-agnostic Postgres
On a non-Supabase Postgres (node-postgres, Neon, RDS, pglite), pair the
provider-agnostic PgStorageAdapter
and createPgEventLogWriter — both
take an injected PgClientLike. Cross-restart durability requires wiring a
durable storage adapter and event-log writer; without them the runtime
defaults to in-memory and loses state on restart. A shipped Postgres
Checkpointer is not bundled yet — implement the Checkpointer interface
against your PgClientLike if you need durable checkpoints on the same store
(the durable local-dev store is the
executable reference).
Durability levels
Every checkpoint carries a _durability stamp set by the
checkpointer after the underlying write acknowledges. The
stamp lets the runtime know which side of a partition the
checkpoint actually survived.
| Checkpointer | _durability.level | When the stamp fires | Survives |
|---|---|---|---|
MemorySaver | "memory" | Synchronously, immediately on put() | Process restart? No. |
IndexedDBSaver | "disk" | After the IDB transaction's complete event fires | Browser refresh? Yes. Tab close + reopen? Yes. Profile wipe? No. |
SupabaseSaver | "replicated" | After the server-side insert returns 200 | Process restart? Yes. Region failover? Depends on your Postgres replication. |
This is pleach's shape for the same trade LangGraph documents as
durability: "exit" | "async" | "sync". LangGraph offers the dial
per write against the same checkpointer; pleach pushes the
choice to the checkpointer constructor and stamps the outcome on
every checkpoint. The reason: a per-write dial means a single
session can have some checkpoints durable and others not, which
makes "did this turn survive the crash" a runtime decision.
Pleach's stamp-after-ack discipline means inspecting any
checkpoint tells you the durability level deterministically.
If you need LangGraph's per-write dial behavior — async writes for
hot loops, sync writes at the turn boundary — wrap a checkpointer
that batches put() calls and flushes on a signal. The
contract intentionally doesn't ship this out of the box; the
operator-visible discipline is "one checkpointer, one durability
level per session."
Wiring a checkpointer
import { SessionRuntime } from "@pleach/core";
import { createSupabaseAdapter } from "@pleach/core/sessions";
import { createSupabaseSaver } from "@pleach/core/checkpointing";
const runtime = new SessionRuntime({
storage: createSupabaseAdapter(supabase),
checkpointer: createSupabaseSaver(supabase),
userId: "user_123",
});SupabaseSaverConfig exposes tableName and a maxCheckpoints cap;
the saver auto-prunes on every put to keep the per-session
checkpoint count under that ceiling. Pass maxCheckpoints: 0 to
disable auto-prune and manage retention yourself.
Once wired, the runtime writes a checkpoint at message and event
boundaries — each tells you what triggered it through
CheckpointMetadata.source ("message", "tool_execution",
"job_complete", "subagent", "sync", or "manual"). You don't
have to call anything; the checkpoints exist by the time the turn
lands. Each write fires a checkpoint.created stream event carrying
the full Checkpoint envelope — the same event the React DevTools
surface listens for to populate its checkpoint picker. The source
on the envelope is what tells a UI "this checkpoint was written
after a tool execution" versus "this one was written on the inbound
message."
Listing checkpoints for a session
runtime.checkpoints.list is an async generator yielding
CheckpointSummary records, newest first.
for await (const cp of runtime.checkpoints.list(sessionId)) {
console.log(cp.id, cp.createdAt, cp.source);
// source: "manual" | "message" | "tool_execution" | "job_complete" | "subagent" | "sync"
}Pass a limit as the second argument to cap iteration without
draining the underlying generator.
Rolling back to a checkpoint
const restored = await runtime.checkpoints.rollback(sessionId, checkpointId);The runtime rebuilds session state — messages, in-flight tool calls,
pending jobs, planning context — to exactly what it
was at the checkpoint, bumps its version vector, and writes a NEW
checkpoint with source: "manual", writes: ["rollback"], and
parentCheckpointId set to the rollback target. The next
executeMessage continues from the restored state.
Beta — channel-level re-execution replay. Rollback restores the persisted session state (durable checkpoints are battery-proven). Reconstructing every in-memory channel to re-run the turn deterministically from the checkpoint is a completeness slice still in beta — the runtime wires a minimal channel projection today (
createChannels: () => ({})). Treat the continuation as state-restore, not deterministic channel-replay.
Rollback vs fork
The two shapes look similar but answer different questions.
| Operation | Same sessionId? | Reach for it when |
|---|---|---|
rollback(sessionId, cp) | Yes — session is mutated in place | A tool ran with bad args (e.g. search_corpus called with the wrong query) and you want to retry from the pre-call channel state without losing the user's prior messages |
fork(sessionId, cp, …) | No — new sessionId is minted | You want to keep the original transcript intact and explore an alternate continuation (eval branch, "what if the planner had picked classify instead of summarize") |
Rollback is destructive on the live session; fork is non-destructive and gives you two siblings sharing a common parent checkpoint.
The flat methods runtime.rollbackToCheckpoint(...) and
runtime.listCheckpoints(...) remain callable but are deprecated —
prefer the runtime.checkpoints facet.
Time-travel from React DevTools
In a React app with useHarnessDevTools() wired, time-travel is a
one-liner from the browser console:
__HARNESS_DEVTOOLS__.checkpoints();
// → [{ id: "cp_018f...", source: "tool_execution", createdAt: ... }, ...]
__HARNESS_DEVTOOLS__.rollback("cp_018f...");
// session state reverts; UI re-renders against the restored stateSee DevTools below for the full surface.
Branching: fork a session from a checkpoint
TimeTravelAPI is the substrate-shipped fork surface. Construct it
once from any Checkpointer and call fork to spawn a new session
id pointing at the same state envelope.
import { TimeTravelAPI } from "@pleach/core/time-travel";
const api = new TimeTravelAPI(checkpointer);
const forked = await api.fork(sourceSessionId, checkpointId, crypto.randomUUID());The forked checkpoint records source: "fork" and a forkedFrom
metadata block carrying the source session id + parent checkpoint id
so the lineage stays queryable from the audit ledger.
The Checkpointer interface
All three savers implement the same contract. Write your own (Redis, S3, filesystem) by implementing it:
import type {
Checkpointer,
Checkpoint,
CheckpointMetadata,
CheckpointListOptions,
PendingWrite,
} from "@pleach/core";
interface Checkpointer {
put(
threadId: string,
state: SessionState,
metadata: CheckpointMetadata,
): Promise<Checkpoint>;
get(threadId: string, checkpointId?: string): Promise<Checkpoint | null>;
list(
threadId: string,
options?: CheckpointListOptions,
): AsyncIterable<Checkpoint>;
delete(threadId: string, checkpointId: string): Promise<void>;
prune(threadId: string, keepLast: number): Promise<number>;
putWrites?(
threadId: string,
writes: PendingWrite[],
taskId: string,
): Promise<void>;
}threadId here is the session id — the parameter name carries over
from the LangGraph-derived checkpointer contract. list is an
AsyncIterable, not an array; iterate with for await. prune
returns the number of checkpoints deleted and keeps the most recent
keepLast (it does NOT take an "after" cursor). A typical retention
job runs prune(sessionId, 50) per session at the end of each turn
— SupabaseSaver already does the equivalent via maxCheckpoints,
so you only call prune directly when wiring a custom Checkpointer
against a store the runtime can't auto-prune for you.
A Checkpoint is a typed envelope carrying the session state
plus per-channel snapshots; the shape is part of the
language-agnostic contract that keeps the Go and TypeScript
implementations in sync.
DevTools
Wire the DevTools hook once near your provider:
import { useHarnessDevTools } from "@pleach/core/react";
function App() {
useHarnessDevTools();
return <HarnessProvider runtime={runtime}>...</HarnessProvider>;
}Then from the browser console:
__HARNESS_DEVTOOLS__.session; // current SessionState
__HARNESS_DEVTOOLS__.checkpoints(); // list checkpoints
__HARNESS_DEVTOOLS__.rollback("cp_..."); // restore to a checkpoint
__HARNESS_DEVTOOLS__.tools(); // list registered tools
__HARNESS_DEVTOOLS__.syncStatus(); // version vectors
__HARNESS_DEVTOOLS__.forceSync(); // push a sync nowGate the hook call behind process.env.NODE_ENV !== "production"
to keep the DevTools surface out of production bundles. A typed
HarnessDevToolsAPI is exported for typing
window.__HARNESS_DEVTOOLS__ in consuming apps.
Where to go next
Cache & memoization
Runtime-side cache for prepared LLM inputs — the CacheBackend contract, the always-on memory default, and the four fingerprint gaps that keep hits correct across modes.
Sync
Version-vector sync across clients — conflict detection at write time, durable outbox, hybrid logical clock, and the SyncCoordinator API.