Skip to content

Concepts

The mental model behind agentsessions: the nouns, why each exists, and how they fit together. Read this first, then Quickstart to see them in action and Architecture for the implementation.

An agent session is a durable, long-running conversation between a user (or a producer like an issue tracker) and an agent. agentsessions stores that session as an append-only, hash-chained event log and drives it through a single-writer controller. Because the log is the source of truth, a session can be killed and reconstructed byte-for-byte by replaying the log, moved between compute backends, forked to explore alternatives, and audited by anyone. The core knows nothing about which framework wrote the agent or which cloud runs it.

agentsessions is a narrow waist with three pluggable contracts. Producers plug in on top, compute plugs in underneath, and the harness is brought by the user.

Seam Contract Who implements it
Sessions api/session.proto The client. A producer adapter (GitHub, Foundry, a CLI) drives create / exec / suspend / resume / fork / replay.
Harness api/harness.proto, api.Harness The agent author. One Run drives one turn and streams typed events. See Writing a harness.
Runtime api/runtime.go, api.Runtime The compute backend. A pod, Kata, Cloud Hypervisor, or agent-substrate provides the sandbox.

The rest of this document is about the nouns those seams pass around.

A session is the durable unit. It has a stable UID (for example sess-6ff29e8d93d3a4c65f6f38bc) and its entire truth lives in one event log. A session is not tied to a process: the same session can run on one incarnation now, be killed, and continue on a different one later. Its lifecycle has two independent axes, execution and compute (api/state.go), which are reported separately because collapsing them loses which one actually moved. The diagram below reads them together:

stateDiagram-v2
  [*] --> PENDING
  PENDING --> RUNNING: exec
  RUNNING --> SUSPENDED: suspend (cold, snapshot in storage)
  SUSPENDED --> RUNNING: resume
  RUNNING --> FORKING: fork
  FORKING --> RUNNING
  RUNNING --> TERMINATED: end (log retained, replayable)
  RUNNING --> FAILED

SUSPENDED is cold: the snapshot is written to storage and the worker is freed. TERMINATED still keeps the log, so a finished session stays replayable.

There is no warm pause. The state model reserves a warm tier, but no Runtime backend implements a node-local warm checkpoint, so nothing can currently move a session into it and there is no operation that tries. A session goes from live to a cold snapshot and back.

The log holds what happened. It does not hold what the session is — the project that owns it, its display name, the configured harness and model — because none of that is an event. That lives in a metadata row alongside the log, written by CreateSession before any event exists. Caller-supplied labels and annotations are stored there as JSON and returned by CreateSession, GetSession and ListSessions, including after the journal is reopened.

This is why a session that was just created, with no turns and no compute, still shows up in ListSessions. A listing derived from the event table alone would drop exactly the sessions a user just started and is waiting on.

ListSessions reads that row, so a listing is complete after a process restart and does not depend on anything held in memory. It filters on an exact project, never a wildcard, so a caller cannot enumerate a tenant it did not ask for. Results are newest first and paged with a cursor that carries the last (create time, uid) seen, so a session created while a caller walks pages neither skips nor duplicates a row.

The cursor and lifecycle status are derived rather than caller-writable:

  • last_seq is derived from the indexed event head, including events appended by an older writer that does not update the approval projection.
  • compute_state is a projection maintained in the same transaction that appends an event, since an event body is an opaque blob no query can filter on. It records what the log implies about compute, not a live probe of the backend. Execution activity implies LIVE; SUSPEND and FORK land it COLD, and RESUME returns it to LIVE. A session that has been created but never run stays NONE, which is what makes it visible in a listing before it has any compute. LIVE goes stale if the process behind it later died. A backend that can enumerate its own incarnations is the thing that would make this exact; reconciling against the runtime is not implemented. Approval requests/decisions do not themselves move compute: a request repair or pending query cannot prove that a snapshot committed.
  • pending_approval is an output-only tuple projected inside the append transaction. Metadata, correlation and last_seq are read atomically; rendering does not decode the journal. Later appends do not change an earlier captured response, including one with a zero cursor.

Every Session reply — Create, Get, List, Fork, Resume, Suspend, Approve and Exec frames — uses the same captured projection. No events reports PENDING. A valid owned unanswered host request in the latest execution (ordered by first-seen non-lifecycle identity) reports AWAITING and exposes pending_approval. Requests and decisions match execution ID, call ID and the request’s journal sequence. Errors and ordinary lifecycle markers do not resolve it; a decision, END, or newer execution removes the pending reference.

