Persistence
Where the agents server keeps its state: one SQLite database with a schema gate, a table-by-table reference, secret encryption, durable replay, and the transaction rules.

A Seed Agents server keeps its state in one SQLite database. The canonical schema is agents/src/sqlite-schema.sql. Open and migration validation live in agents/src/sqlite.ts. What an agent publishes as signed blobs goes to the HM server. This database holds the agents server's own runtime state.

Database path

Default:

agents/data/agents.sqlite

Configured by:

    SEED_AGENTS_DB_PATH

    --db-path

Schema gate

On startup, sqlite.open() either:

    initializes a fresh DB from sqlite-schema.sql and stamps it at desiredVersion;

    opens a DB at or behind the desired version and applies the pending migrations;

    rejects a DB whose version is unknown, unparseable, ahead of this binary, or predates version tracking (a legacy schema_version key, or no server_config table at all).

The service never runs against an unknown schema state. On rejection, main.ts serves a 500 on every route and logs both versions.

Migrations live in the migrations array in sqlite.ts and are prepend-only: new entries go at the top of the literal and the array is .reverse()d. Index order then equals apply order, and desiredVersion is migrations.length. Each migration applies inside its own savepoint within one transaction, and a failure rolls the whole batch back. sqlite-schema.sql is the fresh-install baseline and must stay equivalent to baseline + every migration.

Tables

server_config

Stores server-local config blobs.

Current key:

    secret_encryption_key_v1: AES-GCM key for encrypted secrets.

Production caveat: the encrypted secrets and the encryption key sit in the same DB. That beats plaintext in API responses or logs, but it is weaker than KMS or keychain-backed storage.

accounts

Stores account IDs known to this server.

Rows are created or updated as account-owned resources are written. An account id is a Seed account principal.

account_authorizations

Stores local delegated signers for an account.

Accepted roles:

    OWNER

    AGENT

Used by auth.isAuthorizedSigner() and tests. The production UX for delegation and capabilities is still incomplete.

model_providers

Stores account-scoped provider config.

Important columns:

    account_id

    name

    type

    config_cbor

config_cbor encodes ModelProviderConfig:

type ModelProviderConfig = { type: string modelDefaults?: Record<string, unknown> secretRefs?: Record<string, string> baseUrl?: string }

The (account_id, name) pair is unique.

secrets

Stores encrypted account-scoped secret values.

Important columns:

    account_id

    name

    ciphertext

    metadata_cbor

Secrets are never returned in plaintext through the API.

agents

Stores agent definitions.

Important columns:

    account_id

    definition_cbor

    state_dir

    status

definition_cbor encodes AgentDefinition.

state_dir points at the per-agent directory under the service data dir. Its memory/ subdirectory is the agent's private memory filesystem (see agents/src/agent-memory.ts). It is the ~/memory/ half of its Space. The agent reaches it through the read and write verbs, and its owner through the signed agent-memory actions. It lives on disk, outside SQLite, and is removed with the rest of state_dir when the agent is deleted.

Current agent statuses:

    idle

    running

    stopped

    error

Activity rollup (activity_at, activity_kind, message_at, message_from, activity_session_id): the latest transcript activity across the agent's sessions, written on every appended session event. Tool calls, spawns, and results, and every event of a delegated child session, move only activity_at and activity_kind. A message from a person, a trigger, or the agent in a top-level session also moves the message_* columns and activity_session_id. It is exposed as AgentInfo.activity together with a busy flag derived from live runs, so unread indicators read the agents list and never enumerate sessions. NULL until the first event.

Most runtime work happens at the session level. Agent status is not a rich run-state machine yet.

agent_collaborators

Stores pending invitations and accepted agent-level access grants, keyed by (agent_id, account_id). The account_id is the invited or collaborating Seed account. The agent's owning account stays on agents.account_id, and all agent content is still stored under that owner.

Important columns:

    role: reader or writer;

    status: pending or accepted;

    accepted_at: acceptance time, NULL while pending;

    created_at, updated_at.

agent_triggers

Stores saved agent-scoped trigger definitions for HM activity triggers and schedule triggers.

Important columns:

    account_id

    agent_id

    name

    enabled

    source_cbor

    prompt

    continuation_cbor

    last_checked_at

    last_fired_at

    last_error

