Session persistence
The agent loop has no built-in storage backend. Persistence is intentionally a host concern, but agentkit ships the three primitives you need to compose any backend you like. This chapter documents the contract and walks through the openrouter-session-persistence example that puts the pieces together.
The three primitives
| Primitive | Purpose |
|---|---|
AgentBuilder::transcript(items) | Restore prior transcript before the loop starts. |
TranscriptObserver::on_item_appended(&item) | Mirror every newly-appended item to durable storage as the loop runs. |
LoopDriver::snapshot() -> LoopSnapshot | Read-only point-in-time view of transcript and pending_input for ad-hoc dumps, audit, or full-state checkpoints. |
That is the whole protocol. Any storage backend — in-memory map, sqlite, Postgres, S3, Redis — implements the same shape:
- On startup: load the prior
Vec<Item>for the session id (or empty for a fresh session) and pass it toAgentBuilder::transcript. - During the run: register a
TranscriptObserverthat appends eachItemto durable storage. - On shutdown (graceful or not): nothing required — the observer has already persisted every appended item.
Two important guarantees
Append-only ordering. on_item_appended is called synchronously by the loop, in the exact order items land in the transcript. The observer is the single mutation point — every push to the transcript funnels through it. This means a strictly monotonic seq column on a sqlite items table reproduces the transcript byte-for-byte on reload.
Mutators are out-of-band. Mutator-driven transcript rewrites (compaction, redaction, repair) do not fire on_item_appended. They are signalled via AgentEvent::MutationFinished { dirty: true, .. }, observable through a LoopObserver. A mutation-aware persistor subscribes to both channels and replaces the stored transcript when it sees a dirty mutation finish. An agent without mutators (most coding agents that rely on the provider’s prompt cache plus a long context window) can ignore this.
A complete sqlite implementation
The shape below is from the example crate. Two tables, three operations:
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
CREATE TABLE items (
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
json TEXT NOT NULL,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
The observer is a struct holding an Arc<SqliteSessionStore> and a session id:
struct SqliteTranscriptObserver {
store: Arc<SqliteSessionStore>,
session_id: String,
}
impl TranscriptObserver for SqliteTranscriptObserver {
fn on_item_appended(&self, item: &Item) {
if let Err(error) = self.store.append(&self.session_id, item) {
eprintln!("[persistence] failed to append item: {error}");
}
}
}
Restore on startup is a single SELECT:
let prior = store.load(&session_id)?; // Vec<Item> in transcript order
let agent = Agent::builder()
.model(adapter)
.transcript(prior) // <- starting state
.transcript_observer(SqliteTranscriptObserver {
store: Arc::clone(&store),
session_id: session_id.clone(),
})
.build()?;
That is the entire round-trip. Run the example twice with the same --session flag and the second run resumes mid-conversation — the first next() call returns AwaitingInput because the transcript is loaded but no input is queued, and the host supplies the next user message in response.
Choosing a backend
Sqlite is the easiest to drop into a single-process CLI. For multi-process or distributed agents, swap the storage backend; the observer interface is unchanged:
- Postgres / MySQL — same two-table schema, use a connection pool.
on_item_appendedruns on the loop’s task; if your write latency is significant, queue items into a buffered channel and persist on a dedicated task to avoid stalling the loop. - Redis —
RPUSH session:<id> <item-json>andLRANGE session:<id> 0 -1for restore. Atomic, fast, no schema migrations. - S3 / GCS — write a JSONL blob per session, append-on-flush. Higher latency, but cheap and infinitely scalable for archival workloads. Use
LoopDriver::snapshot()to take periodic full-state checkpoints rather than streaming each item. - In-memory
HashMap<SessionId, Vec<Item>>— for tests and ephemeral demos. The observer is a one-liner.
Why no SessionStore trait
A SessionStore trait would force every backend to implement the same four or five methods. That is what Anthropic’s claude-agent-sdk-python does — five methods plus a thirteen-test conformance harness — and it works because their SDK consumes session storage.
agentkit doesn’t consume the backend. The loop just calls on_item_appended. Restoration is the host’s job. Picking your own shape (one table, three tables, a stream, a directory of JSON files) is the right default for a library that doesn’t know how you want to query, archive, or share session state.
The integration test crate exercises the round-trip pattern internally; see crates/agentkit-integration-tests for the canonical worked tests.
Mutation-aware persistence
If your agent registers any LoopMutators (compaction, redaction, repair), the persistence flow extends:
TranscriptObserver::on_item_appendedcontinues to mirror new items as they arrive.- A
LoopObserversubscribes toAgentEvent::MutationFinished { dirty: true, .. }and uses it as a signal to replace the stored transcript. - After a dirty mutation finish, call
LoopDriver::snapshot()from the host’s main task and replace the persisted transcript withsnapshot.transcript. Subsequenton_item_appendedcalls resume appending from the new tail.
The two channels exist precisely so persistence can stay simple in the no-mutator case (one observer) without sacrificing correctness when mutators are wired in (one observer plus one event listener).