- Central database —
data/v2.db. Holds the entity model (agent groups, messaging groups, users), the wiring between them, sessions, and pending approvals. Opened once at startup withjournal_mode = WALandforeign_keys = ON. - Per-session databases — one pair per session at
data/v2-sessions/<agent_group_id>/<session_id>/.inbound.dbis host-written, container-read;outbound.dbis container-written, host-read. One writer per file eliminates SQLite write contention across the host–container mount boundary.
journal_mode = DELETE, not WAL — WAL’s memory-mapped -shm file doesn’t refresh across the host-to-container mount, so the container would silently miss new messages. The host also opens, writes, and closes per operation (a long-lived connection would freeze the container’s view), and only ever writes to outbound.db when no container is running.
The schema is created by migrations (
src/db/migrations/), not by src/db/schema.ts — that file is a reference copy. Migrations are tracked by name in a schema_version bookkeeping table, so module-installed migrations can be added without coordinating version numbers.Central database
data/v2.db. Column-level enums (engage_mode, role, unknown_sender_policy, and so on) are enforced in application code, not by SQL CHECK constraints — the database accepts any text.
agent_groups
Agent workspaces: folder, skills, CLAUDE.md. All workspaces are equal — privilege lives on users, not groups. Container settings live incontainer_configs, not here.
messaging_groups
Platform chats and channels, one row per(channel_type, platform_id, instance) triple (UNIQUE).
The
'strict' column default is mostly vestigial: every creation callsite passes the policy explicitly, and the router’s auto-create path hardcodes 'request_approval'.
Migration 016 added instance and relaxed the UNIQUE constraint from (channel_type, platform_id) to (channel_type, platform_id, instance) via a full table rebuild (SQLite can’t relax a table-level UNIQUE in place). Existing rows backfill to instance = channel_type, so single-instance installs see no behavioral change.
messaging_group_agents
Which agent groups handle which messaging groups. Migration 010 replaced v1’s opaquetrigger_rules JSON and response_scope enum with four explicit, orthogonal columns.
UNIQUE(messaging_group_id, agent_group_id) — one wiring per pair.
The 'mention' / 'all' / 'drop' defaults on the four engage columns exist only in schema.ts’s reference copy — migration 010 adds them as bare nullable ADD COLUMNs with no SQL defaults, and application code always passes values explicitly. On a migrated database the effective column default is NULL.
users
Messaging-platform identifiers, namespaced:phone:+1555…, tg:123, discord:456, email:a@x.com. One human can own multiple user rows across unrelated channels — there’s no identity linking yet.
user_roles
Role grants on users. Privilege is user-level, not group-level.role is owner or admin: owner is always global (agent_group_id IS NULL); admin is global when agent_group_id is NULL, otherwise scoped to that agent group. Invariant (enforced in code): admin of group A is implicitly a member of A — no membership row needed.
Primary key:
(user_id, role, agent_group_id). Indexed on (agent_group_id, role).
agent_group_members
“Known” membership in an agent group — required for an unprivileged user to interact with a workspace.
Primary key:
(user_id, agent_group_id).
user_dms
Cached mapping from (user, channel) to that user’s DM messaging group. Lets the host initiate cold DMs (pairing, approval cards) without reprobing the platform API on every send. Populated lazily byensureUserDm().
Primary key:
(user_id, channel_type).
sessions
One folder = one session = one container when running.
Indexed on
agent_group_id and (messaging_group_id, thread_id).
pending_questions
Interactiveask_user_question cards awaiting an answer.
pending_approvals
Host-side records for any approval-requiring request:install_packages / add_mcp_server (session-bound), and OneCLI credential approvals (session_id may be NULL, action='onecli_credential'). The OneCLI-specific columns let the host edit the admin card when a request expires and sweep stale rows on startup.
pending_sender_approvals
Unknown-sender approval flow (unknown_sender_policy = 'request_approval'). A non-member message triggers a card to the most appropriate admin. UNIQUE(messaging_group_id, sender_identity) gives in-flight dedup — a second message from the same unknown sender while a card is pending is silently dropped instead of spamming the admin. The row is cleared on approve or deny.
pending_channel_approvals
Unknown-channel registration flow (migration 012). When an unwired channel receives a mention or DM, the router escalates to an approver (group admins first, then global admins, then owners). Approve creates amessaging_group_agents row with conservative defaults and replays the event; deny stamps messaging_groups.denied_at so the channel never re-prompts. Primary key on messaging_group_id gives free in-flight dedup.
agent_destinations
Per-agent named map of allowed message targets — both the routing map and the ACL. A row exists if and only if the source agent may send to the target; no row means unauthorized. Names are scoped per source agent: worker-1 may call the admin “parent” while the admin calls the child “worker-1”.
Primary key:
(agent_group_id, local_name). Indexed on (target_type, target_id).
agent_message_policies
Per-message approval gate on an agent-to-agent connection (migration 017). A row means every message from the source agent to the target is held for human approval before delivery — the destination stays wired, but each message pauses. No row = free flow. The gate is directed and per-pair: gate both directions with two rows. Operator-managed only (agents can’t gate their own edges), and a row is deleted in both directions when either agent group is removed, so no policy outlives its connection.
Primary key:
(from_agent_group_id, to_agent_group_id).
container_configs
One row per agent group: how its containers spawn (migration 014,cli_scope added in 015). See Container configuration for full field semantics and the ncl groups config commands that edit it.
unregistered_senders
Audit log of dropped messages from senders that didn’t pass policy (migration 008). One row per(channel_type, platform_id) with a running count, so admins can see who’s knocking.
chat_sdk_* tables
Four small tables backing the Chat SDK bridge state (migration 002):chat_sdk_kv (key/value with optional expires_at), chat_sdk_subscriptions (thread_id, subscribed_at), chat_sdk_locks (thread_id, token, expires_at), and chat_sdk_lists (key, idx, value, optional expires_at). A named non-default adapter instance prefixes its keys with <instance>: so sibling instances of one platform never collide; the default instance uses unprefixed keys (the legacy keyspace), so the table layout is unchanged.
schema_version
Migration bookkeeping:version (applied-order number, auto-assigned), name (UNIQUE — the dedup key), applied (timestamp). A pending_credentials table from earlier builds was dropped by migration 009.
Session databases
One pair per session atdata/v2-sessions/<agent_group_id>/<session_id>/. Schemas come from INBOUND_SCHEMA / OUTBOUND_SCHEMA in src/db/schema.ts; columns added after the initial release (series_id, trigger, source_session_id, on_wake, delivered.platform_message_id, delivered.status) are retrofitted onto pre-existing session DBs by migrateMessagesInTable() / migrateDeliveredTable() at open time.
The seq column is the agent-facing message ID, globally ordered across both files: the host writes even seq numbers into messages_in, the container writes odd into messages_out, so an ID is unambiguous across both tables.
inbound.db (host writes, container reads)
messages_in
Everything the agent receives — chat messages, system events, and scheduled tasks (the scheduling module stores tasks as ordinary rows withkind='task'; it owns no tables of its own).
delivered
Delivery outcomes formessages_out IDs. Lives in inbound.db because the host writes it — writing into container-owned outbound.db would break the one-writer rule.
destinations
The destination map for this session’s agent, derived fromagent_destinations. The host overwrites it on every container wake and on demand (rewires, new child agents); the container queries it live on every lookup, so changes take effect mid-session without a restart.
session_routing
Default reply routing — single-row table (CHECK (id = 1)). The host overwrites it on every container wake from the session’s messaging group and thread. send_message requires an explicit to; the container reads this row to preserve the session’s thread_id when that destination is the same channel the session is bound to (so replies land in the originating thread), and ask_user_question reads it to route its card. Columns: id, channel_type, platform_id, thread_id.
outbound.db (container writes, host reads)
messages_out
Everything the agent sends.processing_ack
The container records processing status here instead of updatingmessages_in (which it can’t write). The host reads it to learn which messages completed; on container startup, stale 'processing' entries are cleared for crash recovery, and the host deletes orphan claims after killing a container.
session_state
Persistent key/value state owned by the container — among other things, the SDK session ID so the conversation resumes across container restarts. Cleared by/clear. Columns: key (primary key), value, updated_at.
container_state
Current tool-in-flight state — single-row table (CHECK (id = 1)). The container writes on PreToolUse and clears on PostToolUse; the host sweep reads it to extend the stuck-detection window while Bash runs with a declared timeout over 60 seconds, so long-running scripts aren’t flagged as stuck.
Related pages
- Entity model — how agent groups, messaging groups, users, and sessions relate
- Container configuration — full semantics of every
container_configsfield - ncl CLI — the commands that read and write these tables