source_cbor encodes AgentTriggerSource: document-comment, user-mention, comment-reply, document-author-comment, site-update, activity, schedule, webhook, and run-completed. An activity source contains a nonempty list of {id, source} alternatives, limited to the activity filters. Legacy single-source rows remain valid without rewriting their CBOR or changing their identities. Schedule triggers store interval, weekly days and time, or one-time run configuration inside this CBOR value. There is no separate schedule table.

merged_into marks a trigger retired by combination. It is disabled and immutable, retaining its original firing history. The surviving trigger receives the combined conditions and the selected shared action in one transaction.

trigger_event_claims records (account_id, trigger_id, activity_key) for activity admission. The migration seeds it from existing firings, including canonical blob-key aliases for old raw mention keys. Combining imports the union of both triggers' claims; deleting a predecessor does not delete claims already imported into the survivor. Original firing rows keep their IDs and trigger associations even when both predecessors handled the same event.

New activity firings store context_cbor with the name, prompt, and matched conditions observed at admission. Session attribution reads that snapshot instead of the current configuration. Legacy firings retain their existing fallback; combination captures their current view before changing the surviving configuration, without claiming historical condition-match information that was never recorded.

continuation_cbor encodes what a firing does: {kind: 'newThread'} or {kind: 'wake', signal, runId?, payload?}. See trigger continuations. NULL means the only thing triggers used to do: start a new thread. The event-bus milestone moves this, and the rest of a trigger, into a Space document. Until then it lives in this column.

Rows are written by the signed CRUD actions and by the agent itself through write ~/triggers/<name> (writeTriggerAddress), which honors enabled as written. The agent manages its own triggers directly. See security.

The table also carries a cooldown_ms column that nothing reads or writes. It is vestigial: no protocol field sets it and no monitor consults it.

sessions

Stores chat-like sessions.