Calls whose TOOL_CALL precedes a child’s latest FORK marker are inherited and excluded from reporting. A later call in an inherited execution belongs to the child and can report its own request. Controller admission, decisions and recovery validate the same per-call ownership rule independently of the reporting projection.

Malformed approval-bearing evidence anywhere in the captured history suppresses actionable references, matching strict inspection. Malformed nongated histories do not. A projection lagging the journal head also suppresses references. If an already-open older writer skips projection updates, later appends do not restore reporting for that session by skipping the missing evidence. A v2→v3 backfill isolates an undecodable session, preserving its cursor/metadata but permanently withholding references while healthy neighbors remain readable. SQL faults abort the migration. Queries are not integrity checks: raw journal reads/Verify and strict commands still detect undecodable evidence.

Other histories retain the compatibility COMPLETED fallback, including ordinary/legacy interrupted turns and decided calls without receipts. That status does not prove completion or admission: an unreceipted owned call still blocks new input. Execution and compute axes are independent; recovery uses the journal rather than trusting an enum.

A fork inherits the parent’s project, harness and model, because those define the workload and a branch of a run is still that run. It does not inherit the parent’s name. A name is a label the caller chose for humans, and copying it makes a listing report N+1 rows that all claim to be the same session. Synthesizing one instead ("<parent> fork 2") would push a presentation convention into the wire contract, and the counter is wrong as soon as the same parent is forked by two separate calls. So a child is unnamed unless the caller names it with ForkRequest.child_names, which takes either no entries or exactly count of them. Lineage is not lost by this: parent_uid and fork_seq are stored on the child, so a caller that wants to show “branched from X” has the structured data to build it from and does not need the server to flatten it into a string.

Map metadata follows a separate rule: each child gets exactly ForkRequest.labels, never a merge with or copy of the parent’s labels. Leaving request labels unset or empty gives children an empty label map. The parent’s annotations and origin are inherited unchanged. Both maps are persisted and returned in the fork response and subsequent GetSession and ListSessions calls.

ForkRequest.identity is unsupported because per-child principals are not enforced. Any supplied identity, even an empty message, is rejected with INVALID_ARGUMENT before placement, provisioning, or journal mutation. Leave it unset to fork; children do not inherit the parent’s identity. This does not change Session.identity, which remains recorded provenance rather than authorization (see security posture).

The session log is a sequence of Events. An event is not an opaque blob: it is typed, so provenance, cost, audit, and tool approval are first-class rather than parsed out of text after the fact. The EventKinds (api/types.go):

Kind Meaning
EXECUTION_START Host-owned execution config, cursor, input count, resolved harness name and advertised version, recorded before inputs for every new execution.
INPUT A user or producer input message.
MODEL_CALL A call to a model was made (records the model, params, and an input hash for the replay check).
OUTPUT Assistant output (a delta or a full message).
TOOL_CALL / TOOL_RESULT A tool invocation and its outcome. Aligned with an MCP tool call.
APPROVAL_REQUEST / APPROVAL_RESULT A host-owned request and explicit caller decision around a stateless tool call.
USAGE Per-call token and cost accounting.
LIFECYCLE A compute or session transition (SUSPEND, RESUME, FORK, BASELINE, CANCEL).
END The terminal state of one execution (COMPLETED / FAILED / CANCELED).
ERROR An error, mirroring a gRPC status.

The same Event type is shared by the session log and the harness stream, so what a harness emits is exactly what gets journaled; the host additionally owns invocation and lifecycle records. Content is carried as A2A-style Messages (a role plus a list of Parts: text, file, structured data, an opaque reasoning block, a model’s tool call, or a tool’s result).

The controller commits an EXECUTION_START before inputs or harness execution for every new execution, including default-config, zero-cursor turns. It preserves opaque config bytes verbatim, including binary data and whitespace, and records the harness cursor and expected INPUT count at the same boundary. A direct Controller.Exec with no inputs records an explicit zero count. The caller’s expected_last_seq guards this first committed record; if that append fails, neither the harness nor the model runs.

The marker also records the executing registry name, which may differ from the harness’s Descriptor.ID, and its advertised Descriptor.Version. Standalone controller/Placer calls use the descriptor ID as the name unless an explicit host harness option supplies an alias. The reference harnesses advertise replay version "1"; this is an author-owned implementation token, not the host binary’s release/build version.

Versioning is opt-in. Exec accepts an unversioned harness and records an empty version. Controller Replay checks all completed invocations against its single supplied harness before the first Run; interrupted Resume checks its selected turn before Run. A nonempty recorded version must match exactly, including rejection of an empty served version. No trimming or semantic-version ordering is applied. An empty recorded version retains existing behavior without a version check. Mismatches return ErrHarnessMismatch or ErrHarnessVersionMismatch without harness Run, model, or tool calls or new execution records; placement can already have allocated/restored compute and advanced its fence. Controller Replay is not a multi-harness dispatcher: callers supply a matching harness/name or it fails closed. Sessions.Replay still streams stored records without running or checking a harness.

Sessions.Resume routes an interrupted turn to its recorded registry entry, including that entry’s backend, model and tool executor. An unserved recorded name or mismatched version is FAILED_PRECONDITION, never fallback to another harness. Restore the recorded entry/version before retrying. A new Exec can supersede an ordinary interrupted turn and records its own invocation identity; an owned approval call blocks new inputs until its receipt commits. An override is still per-turn: session metadata is unchanged and a later Exec with no override uses the stored default. A session whose served stored harness requires memory snapshots refuses a stateless override with FAILED_PRECONDITION before any fence or journal write: parking only the override would leave the stored harness’s live memory uncheckpointed. Resume and no-input Exec recovery apply the same refusal to older interrupted overrides before compute changes or journal repair. An ordinary interrupted override can be superseded with a new Exec on the stored harness. A stored runtime without memory-snapshot support is not contacted for this check, so a down remote stored harness does not block a stateless override. Completed Resume, ordinary Suspend and Fork retain their existing session-default routing. An owned unreceipted approval’s Suspend uses its recorded invocation route, including retry after a failed automatic snapshot or append. An inherited approval instead suspends through the child’s stored default to checkpoint its own compute; Resume still refuses unresolved inherited tool intents.

Older start markers without a name fall back to the session’s stored harness; those without a version retain unversioned behavior. These rules apply independently if only one field is missing, and do not relax input-count validation. Remote checks use Describe, not negotiation on the Connect stream; an endpoint/replica changing between those calls remains the documented operator-controlled address limitation.

The marker and inputs are separate appends. If an input append fails, or the process dies before all inputs commit, the marker’s count prevents recovery from running a different invocation. Before any harness runs, controller Replay validates every completed invocation’s count, and Resume validates the last interrupted invocation. Missing/negative counts, mismatched committed input counts, and payloadless INPUT records return ErrInvalidExecutionLog without running the harness/model/tool or appending execution records. Explicit zero permits genuinely inputless invocations; all inputs committed before a crash permits resume even if the harness has not run yet. Completed Replay skips an incomplete trailing invocation and remains read-only. A historical fork cut before all expected inputs likewise cannot resume, but completed prefixes and complete invocation boundaries still work.

For ID-bearing executions, controller replay and interrupted-turn resume reconstruct each Start from its own recorded values, including in inherited fork prefixes. History is the exact prior journal prefix: it excludes the current execution’s start marker and inputs, but retains markers from prior executions. Replay is read-only; recovery serves already-recorded effects rather than repeating them. The harness cursor is opaque and distinct from the append CAS cursor.

Legacy logs: an execution without an EXECUTION_START reconstructs with empty config and a zero cursor, regardless of a new controller’s WithStart option. Older builds discarded these values; non-empty config or non-zero cursors from those executions cannot be recovered retroactively. No SQLite schema migration or rewriting of existing records is needed. Experimental start markers without input_count fail closed when their invocation is selected for resume or is completed for replay: absence cannot distinguish a genuine inputless turn from lost inputs. Markerless logs retain their prior behavior and lack completeness information; only older writers omit the marker.

Released v0.1.2 journals also lack execution IDs. Compatibility recognizes only their ID-less execution event kinds (INPUT, MODEL_CALL, OUTPUT, TOOL_CALL, TOOL_RESULT, USAGE, END, ERROR), with session-scoped lifecycle records allowed. Output deltas were ephemeral, not journal records. Controller Replay preserves v0.1.2’s one whole-log harness invocation: all recorded inputs and the entire legacy journal as History, even for an unfinished log; usage is excluded from the served effect stream. Multi-turn legacy journals retain the old replay limitation (for example, echo can fail its model-input hash check), because inferring turn boundaries would change historical behavior. Controller Resume instead selects only the last INPUT, supplies its prior history, and continues an interrupted turn with ID-less records. Legacy harness invocations receive a legacy-<sha256> compatibility ID in Start and transport frames only. The digest includes the host-bound session UID, the original input record’s sequence position, and its canonical event content (whole-log Replay uses the first input; Resume uses the last). If Replay has no input, it uses the first legacy record. Retrying the same turn preserves the ID while the turn remains pending; identical legacy bytes in different sessions have distinct IDs. A recorded END or ERROR finishes a legacy turn, so Resume then becomes a no-op. This namespace cannot collide with the controller’s hexadecimal modern IDs. The token is never copied into journal event identity, including when echoed by a remote harness. Direct controller callers must bind a nonempty WithSessionUID; a selected legacy invocation without it returns ErrMissingSessionUID before running the harness or appending execution records. It still refuses unresolved inherited tool intents; a recorded model intent without a completion still fails recovery rather than repeating the model call.

A new Exec may follow a completed, errored or unfinished legacy tail, just as v0.1.2 allowed another Exec after a failure. Its EXECUTION_START is an explicit legacy boundary even without a preceding END. Mixed Replay reconstructs the complete legacy prefix with its whole-log semantics, skips the abandoned failed/incomplete tail, and replays each completed modern execution normally; modern History still includes every original legacy event because Exec saw that same history. Resume selects the last modern turn rather than reviving the superseded tail. A completed legacy prefix includes a v0.1.2 crash recovered by the upgraded controller before a new Exec. ID-bearing modern events without a start marker still require a finished legacy boundary; remaining invalid-log refusals return FAILED_PRECONDITION through Sessions Exec/Resume. Once an EXECUTION_START or ID-bearing execution event appears, subsequent ID-less execution events are invalid, not legacy. ID-less start markers and other non-legacy kinds likewise fail closed. Existing records and hashes are never rewritten.

Rollback compatibility: binaries older than this release fail chain verification for sessions containing EXECUTION_START, reporting a content_hash mismatch. Rollback is not supported for any session that executed a turn on this release, including existing markerless sessions that execute another turn after upgrading. Replay streams and harness History now carry one extra EXECUTION_START event per turn (in History, only prior turns).

The event log: single writer, append-only, tamper-evident

Section titled “The event log: single writer, append-only, tamper-evident”

The log (eventlog/ reference in memory, sqlitelog/ on disk) is where durability, concurrency, and provenance all live. It has three properties stacked on an append-only sequence:

  • CAS on append. Every append names the sequence number it expects to extend (expectedLastSeq). Two writers racing the same session: one wins, the other is rejected. This is what makes “single writer” enforceable rather than aspirational.
  • A fencing token per incarnation. See Incarnation and fence.
  • A hash chain. Each record carries prev_hash and a content_hash over its event. Change any past event and its hash no longer matches, and every later prev_hash link is now inconsistent. Tampering is detectable by recomputation, with no external index.

Canonical hash and language-neutral provenance

Section titled “Canonical hash and language-neutral provenance”

The content_hash is not json.Marshal of a Go struct. It is RFC 8785 JCS (JSON Canonicalization Scheme) over the proto3-JSON form of the event (canon/). Because the canonical form is defined on the wire proto and not on any language’s serializer, the chain is reproducible outside Go. A roughly 30-line Python verifier (hack/verify_chain.py) recomputes the exact Go hash for a golden vector and audits a full real journal. Provenance you can only check with the original implementation is not provenance, so this property is load-bearing.

An incarnation is one live compute instance of a session: a sandbox on some worker, with an address where its harness listens. A session can outlive many incarnations (kill one, start another).

A fence is a monotonic token bound to an incarnation. The log is the single fence authority: a fresh incarnation mints a higher fence, and the log rejects any append carrying a fence lower than the current one. So if a supposedly-dead incarnation wakes up and tries to write (a zombie), its stale fence is refused. Fencing plus CAS is how a session with automatic failover still has exactly one writer at any instant.

How a harness survives suspend, resume, and fork is declared, not guessed. It is a property of the harness (api.Capabilities.Resumability):

  • STATELESS_REPLAY (the default). The harness keeps no durable in-process state beyond the log. To resume or fork, the host replays history into it (Start.History). This runs on any runtime, including a plain pod with no snapshot support. echoagent is the reference example.
  • REQUIRES_MEMORY_SNAPSHOT. The harness holds in-process state that the log cannot reconstruct: a live REPL, a browser, a long-running computation. Resuming means restoring RAM, so the host only schedules it on a runtime that can snapshot memory. Forking means the same thing: the parent is checkpointed and each child is cloned from that snapshot, so a fork costs a suspend on the parent and is only available at HEAD. counteragent is the reference example.

The distinction is honest by design. A stateless-replay harness costs nothing special and goes anywhere. A memory-snapshot harness gets a capability a plain pod structurally cannot provide, and pays for it by being placeable only on memory-capable compute.

A harness declares what it needs (api.Capabilities). A runtime declares what it can do (api.RuntimeCapabilities, for example MemorySnapshot, CoWFork, Attest, GPUState). Before any compute is provisioned, CanPlace(harness, runtime) (controller/) refuses a placement the runtime cannot honor, for example a REQUIRES_MEMORY_SNAPSHOT harness on a filesystem-only backend. The result is honest degradation up front (a FailedPrecondition), instead of resuming a session wrong and discovering it later.

Replay is only useful if it is exact. The controller (controller/) mediates every nondeterministic effect through the harness event sink so the effect is recorded before it is observed, and the replay-conformance suite (conformance/) enforces a numbered contract. The load-bearing rules:

  • I1: replay never invokes the model. On replay the recorded model result is served from the journal. The model is called zero times.
  • I2: reasoning continuity. Opaque provider reasoning parts are recorded verbatim and replayed, so reasoning survives resume and fork on the stateless path.
  • I3: at-most-once effect re-drive. A crash between an effect’s intent and its result is re-driven at most once. Host-executed tools carry an idempotency key so the re-drive dedups.
  • I4: memory-restore is not replay. After a memory restore the harness already holds its state in RAM, so Start.History is empty and must not be replayed into it. A REQUIRES_MEMORY_SNAPSHOT harness never reconstructs state from History (see Writing a harness).
  • I5: byte-identical replay. Replay reproduces the recorded events byte-for-byte (checked against the canonical hash), not merely output that looks equivalent.

Putting it together, a single exec turn on a stateless-replay harness:

sequenceDiagram
  participant C as Client (Sessions)
  participant Ctrl as Controller
  participant Log as Event log
  participant H as Harness
  participant M as Model

  C->>Ctrl: Exec(input, expectedLastSeq)
  Ctrl->>Log: append EXECUTION_START (CAS + fence + hash)
  Ctrl->>Log: append INPUT (CAS + fence + hash)
  Ctrl->>H: Run(Start{Config, ResumeFromSeq, Inputs, History})
  H->>Ctrl: sink.Model(request)
  Ctrl->>M: invoke model (live only)
  M-->>Ctrl: completion
  Ctrl->>Log: append MODEL_CALL + OUTPUT
  Ctrl-->>H: recorded completion
  H-->>Ctrl: Run returns
  Ctrl->>Log: append END
  Ctrl-->>C: stream the new records

On replay the same sequence runs with one difference: at sink.Model the controller serves the recorded completion from the log and never touches the model (I1). That is why a killed session comes back byte-identically without spending a single model call.

Term One line
Session A durable, event-sourced agent run identified by a UID.
Event The typed unit of the log and the harness stream.
Event log Single-writer, append-only, CAS + fence + hash chain.
Incarnation One live compute instance (sandbox) of a session.
Fence Monotonic token that makes single-writer enforceable across failover.
Canonical hash RFC 8785 JCS over proto3-JSON, the language-neutral content_hash.
Resumability STATELESS_REPLAY or REQUIRES_MEMORY_SNAPSHOT.
CanPlace The gate matching a harness’s needs to a runtime’s capabilities.
Harness The Bring-Your-Own-Harness agent behind the api.Harness SPI.
Runtime The pluggable compute backend behind the api.Runtime SPI.
Producer An adapter that maps an external world (issues, chat) onto sessions.