pleach
Architecture

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.

Subpath@pleach/core/checkpointingSourcesrc/checkpointer/

Picking a checkpointer

CheckpointerEnvironmentPairs naturally with
MemorySaverAnyMemoryAdapter (tests, dev)
IndexedDBSaverBrowserIndexedDBAdapter (offline-first)
SupabaseSaverServerSupabaseAdapter (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.levelWhen the stamp firesSurvives
MemorySaver"memory"Synchronously, immediately on put()Process restart? No.
IndexedDBSaver"disk"After the IDB transaction's complete event firesBrowser refresh? Yes. Tab close + reopen? Yes. Profile wipe? No.
SupabaseSaver"replicated"After the server-side insert returns 200Process 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.

OperationSame sessionId?Reach for it when
rollback(sessionId, cp)Yes — session is mutated in placeA 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 mintedYou 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 state

See 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 now

Gate 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

On this page