Important columns:

    account_id

    agent_id

    title

    title_source (system, agent, or user)

    status

    parent_session_id: set on sessions spawned by another session (model children of delegate, script children's ctx.delegate, and agent-started sessions). Lineage-aware clients exclude rows with a parent from the top-level ListSessions view by passing includeChildren: false

    run_id: the run this session is the transcript of, for sessions created as run children

    plan_cbor: the live checklist written by the plan verb (a RunPlan)

    model_override_cbor: the session's quick-switched provider and model, when it differs from the agent's

    description: the live status the agent maintains with the status verb

    thoroughness: the session's delegation preset override (quick, normal, or deep)

title_source protects a title the user typed. Rows start as system. UpdateSession writes user, and every automatic titling path refuses to overwrite a user row. Today the automatic path is #ensureSessionTitled, a small dedicated model call made when a turn parks or finalizes with the session still untitled (enabled by SEED_AGENTS_SESSION_TITLE_GENERATION). It leaves title_source at system on purpose, so the user can still rename. The third value, agent, is written only by #setSessionTitleFromAgent, and no code path calls it. The in-turn set_session_title tool it belonged to was deleted on purpose (api-service.test.ts asserts it never reappears in the tool list), and the function outlived it.

plan_cbor carries RunPlan: {title?, steps: [{id, label, status, resolvedBy?}], settledAt?}. Two fields come from the runtime and never from the model, and normalizeRunPlan accepts neither from model input. resolvedBy: 'runtime' marks a step the runtime closed because every child attached to it came back succeeded. Only success is ever derived this way. settledAt stamps the moment every step became terminal (done, failed, or skipped), so a client watching only the snapshot knows when the checklist finished. A later edit that reopens a step clears it.

Current session statuses:

    idle

    streaming

    stopped

    error

status is a derived mirror of run state, kept for client compatibility. It is streaming iff a non-terminal agent run references the session, error when the session's latest run failed, and idle otherwise. Canceled runs also mirror to idle, so old clients see the pre-runs behavior. The runs table holds the truth about liveness, so a crash can never leave a session stuck in streaming. The boot sweep requeues interrupted runs and the mirror re-derives.

Deleting a session detaches its rows and does not cascade. Its runs keep their history with session_id nulled, and child sessions promote to top level (parent_session_id nulled).

Latest message (message_at, message_from): the per-session half of the agent rollup above, written on every appended message from a person, a trigger, or the agent in a top-level session. Tool activity and child sessions never move it. Exposed as SessionInfo.activity, so a session list can mark which chats hold something unread.

runs

Every execution is a durable row in runs: an interactive turn, a trigger firing, an agent-started session, a delegated model child, a script child. See Runs. The table is also the dispatch queue (see agents/src/runs.ts). Runs form a tree via parent_run_id, with a denormalized root_run_id so one WebSocket subscription covers a whole tree.

Important columns:

    id, account_id, root_run_id, parent_run_id, depth

    parent_tool_call_id: the parent's delegate call that spawned this run. It is also in the run's input payload, but only a column can be read back without decoding every run. The column lets a delegate row in a transcript find its child while that child is still working, before any result exists.

    continued_from_run_id: the run this one continues. ctx.continueAsNew ends a run and starts a successor that carries only the state it declared, so a day-scale loop never grows an unbounded journal. The two rows are one piece of work.

    kind: agent (a model turn, with a transcript session) or workflow (a script child in the QuickJS engine)

    agent_id, session_id (transcript session for agent runs; NULL for script runs), trigger_firing_id

    origin: user, trigger, agent, workflow, or system

    title, model

    source_cid, source_text: for script runs, the JS module and its sha256: digest

    input_cbor, output_cbor, error_cbor ({code, message, retryable?, httpStatus?})

    status: queued, claimed, running, waiting, succeeded, failed, canceled

    wait_cbor: why a run is parked, one of four reasons: children (spawned children, with toolCallIds), timer (wakeAt), event (ctx.waitForEvent), budget-pause (it stopped before spending more). See park and wake source. RunWaitInfo.answerWith names the signal that would answer the wait by hand, when one can.

    attempt, max_attempts, not_before (backoff or timer wake), queue (interactive or background)

    lease_owner, lease_expires_at: crash recovery. The boot sweep requeues rows a dead process left claimed or running

    budget_cbor, usage_cbor (persisted per turn boundary, child usage rolled up into the parent on finalize)

    plan_cbor: a workflow's own ctx.step and ctx.plan snapshot, or the immutable copy of a session plan written onto its owning agent run when that plan settles. The copy keeps completed checklist history after the session starts a new mutable plan

A run's output_cbor or error_cbor may also carry unmetObligations: what the run committed to and had not delivered when it ended. That is an undelivered typed result ({kind: 'typed-result'}), or plan steps left neither finished nor written off ({kind: 'plan', steps}). Nearly every run keeps its word, so the presence of the field is the signal. A run with budget left is asked once to settle every open obligation at the same time. A run that runs out leaves a notice on the log and does not quietly write the debt off.

Deleting a trigger detaches its runs (trigger_firing_id nulled) before deleting firings.

run_event_waits

One row per outstanding ctx.waitForEvent: (run_id, wait_id) primary key plus account_id, match_cbor (the wait criteria: a {signal} for a person or system answering, or {eventType, resource, author} for the activity feed), timeout_at, and created_at, indexed by (account_id, created_at).

Waits have their own table on purpose, with no marker on agent_triggers. A trigger is user configuration: listed and edited in the desktop, carrying prompts and continuations. A wait is transient run state. A running script creates it, and it is deleted the moment it is delivered, times out, or its run dies. Sharing the table would mean filtering the marker out of every trigger listing and mutation forever, and a leaked row would look to its owner like a trigger they never made.

mcp_servers

One row per remote MCP server an account has connected, unique on (account_id, name): id, config_cbor ({url, transport?, headers?, secretRefs?}; secret header values live in secrets under the mcp-<name>-<header> convention and are referenced by name), tools_cbor (the McpToolInfo[] from the last successful discovery, kept across a later failed refresh), status_cbor ({state, error?, checkedAt?}), timestamps. Agents reference servers by name from definition.mcpServers. Deleting a server scrubs those references, the projected documents, and its owned secrets. See mcp.md.

tool_documents

Every tool an agent holds is a content-addressed tool document, one row per (account_id, agent_id, name): kind (builtin, lambda, or mcp), cid, doc_cbor, enabled, timestamps.

doc_cbor is the canonical DAG-CBOR encoding of a ToolDocument (agents/src/tool-documents.ts): {name, kind, summary, description, input, output?, source?, runtime?, binding?}. cid is the CIDv1 over exactly those bytes, the same encoding the hypermedia network uses for blobs. The CID is the tool's version. It changes on every edit, so you can always answer "what exactly can this agent run", and publishing a tool to the network later means publishing bytes that already exist.

Builtin rows are materialized (and refreshed when the shipped registry contract changes, detected by CID mismatch) by ensureBuiltinToolDocuments, which runs whenever the Space index or a ~/tools listing is built and on ListAgentTools. Builtins carry a binding (the runtime executor id) and no source. Lambdas carry authored TypeScript or Python source that runs in the execute sandbox when called by name through the call verb. Authored documents are validated before they are ever stored (name pattern ^[a-z][a-z0-9_-]{1,63}$, 16 KiB description cap, 256 KiB source cap, input and output schemas run through validateJsonSchemaShape), because both the Space index and the call verb trust stored documents. A lambda may not take a builtin's or a verb's name. Builtins cannot be deleted. They are withheld through the agent's grants instead.

mcp rows are projections of mcp_servers.tools_cbor filtered by the agent's definition.mcpServers, named <server>__<tool> and carrying server and remoteName. syncMcpToolDocuments reconciles them (rewrite on CID change, delete when the server is disabled or gone) eagerly on agent and server writes and opportunistically on every listing and run start. They cannot be deleted or replaced by a lambda. A lambda that already holds the name wins.

agent_drafts

Hypermedia write drafts (write hm://โ€ฆ with options.action: 'draft.create' and friends): per-account rows holding the draft content as CBOR (content_format + content_cbor), optional metadata_cbor, the signing identity (signer_secret_name), and the edit and location targets the draft will publish against. Indexed by account + updated_at, by agent, and by status.

run_journal

Append-only journal for script (workflow) runs. It makes replay-from-top resume safe. Rows are (run_id, seq, entry_cbor, created_at) with seq monotonic per run. Each entry carries a callSeq that ties together the entries of one ctx call (call and result, timer and fired; the (run_id, seq) primary key cannot repeat). Each entry also carries a key: the effect's deterministic content key (tool|name|inputJSON, agent|specJSON, sleep|ms, โ€ฆ). Replay matches by key, consuming each key's group FIFO. It does not match by arrival order, because continuation ordering after ctx.parallel depends on real completion timing, and order-based matching misfiles results on resume. A live effect with no journaled group executes fresh (the run's source is pinned via source_cid and source_text). Groups left unconsumed at success log a warning. Entry kinds: call, result, timer, fired, wait, event, now, log, step, plan (see WorkflowJournalEntry in agents/src/workflow-host.ts). wait and event are the two halves of a ctx.waitForEvent: the registration and its resolution (a delivered payload, or nothing at all on timeout). A call entry may carry a description, the human-readable narration a script attaches to an effect. It is display metadata and stays out of the replay key. Caps: 5,000 entries or 8 MiB per run, after which the run fails journal-cap. Entries are streamed to runs/<rootRunId> subscribers as append events and replayed on subscribe.

session_continuations

One row per continuation edge created by continue_session: the predecessor and successor sessions, the origin session of the chain, the tool call and initiating event, the reason, and manifest_cbor, the successor's exact starting point (handoff, cited sources, what was loaded and what was left cold). A successor has exactly one row, and (predecessor_session_id, tool_call_id) is unique so a retried call cannot create a second successor. The session continuation page describes the manifest.

webhook_trigger_credentials

One row per webhook trigger: secret_hash, which delivery requests are checked against, and secret_ciphertext, the encrypted secret that GetAgentTrigger returns to editors. The secret is created with the trigger. See triggers.

trigger_firings

Tracks activity events or scheduled occurrences that matched a trigger and the sessions created from those matches. The activity and schedule monitors use this table for durable idempotency and trigger session history.

Important columns:

    account_id

    agent_id

    trigger_id

    activity_key

    session_id

    activity_cbor

    status

    error

(account_id, trigger_id, activity_key) is unique so feed retries or schedule monitor retries cannot create duplicate firings for the same trigger. Schedule triggers use stable keys in the form schedule:<triggerId>:<scheduledAt>.

activity_watermarks

Stores per-account HM activity feed progress for the activity trigger monitor.

Important columns:

    account_id

    server_url

    cursor_cbor

    last_poll_at

    last_success_at

    last_error

session_events

Append-only durable event log.

Important columns:

    session_id

    seq

    event_cbor

    created_at

seq is monotonic per session. Events are returned by GetSession and replayed on session WebSocket subscriptions.

This is the Log, a shared workspace log. Every entry carries an actor that says who did it, because the user holds the same verbs the agent does. A verb run through InvokeSessionTool appends its tool_call and tool_result here stamped actor: 'user', and the agent reads them on its next turn exactly as it reads its own.

Current event payloads:

type SessionActor = 'user' | 'agent' | 'system' | 'trigger' type SessionEventMeta = { accountId?: string // Seed account that originated a user message signerId?: string // exact cryptographic signer from its verified action envelope model?: string // model that produced the message provider?: string // provider it ran on usage?: AgentRunUsage // this turn's tokens, not the run's cumulative total durationMs?: number // wall time for this message or tool call } type SessionEventPayload = | { type: 'message' role: 'user' | 'assistant' | 'tool' content: string toolCallId?: string rawMarkdown?: string blocks?: AgentMessageBlock[] contextLines?: string[] // client `context` parts; fed to the model, never part of `content` attachments?: SessionAttachmentInfo[] actor?: SessionActor meta?: SessionEventMeta } | {type: 'tool_call'; id: string; name: string; input: unknown; actor?: SessionActor} | { type: 'tool_spawn' toolCallId: string name: string runId: string sessionId?: string title: string actor?: SessionActor } | { type: 'tool_result' toolCallId: string name: string output?: unknown error?: string actor?: SessionActor meta?: SessionEventMeta } | {type: 'error'; message: string; actor?: SessionActor} | Record<string, unknown>

delegate appends tool_spawn the moment its child exists, before the child has run a step. It names the child run, and the session for a model child. The call stays parked without a tool_result until the child finishes, so this event lets a transcript open the child while it works. It is not replayed to the model, and it never stands in for the result, which the child's finalizer appends later.

actor and meta are both optional because events predating them exist. Never treat either as required structure. sessionEventActor() in the protocol package derives the actor of an older event from its shape (a user-role message is user, an error is system, everything else is agent). meta is display detail, absent on older rows. New signed user messages always stamp both the acting Seed accountId and the exact signerId. They may differ when the account uses an authorized device or delegate signer. Model replay keeps that distinction by prefixing signed human messages with their authoritative accountId. For shared agents the system prompt also carries the accepted member roster, roles, and best-effort profile display names.

Plan updates are not durable events. The plan verb writes sessions.plan_cbor in place, so the plan snapshot carries its own settledAt timestamp.

Live assistant partials are not persisted here.

action_idempotency

Stores request and response CBOR keyed by account, action, and client ID.

Used by:

    CreateAgent.clientRequestId

    CreateAgentTrigger.clientRequestId

    CreateSigningIdentity.clientRequestId

    CreateSession.clientRequestId

    MessageSession.clientMessageId

Same client ID with identical request bytes replays the response. Same client ID with different request bytes returns 409.

Secret encryption

Implementation: encryptSecret() and decryptSecret() in api-service.ts.

Current scheme:

    AES-GCM;

    32-byte server-local key;

    12-byte random nonce per write;

    stored ciphertext is nonce || encryptedBytes.

Durable replay

GetSession accepts afterSeq:

{ _: 'GetSession', sessionId, afterSeq }

It returns events with seq > afterSeq.

Session WebSocket subscriptions use the same replay logic when afterSeq is supplied.

Transaction policy

Do not hold write transactions during provider or tool network calls.

CreateAgent and CreateSession can use short idempotent transactions. MessageSession must avoid long SQLite transactions because it does model and network work. Concurrent collaborators append synchronously through the single service process, then enqueue independently. The run queue serializes model turns per session without delaying event persistence.

Improvement areas

    Move from MAX(seq)+1 event sequence allocation to a stronger per-session sequence allocator before supporting multiple service processes writing the same database.

    Add retention and pruning for old events, runs, journals, and idempotency rows.

    Add secret versioning and rotation metadata.

    Add audit log tables for provider, secret, tool, and security events.

    Add a KMS or keychain option for the secret encryption key.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime