Checkpoints
A checkpoint is a saved copy of a run’s transcript. With checkpoints on, an interrupted run continues from where it stopped without re-running or re-billing the turns it already did, and you can fork a session from an earlier point. Use them for long runs where starting over is expensive.
With Tuning(checkpoint=True), the agent loop persists a transcript
snapshot (messages, turn count, cumulative usage) after every
architecture pass. A crashed run resumes from the latest snapshot with
agent.resume(): prior turns are restored verbatim, never re-executed,
never re-billed.
from loomflow import Agent, Tuning
from loomflow.runtime import SqliteRuntime
agent = Agent(
"...",
model="claude-opus-4-7",
runtime=SqliteRuntime("./journal.db"),
tuning=Tuning(checkpoint=True),
)
# First run, killed by OOM / deploy / Ctrl-C partway through:
await agent.run("long research task", session_id="research-2026-07-04")
# After restart: restore the latest checkpoint and keep going.
# The restored turns are accounted in the result's usage but the
# model is never re-invoked for them.
result = await agent.resume(
"pick it up where you left off",
session_id="research-2026-07-04",
)Checkpointing is off by default (checkpoint=False). A failed
checkpoint write is logged as a warning and never kills the run: it’s
a durability optimisation, not a correctness dependency.
What a checkpoint contains
class Checkpoint(BaseModel):
session_id: str
checkpoint_id: str # auto: new_id("ckpt"), time-sortable
turn: int
messages: list[Message] # the full transcript
cumulative_usage: Usage # tokens + cost up to this point
cursor: str | None = None # architecture-local re-entry marker
created_at: datetimeCheckpoints are serialised as JSON via model_dump_json, never
pickle, so payloads are inspectable with jq and safe to load even
from a store an attacker could write to. Journal step entries are
tagged JSON too, unless you opt into serializer="pickle"; see
Journal format.
One checkpoint is written per architecture pass: the first pass, and each stop-hook (Ralph loop) iteration. Snapshots are taken after auto-compaction runs, so a compacted transcript is what gets stored.
agent.resume(...)
async def resume(
self,
prompt: str | None = None,
*,
session_id: str,
from_checkpoint: str = "latest",
user_id: str | None = None,
**kwargs, # any other run() keyword: metadata, context, extra_tools, emit, output_schema, effort, ...
) -> RunResult: ...When the runtime supports checkpoints and one exists for
session_id:
session.messages,turnsandcumulative_usageare restored verbatim from the snapshot.- Memory-rehydration seeding is skipped: the restored transcript is the state, so no duplicate system/memory blocks are injected.
promptis appended as a fresh USER turn.prompt=Noneinjects an internal continuation nudge ("Continue the interrupted task.") so the model picks up without new user input.- The loop continues. The returned
RunResultrolls up restored and new usage. Prior turns are accounted, not re-billed: no model call is repeated for them.
Fallback. When the runtime has no checkpoint support, or no
checkpoint exists for the session (for example, the run didn’t have
Tuning(checkpoint=True)), resume() falls back to
run(prompt, session_id=session_id) against the session’s last run.
Memory rehydration rebuilds the conversation and, on a durable
runtime, that run’s journaled steps replay instead of re-executing,
even if it finished. See
Replay and resume.
Checkpoints are stored per (session, user). Pass the run’s
user_id to resume() and to agent.list_checkpoints(session_id, user_id=...); a different user (or none) won’t see them.
Fork from an older checkpoint
from_checkpoint selects the snapshot. "latest" (the default)
continues the session in place. An explicit checkpoint id that is
not the latest forks: a fresh session_id is derived
(new_id("sess")), the checkpoint’s messages are copied in, and the
run proceeds under the fork id. The original session’s history and
checkpoints are untouched.
metas = await agent.list_checkpoints("research-2026-07-04")
older = metas[-1] # newest-first ⇒ last = earliest
result = await agent.resume(
"try a different approach from here",
session_id="research-2026-07-04",
from_checkpoint=older.checkpoint_id,
)
result.session_id # "sess...": a NEW id, the forkPassing the id of the latest checkpoint continues in place, the same
as "latest", with no fork.
agent.list_checkpoints(...)
metas = await agent.list_checkpoints(session_id, limit=50, user_id="alice")
for m in metas: # newest first
print(m.checkpoint_id, m.turn, m.created_at)Returns list[CheckpointMeta] (id, turn and timestamp only, no
message payload), so listing is cheap. Returns [] when the runtime
has no checkpoint support. Pass the user_id the run used (omit it for
runs without one).
Retention
Every checkpoint-capable runtime takes max_checkpoints_per_session
(default 20): on each write, the session’s oldest checkpoints beyond
the limit are dropped. Values < 1 disable pruning (unbounded).
SqliteRuntime("./journal.db", max_checkpoints_per_session=5)
runtime = await PostgresRuntime.connect(dsn, max_checkpoints_per_session=50)
InProcRuntime(max_checkpoints_per_session=20)On the journaled runtimes, runtime.store.prune(before=..., session_id=...) deletes journal entries and checkpoints matching
the filters; use it to purge completed sessions. Nothing else prunes
automatically.
InProcRuntime checkpoints are process-local. The default runtime
implements the full checkpoint API, so Tuning(checkpoint=True) +
resume() works within one process (useful for tests and
long-running services that survive logical failures). But the
snapshots live in memory and are lost on exit. For crash durability,
use SqliteRuntime or PostgresRuntime.
Journal vs checkpoint
Both live in the same store, and they solve different problems:
| Journal | Checkpoint | |
|---|---|---|
| What it stores | Each step’s result (model call, tool dispatch), keyed by run + name + input fingerprint | The whole transcript (messages + usage + cursor) at a pass boundary |
| What it’s for | Memoisation: a resumed run skips already-completed steps | Restoration: a resumed run starts from the snapshot, no replay walk |
| Granularity | Per step, within a pass | Per architecture pass |
| Wire format | tagged JSON by default; pickle only with serializer="pickle" (trust the store) | JSON (model_dump_json, inspectable) |
| Enabled by | Using a JournaledRuntime (Sqlite / Postgres) | Tuning(checkpoint=True) on any checkpoint-capable runtime |
They compose: on a durable runtime with checkpointing on, resume()
restores the latest snapshot directly. If the run crashed, the journal
still replays any step after the restore point that re-fires with
identical inputs.
Checkpoints must have been written to be restored. resume() on a
session whose runs never had Tuning(checkpoint=True) silently takes
the run() fallback. That’s the designed behaviour, not an error.
Check agent.list_checkpoints(session_id) if you need to know which
path you’ll get.
For crash-recovery semantics of the journal itself (step keys, the determinism contract, at-least-once delivery), see Replay and resume. For pausing a live run and resuming it on human input, see Signals.