Everything the agents service remembers is in one SQLite file (./data/agents.sqlite by default), plus per-agent state directories on disk for memory and attachments.
The tables
Schema: agents/src/sqlite-schema.sql. Nearly every table is WITHOUT ROWID and carries account_id — account isolation is a schema property, not a convention.
Table | Holds |
|---|---|
| Account rows, created lazily as resources are written |
| Which signer may act for an account, with role |
| Server-wide values, including the AES-GCM secret key |
| Provider records, config as CBOR |
| Encrypted API keys, OAuth blobs, and signing-identity seeds |
| Agent definitions (CBOR), state dir, status |
| Hypermedia write drafts |
| Tool documents, keyed by name, addressed by CID |
| Threads: title, status, parent, plan |
| The Log: CBOR payload per |
| Runs — the execution tree and the dispatch queue |
| Script effect log, |
| What a parked run is listening for (Time) |
| Trigger rules: source, prompt, continuation |
| One row per firing, with the exactly-once unique key |
| Per-account, per-server feed cursors |
| Stored request/response bytes per client request id |
Two structural details carry most of the system's weight. runs.status/queue/not_before/created_at is indexed as runs_dispatch — that index is the queue. And trigger_firings has UNIQUE (account_id, trigger_id, activity_key) — that constraint is exactly-once trigger firing.
Migrations
agents/src/sqlite.ts holds a plain array of SQL strings with a convention that trips people up:
export const migrations: string[] = [
// ======= IMPORTANT: Add new migrations below this line. =======
…
].reverse()The array is written newest-first and reversed at the end, so a new migration goes at the top of the literal. desiredVersion is simply migrations.length (currently 11).
Each migration runs inside its own SAVEPOINT within one outer transaction; a failure rolls back to that savepoint and aborts the batch, so a half-applied schema is not a state the server can boot into.
The version gate is strict. A database with no server_config, a legacy schema_version key, a null version, or a version newer than the binary is refused outright — and the server then serves a 500 on every route with the message Delete the database file (rm <dbPath>*) and restart the server. Rolling a binary back to before a migration means recreating the database.
The parity test that keeps the baseline honest
A fresh database is built from sqlite-schema.sql alone, never by replaying migrations — so every column a migration adds must also be added to the baseline file, or fresh installs and migrated installs diverge silently.
sqlite.test.ts asserts both directions: a fresh init has the columns migrations add (runs.parent_tool_call_id, runs.continued_from_run_id, the run_event_waits table, agent_triggers.continuation_cbor), and a synthetic old database built by regex-stripping those columns migrates back up to parity. The comment says it plainly: "this is the assertion that catches the two drifting apart."
Transaction policy
Never hold a write transaction across a provider or tool network call. CreateAgent and CreateSession use short idempotent transactions; MessageSession explicitly avoids long transactions because it does model and network work. The exception that proves the rule is signal delivery, which must be one transaction — journal append, run requeue, and wait deletion together — to be exactly-once.
What is not here
There is no retention or pruning policy for events, runs, or journals. A busy account grows monotonically. Journals are the one exception, bounded per run at 5,000 entries or 8 MiB, which is what makes continueAsNew necessary rather than optional for long loops.
Related
Operations — where the database lives
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime