What the runtime stores, what survives a restart, how a host action that was waiting on the host is recovered, and what is deliberately not promised. Read concepts first for sessions, turns and host tools.
| State | With Postgres | Without Postgres |
|---|---|---|
| Sessions, the message tree (user, assistant and host messages), steering | rows in sessions, messages, steering; written through on every change |
process memory, until cache eviction or process shutdown |
| The pending host action | sessions.pending_action |
process memory |
| Prompt-artifact versions, runtime settings | rows | process memory |
| Queued inbox messages | rows | process memory; listing and marking surfaced through HTTP still need Postgres |
| Encrypted provider keys | rows | unavailable |
| Traces (debug) | rows | not stored |
| Background tasks, persistent agents and their messages | rows in tasks, agents, agent_messages |
process memory, lost on restart |
| Events, actions and their confirmations, host state | rows in events, actions, action_confirmations, host_state |
process memory, lost on restart |
Messages, tasks, agent messages, actions, events and voice calls record
updated_at, which every write sets to the writing transaction's time
(migration 0039; existing rows took the latest time they already
recorded). Change cursors read them in (updated_at, id) order, through
an index on each table (see change cursors).
Without Postgres the in-memory stores keep it from the process clock.
The session store is a write-through cache: every change is written to the
row before the in-memory context is updated. The row may be ahead while a
write completes, but memory is never durably ahead of the row. A session that is not in
the cache is loaded from its rows on first use. Postgres-backed sessions expire after
ASSISTANT__SESSION_TTL_HOURS (0 keeps them until they are deleted);
expiry deletes the session with its messages, steering and pending action.
The table's right-hand column is a fallback, chosen once at startup. A
database that answers at startup but cannot load the runtime settings fails
startup, since the next settings change would otherwise overwrite the
stored ones. A deployment that must not lose state sets
DATABASE__REQUIRED=true: startup then fails with
DatabaseUnavailableError when Postgres is unreachable, and nothing falls
back to process memory. A host that chooses a database should set it, so
that a restart during an outage fails instead of starting in memory mode.
It affects startup only: a database lost later leaves /health at 200
(status: degraded) and /health/ready at 503, whether it is set or not
(see health). With DATABASE__MIGRATE_ON_START as well, the runtime creates
its database when the server is reachable but the database does not exist,
then migrates it; a host needs only a running PostgreSQL server.
A database lost after startup is not a fallback, with or without
DATABASE__REQUIRED. Each call tries it again, so the runtime recovers as
soon as Postgres is back; until then a route that uses it answers 503
with {"error": "Database not reachable", "type": "DatabaseError"}. A
runtime-settings change or an inbox message that cannot be saved is
refused the same way rather than kept in memory, and a turn whose
artifacts cannot be read fails instead of building the prompt from the
defaults. Turn traces, the inbox drain, voice checkpoints and OAuth token
deletion are best-effort and log a warning; messages a failed drain could
not read stay in the inbox for the next turn. A refreshed OAuth login that
cannot be saved stays in memory for the process, and its status reports
persisted: false.
DatabaseUnavailableError and MigrationError carry a machine-readable
cause, so a host can word them for its own users: unreachable (no
server answers), auth_refused (the server refused the role),
database_missing (the database does not exist and was not, or could not
be, created) or migration_failed.
The substrate is deliberately the application's own tables. The upstream options were compared and deferred; see the decision below.
Every assistant message stores the record of the system prompt it was
produced with in messages.prompt; the stable part of a prompt (the
artifacts, included profiles, documents) is stored once
in prompt_snapshots under its SHA-256, since it repeats across turns.
Without Postgres the record stays on the in-memory message and the process
keeps the most recent snapshots.
Every message also stores what the model was given, as Pydantic AI messages
(messages.model_messages, serialised with ModelMessagesTypeAdapter). A
user message stores its request: the host context block, its text and its
reference attachments. An assistant message stores everything its turn
added after that request: responses with their thinking, tool calls and
results, delivered steering and ingress messages, and a continuation's
result with its host context. Later turns replay these messages unchanged,
so each request starts with what the model saw before. Three things are not
stored:
- instructions, since every run sends its own;
- binary tool output, stored as
[Binary content omitted]; - a
look_at_screenimage, stored as[Inspected current screen].
Reference attachments are stored with their message, data included, so
bound what you send. segments remain the display form. Messages stored
before model_messages existed are replayed from their text and segments.
Background tasks are rows in tasks (what was asked, its session, status,
result and times). When the runtime starts, a task left queued or
running is marked interrupted; its work is not replayed. That recovery
covers every task in the database, so run tasks in one process per
database; a task's end is recorded once, and a later terminal update
changes nothing. Without Postgres, tasks live in process memory and are
lost on restart. Persistent agents are rows in agents (their current
session, status and config) and their messages rows in agent_messages; a restart
keeps the agents and marks every unfinished message in the database
interrupted in the same way, so the one-process rule covers agents too.
Host-written records have their own tables: events (unique per source and
event id, numbered in arrival order), actions (status history, text
revisions, per-recipient results) and action_confirmations (one row per
recipient, in insertion order, deleted with their action). Host state is
host_state (namespace, key, JSON value, version); a deleted key stays as a
tombstone so its version is never reused. Without Postgres they
live in process memory and are lost on restart.
Artifact versions are keyed by profile, subject (empty for profile-scoped
artifacts) and name. A session created for a subject stores its profile
and subject on the row, decided by the session's first saved message
(an empty session enforces none), so a restart keeps the binding. keep_versions bounds how many superseded
versions an artifact keeps; the rest are deleted as new ones are written.
With DATABASE__REQUIRED, each profile-scoped artifact of a registered profile that has
no stored version gets its default stored as version 1 at startup (actor
seed), so the text a prompt uses is always a versioned record. A changed
default is stored and activated as a new seed only while every stored version
is a seed and the newest is active; any host or assistant version (or a
rollback) stops that for good, so a shipped default never overwrites
anyone's choice.
Artifact version checks, writes, activation and rejection run in one
transaction, serialized per profile/artifact in Postgres; a unique index
allows at most one active version of an artifact. An edit with a stale expected_version returns
409. A failed activation rolls back the proposed version, and a prompt-cache read
started before an edit cannot hide that edit from subsequent prompts.
A host tool call ends the turn. The runtime then:
- writes the assistant message with the call(s) and no result;
- writes
pending_action(tool_call_id,tool_name,assistant_message_id,batch: every host call of that response in order) on the session row; - emits
final_response.pending_tool_callfor the first call and waits.
When a continuation arrives, the accepted result is written on the same
assistant message first. For a batch with calls still waiting, pending_action
then moves to the next call and the turn ends with that call as
pending_tool_call, without a model run; only the final continuation clears
pending_action and resumes the model. A crash between steps 1 and 2 leaves a call without a result and no
pending action: it is resolved as unknown on the next load. A crash after
the result is written but before the row is cleared leaves a pending action
whose call already has a result: the load drops it, because the result is
already recorded.
On load, the stored pending action is restored only when its assistant
message exists and the call still has no result. Every other call without a
result is given a synthetic unknown result so the history stays consistent.
GET /api/sessions/{id} returns pending_action with the call id, tool
name, arguments and assistant message id, so a host that reconnects or
restarts can perform the waiting action and continue without reading the
transcript.
A host tool entry in the stored segments (GET /api/sessions/{id}/messages)
carries the native outcome the model sees and, when the action did not
complete, a status saying how it was resolved.
status |
outcome |
Produced when | External effect |
|---|---|---|---|
completed (no status key) |
success |
the host sent its result | happened, per the host |
failed |
failed |
the continuation carried tool_outcome: "failed" |
did not happen, per the host |
cancelled |
interrupted |
the turn was cancelled or stopped by a limit before the call returned | unknown |
superseded |
interrupted |
a new user message arrived before the continuation, or core closed an older dangling call when a newer result arrived | never recorded |
unknown |
interrupted |
the session was loaded without a matching pending action, or POST /api/sessions/{id}/repair was called |
unknown |
The runtime never asserts that a cancelled, superseded or unknown
action had no effect. If the host knows, it says so with the continuation
(tool_outcome) or by telling the user.
- Reconnect. A Socket.IO disconnect leaves the turn running and its
events are not replayed. Read the session (
messages, andpending_actionon the session) to recover the state, then continue. - Restart. With Postgres, a pending host action survives: the continuation is accepted by the new process. Without Postgres the session is gone with the process, and the continuation is rejected as a session error because the session does not exist.
- Duplicate continuations. Once a call's result is recorded (whatever
its status), any continuation for that
tool_call_idis rejected withContinuation rejected: the result for tool call '...' was already recorded (status: ...). A recorded result is never applied twice, in the same process or after a restart. - Retries. The runtime never re-issues a host action: the model asked
once, the host performs it once. A host that retries on its own side
must make the action idempotent or reconcile an unknown first attempt
itself;
tool_outcome: "failed"is for actions the host knows did not occur. If the continuation is accepted and the model step after it fails, the result stays recorded and the client continues with a new message rather than resending the continuation. - Repair.
POST /api/sessions/{id}/repairresolves the pending action and every call without a result asunknown, in memory and on the rows, so a session stuck on a host that will never answer can continue.
Supported: one runtime process per set of sessions. Within a process, many sessions run concurrently and each session has at most one live turn; a new message cancels the live turn and waits for its cleanup, a continuation waits without cancelling.
The in-memory context is authoritative for a session while its process
runs, and it is refreshed from the rows only on a cache miss. Within a
process, turns on one session are serialised: a continuation is planned only
after the previous turn has finished and persisted, so two continuations for
the same call arriving together are accepted once and rejected once. Two
processes serving the same session at the same time would each cache their
own copy; the runtime does not coordinate them, and each could accept the
same continuation. To run several processes against one database, route
each session to one process (sticky sessions by session_id). A process that restarts, or a session that moves to another
process after the first stopped, is the supported recovery path and is what
tests/compatibility/test_recovery.py exercises: a cold cache over the same
rows accepts the pending continuation, rejects duplicates and resolves
unanswered calls.
- Migration
0022adds the nullablepending_actioncolumn; nothing else changes. Rows created before it have no pending action and their unanswered calls becomeunknownon the next load. - Branching is unaffected: the pending action names its assistant message, and a branch from an earlier message is a new user message, which supersedes the pending action like any other.
- Retention is the session TTL; a pending action does not extend it.
- Migration
0034allows thehostrole. Its downgrade is refused while host messages exist, since deleting them would delete every message that follows them.
The documented local persistent setup is Postgres in Docker:
make db-up && make db-upgrade from a checkout, or assistant-runtime migrate
from an installed package (the migrations ship in the wheel); see
getting started.
A host that builds its settings in code upgrades the schema from Python with the same migrations, and can show the result:
from assistant_runtime.services.database.migrations import migrate, schema_status
await migrate(settings.database) # raises MigrationError
status = await schema_status(settings.database) # state, current, head, errorDATABASE__MIGRATE_ON_START=true does the same before the services start.
schema_status() never raises; its state is unreachable,
auth_refused, database_missing (created at start in required mode
with migrate_on_start), behind (upgraded at start with
migrate_on_start) or ready, and error keeps the driver's message.
assistant-runtime doctor reports reachability and the schema revision
against the packaged head. A local server needs no stored password: leave
DATABASE__PASSWORD empty for trust authentication, or set
DATABASE__HOST to the socket directory (for example /tmp) for peer
authentication. peer matches the database user to the operating-system
user running the runtime, so set DATABASE__USER to that user (or map it
with a PostgreSQL user-name map). Concurrent upgrades, in one process or
several, run one at a time.
It is the same schema and the same migrations as production. SQLite is not
supported: the schema uses JSONB, INSERT ... ON CONFLICT through the
Postgres dialect, Postgres server defaults and a partial unique index, and
the migrations are written for Postgres. SQLite would need a separate
schema and migration path.
Recorded on 2026-09-06 against Pydantic AI 2.38.0 (2.40.0 as the comparison target, see compatibility).
pydantic_ai.durable_exec(Temporal, DBOS, Prefect): deferred. These make one agent run durable by journaling model requests and tool calls in an external workflow engine, and they require that engine and its worker topology. The runtime's recovery unit is different: a host action ends the run, and the continuation starts a new run from the persisted message tree. The pending action is state between runs, which the session row now holds. Upstream also documents that a durable unit may execute more than once after a crash, so it does not provide the exactly-once external effects guarantee either. Applications that want durable model calls can attach a durability capability throughAssistantDefinition(see composition); the runtime does not adopt one.- Harness
StepPersistence: deferred. It persists harness steps, not the graph state of a run, and the runtime does not use the Harness loop (see the compaction evaluation in compatibility). - Native message history: used. Every message row stores its Pydantic
AI messages (
ModelMessagesTypeAdapter), and later turns replay them unchanged, nativeoutcomevalues and thinking signatures included. The segments are the display form. Provider-side conversation ids are not used, so history does not depend on a provider.
Guarantees, in one place: sessions, messages, steering and the pending host
action are durable with Postgres; a continuation is accepted after a
restart when the matching pending_action row was committed, and the
unanswered call is resolved as unknown otherwise; a recorded result is
never applied twice; unanswered calls are
distinguishable as cancelled, superseded or unknown; external effects
are never assumed, never undone and never retried by the runtime.
Migration 0023 adds voice_calls with a JSON snapshot and a cascading
foreign key to the backend session; 0039 adds created_at (when the call
started, from the snapshot) and updated_at (the last checkpoint write). The optional voice bridge
coalesces transcript, delegation and cumulative duration-usage checkpoints
while connected and writes a final snapshot on close. Audio is not retained.
Backend tool/message history still uses the normal native persistence path.
Database outages leave the call usable in memory; a process crash can lose
recent uncheckpointed fragments. Restored active calls are marked interrupted,
with provider finalization unconfirmed; neither provider connections nor tool
execution resume automatically. Reading cached records also requires access to
the current parent session. These snapshots are observation records, not durable
execution checkpoints or proof of audio playback.
Queued steering stores its optional profile selector. An explicit selector
remains pending across cancellation and restart until a turn with that profile
consumes it. Unprofiled legacy and ingress records inherit the consuming turn.
Existing Postgres installations must apply migration 0026 with
assistant-runtime migrate (or make db-upgrade) before running this version.
Working-memory extraction runs after the turn completes. Within one runtime, session metadata writes and updates to each message are serialized through transaction commit, so an older background write cannot replace a newer pending action or continuation usage total. Continuation snapshots retain the latest background-owned working-memory usage without counting it twice.