22 KiB
NanoClaw — Central DB Schema
Complete reference for data/v2.db, the host-owned admin-plane database. Start with db.md for the three-DB overview, the map, and the cross-mount rules.
Access layer: src/db/. src/db/schema.ts's SCHEMA constant is a reference copy of the core tables for orientation — it is not exhaustive: several tables (agent_destinations, pending_approvals, container_configs, agent_message_policies, pending_channel_approvals, and others) exist only in their migration files under src/db/migrations/, which remain the actual source of truth for what's created at runtime.
1. Tables
1.1 agent_groups
Agent workspaces. Each maps 1:1 to a groups/<folder>/ directory containing CLAUDE.md and skills. Container config lives in container_configs (see §1.x below); a container.json file is materialized at spawn time for the container runner to read.
CREATE TABLE agent_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
agent_provider TEXT,
created_at TEXT NOT NULL
);
- Readers:
src/session-manager.ts,src/delivery.ts,src/router.ts - Writers:
src/db/agent-groups.ts
1.2 messaging_groups
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.) per adapter instance.
CREATE TABLE messaging_groups (
id TEXT PRIMARY KEY,
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT,
is_group INTEGER DEFAULT 0,
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
created_at TEXT NOT NULL,
denied_at TEXT,
UNIQUE(channel_type, platform_id, instance)
);
instance: adapter-instance name — N adapters of one platform (e.g. three Slack apps in one workspace) each own their rows. The default instance IS the channel type: migration 016 backfillsinstance = channel_typeandcreateMessagingGroupstamps the same default, so single-instance installs never see the dimension. Inbound lookups are exact-on-instance (an unknown named instance auto-creates its own row); outbound lookups resolve default-instance-first.unknown_sender_policy:strict(drop),request_approval(ask admin),public(allow).- Readers:
src/router.ts,src/delivery.ts,src/session-manager.ts - Writers:
src/db/messaging-groups.ts, channel setup flows
1.3 messaging_group_agents
Wiring: which agent group handles which messaging group. Many-to-many — the same channel can route to multiple agents (see isolation-model.md).
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
engage_mode TEXT NOT NULL DEFAULT 'mention',
-- 'pattern' | 'mention' | 'mention-sticky'
engage_pattern TEXT, -- regex; required when engage_mode='pattern';
-- '.' means "match every message"
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
session_mode TEXT DEFAULT 'shared',
priority INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(messaging_group_id, agent_group_id)
);
session_mode:shared(one session per channel),per-thread(one per thread),agent-shared(one per agent group across all channels).engage_mode/engage_pattern/sender_scope/ignored_message_policy: four orthogonal axes (migration 010) that replaced v1's opaquetrigger_rulesJSON +response_scopeenum.engage_mode='pattern'requiresengage_pattern('.'matches every message — the "always respond" flavor);sender_scope='known'restricts engagement to group members;ignored_message_policy='accumulate'keeps ignored messages as context instead of dropping them.- Side effect: creating a wiring must also populate
agent_destinations— don't mutate one without the other (see §1.10).
1.4 users
Platform user identities. ID is namespaced: tg:123456, discord:abc, phone:+1555..., email:a@x.com. One human may own several rows — no cross-channel linking yet.
CREATE TABLE users (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL
);
- Writers/readers:
src/db/users.ts; channel auth flows
1.5 user_roles
Permissions. Privilege is user-level, never agent-group-level.
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL,
agent_group_id TEXT REFERENCES agent_groups(id),
granted_by TEXT REFERENCES users(id),
granted_at TEXT NOT NULL,
PRIMARY KEY (user_id, role, agent_group_id)
);
CREATE INDEX idx_user_roles_scope ON user_roles(agent_group_id, role);
Invariants:
role = 'owner'→ must be global (agent_group_id IS NULL). Enforced ingrantRole().role = 'admin'→ global (NULL) or scoped to one agent group.- Admin @ A implies membership in A — no
agent_group_membersrow required.
Access layer: src/db/user-roles.ts, src/access.ts.
1.6 agent_group_members
Explicit membership for non-privileged users. Owner and admins don't need rows here — they're implicit members.
CREATE TABLE agent_group_members (
user_id TEXT NOT NULL REFERENCES users(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
added_by TEXT REFERENCES users(id),
added_at TEXT NOT NULL,
PRIMARY KEY (user_id, agent_group_id)
);
1.7 user_dms
Cache of DM channel discovery. Lets the host send a cold DM (approval card, pairing code) without hitting the platform's openConversation API every time.
CREATE TABLE user_dms (
user_id TEXT NOT NULL REFERENCES users(id),
channel_type TEXT NOT NULL,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
resolved_at TEXT NOT NULL,
PRIMARY KEY (user_id, channel_type)
);
Populated lazily by ensureUserDm() in src/user-dm.ts. Cold DMs resolve via the channel's default adapter instance — PRIMARY KEY (user_id, channel_type) is per-platform, not per-instance.
1.8 sessions
Session registry. One row per (agent group, messaging group, thread) tuple subject to session_mode. Stores lifecycle metadata only — no messages.
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
messaging_group_id TEXT REFERENCES messaging_groups(id),
thread_id TEXT,
agent_provider TEXT,
status TEXT DEFAULT 'active',
container_status TEXT DEFAULT 'stopped',
last_active TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id);
CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id);
- Resolved by:
resolveSession()insrc/session-manager.ts. - Creating a session also provisions the session folder and both session DBs via
initSessionFolder()— see db-session.md.
1.9 pending_questions
The ask_user_question MCP tool parks an interactive question here, and the container matches incoming system messages back to it by questionId.
CREATE TABLE pending_questions (
question_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
message_out_id TEXT NOT NULL,
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
title TEXT NOT NULL,
options_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
1.10 agent_destinations
Permission ACL and name-resolution map for outbound sending. An agent asking to send_message(to="dev-channel") must have a row here with local_name = 'dev-channel', or the send is rejected as unknown destination.
CREATE TABLE agent_destinations (
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
local_name TEXT NOT NULL,
target_type TEXT NOT NULL, -- 'channel' | 'agent'
target_id TEXT NOT NULL, -- messaging_group_id | agent_group_id
created_at TEXT NOT NULL,
PRIMARY KEY (agent_group_id, local_name)
);
CREATE INDEX idx_agent_dest_target ON agent_destinations(target_type, target_id);
Projection invariant (load-bearing). The central table is the source of truth, but each running container reads from a projection in its own inbound.db (see db-session.md §2.3). Any code that mutates agent_destinations while a container is running must also call writeDestinations() (src/session-manager.ts) or the container will reject sends with stale data. Known call sites: createMessagingGroupAgent() in src/db/messaging-groups.ts, the create_agent system action in src/delivery.ts.
Access layer: src/db/agent-destinations.ts.
1.11 pending_approvals
Two workflows share this table:
- Session-bound MCP approvals —
install_packages,add_mcp_server.session_idis set. - OneCLI credential approvals —
session_idmay be NULL;agent_group_id+channel_type+platform_idroute the admin card.
CREATE TABLE pending_approvals (
approval_id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
request_id TEXT NOT NULL,
action TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
agent_group_id TEXT REFERENCES agent_groups(id),
channel_type TEXT,
platform_id TEXT,
platform_message_id TEXT,
expires_at TEXT,
status TEXT NOT NULL DEFAULT 'pending',
title TEXT NOT NULL DEFAULT '',
options_json TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX idx_pending_approvals_action_status ON pending_approvals(action, status);
status:pending|approved|rejected|expired.platform_message_idlets the host edit the admin card in place after a decision.- Access layer:
src/db/sessions.ts; sweep + delivery:src/onecli-approvals.ts.
1.12 unregistered_senders
Audit trail: every time a message gets dropped (unknown sender, strict policy), we increment a counter here so admins can see who's been trying to knock.
CREATE TABLE unregistered_senders (
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
user_id TEXT,
sender_name TEXT,
reason TEXT NOT NULL,
messaging_group_id TEXT,
agent_group_id TEXT,
message_count INTEGER NOT NULL DEFAULT 1,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
PRIMARY KEY (channel_type, platform_id)
);
CREATE INDEX idx_unregistered_senders_last_seen ON unregistered_senders(last_seen);
Writer: recordDroppedMessage() in src/db/dropped-messages.ts. On conflict, bumps message_count + last_seen.
1.13 Chat SDK bridge tables
State backing the SqliteStateAdapter used by the Chat SDK bridge (see api-details.md). NanoClaw code rarely touches these directly — they're owned by src/state-sqlite.ts.
CREATE TABLE chat_sdk_kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER -- unix ts, nullable
);
CREATE TABLE chat_sdk_subscriptions (
thread_id TEXT PRIMARY KEY,
subscribed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE chat_sdk_locks (
thread_id TEXT PRIMARY KEY,
token TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE chat_sdk_lists (
key TEXT NOT NULL,
idx INTEGER NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER,
PRIMARY KEY (key, idx)
);
1.14 schema_version
Migration ledger, written by the migration runner (§2).
CREATE TABLE schema_version (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied TEXT NOT NULL
);
1.15 container_configs
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to groups/<folder>/container.json at spawn time.
CREATE TABLE container_configs (
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
provider TEXT,
model TEXT,
effort TEXT,
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
updated_at TEXT NOT NULL
);
- Readers:
src/container-config.ts,src/container-runner.ts,src/cli/dispatch.ts(scope enforcement),src/claude-md-compose.ts - Writers:
src/db/container-configs.ts,src/modules/self-mod/apply.ts,src/backfill-container-configs.ts
1.16 pending_sender_approvals
In-flight state for the unknown_sender_policy = 'request_approval' flow. A row exists while an admin-approval card is outstanding for a first-time sender in a wired messaging group; UNIQUE(messaging_group_id, sender_identity) dedups concurrent attempts from the same sender instead of spamming the admin with repeat cards.
CREATE TABLE pending_sender_approvals (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle)
sender_name TEXT,
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
approver_user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', -- added by migration 013
options_json TEXT NOT NULL DEFAULT '[]', -- added by migration 013
UNIQUE(messaging_group_id, sender_identity)
);
Deleted on admin approve (after adding the sender as a member) or deny.
- Access layer:
src/modules/permissions/db/pending-sender-approvals.ts - Readers/writers:
src/modules/permissions/sender-approval.ts,src/modules/permissions/index.ts,src/db/sessions.ts(getAskQuestionRender),src/cli/resources/groups.ts
1.17 pending_channel_approvals
In-flight state for the unknown-channel registration flow. When a channel with no messaging_group_agents wiring receives a mention or DM, the router escalates to the owner; PRIMARY KEY(messaging_group_id) gives free in-flight dedup via INSERT OR IGNORE — a second mention while a card is pending drops silently.
CREATE TABLE pending_channel_approvals (
messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
-- agent the approved wiring will target (earliest
-- agent_group by created_at, picked at request time)
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
approver_user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', -- added by migration 013
options_json TEXT NOT NULL DEFAULT '[]' -- added by migration 013
);
Approve creates the messaging_group_agents wiring and replays the triggering event; deny sets messaging_groups.denied_at so future messages on that channel drop without re-prompting. Either way, this row is deleted.
- Access layer:
src/modules/permissions/db/pending-channel-approvals.ts - Readers/writers:
src/modules/permissions/channel-approval.ts,src/modules/permissions/index.ts,src/router.ts,src/db/sessions.ts(getAskQuestionRender),src/cli/resources/groups.ts
1.18 agent_message_policies
Per-message approval gate on an agent-to-agent connection between two agent groups. No row for a (from, to) pair means free flow (no approval required); a row names the approver who must sign off on each message.
CREATE TABLE agent_message_policies (
from_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
to_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
approver TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (from_agent_group_id, to_agent_group_id)
);
- Access layer:
src/modules/agent-to-agent/db/agent-message-policies.ts - Readers/writers:
src/cli/resources/policies.ts; approved messages create a row inpending_approvals(see §1.11) via the a2a send path.
2. Migration system
Migrations live in src/db/migrations/, one file per migration. Runner: runMigrations() in src/db/migrations/index.ts. It:
- Creates
schema_versionif absent. - Reads every already-applied
namefromschema_versioninto aSetand filters themigrationsbarrel array down to the ones whosenameisn't in that set — dedup is by name, not by the numericversionfield. - Runs each pending migration's
up(db)inside a transaction, in the barrel array's literal order (which is not sorted byversion), then inserts aschema_versionrow. - The
versioncolumn stored inschema_versionis not the migration's ownversionfield — it'sCOALESCE(MAX(version), 0) + 1, i.e. an auto-assigned applied-order number computed at insert time. Theversionfield on theMigrationobject is just an ordering hint for humans reading the barrel file; it lets module migrations (installed later by skills) pick arbitrary numbers without coordinating with trunk.
A few migrations also set disableForeignKeys: true (needed for table recreates — SQLite can't relax a table-level UNIQUE without DROP+RENAME, which fails FK integrity checks with live child rows). The runner toggles PRAGMA foreign_keys around the transaction and runs PRAGMA foreign_key_check inside it, snapshotting pre-existing violations so it only fails on violations the migration itself introduced.
Several early migrations were later renamed/retired and replaced by "module" files (their original name is retained on the new file so already-migrated DBs don't re-run them):
| Ver. | Name (stored in schema_version) |
File | Introduces |
|---|---|---|---|
| 1 | initial-v2-schema |
001-initial.ts |
Core tables: agent_groups, messaging_groups, messaging_group_agents (with the original trigger_rules/response_scope columns — see v10), users, user_roles, agent_group_members, user_dms, sessions, pending_questions |
| 2 | chat-sdk-state |
002-chat-sdk-state.ts |
chat_sdk_kv, chat_sdk_subscriptions, chat_sdk_locks, chat_sdk_lists |
| 3 | pending-approvals |
module-approvals-pending-approvals.ts |
pending_approvals (session-bound + OneCLI fields) |
| 4 | agent-destinations |
module-agent-to-agent-destinations.ts |
agent_destinations + backfill from existing messaging_group_agents wirings |
| 7 | pending-approvals-title-options |
module-approvals-title-options.ts |
Retroactive ALTER TABLE pending_approvals add title, options_json for DBs that ran migration 3 before its CREATE TABLE was edited to include those columns |
| 8 | dropped-messages |
008-dropped-messages.ts |
unregistered_senders |
| 9 | drop-pending-credentials |
009-drop-pending-credentials.ts |
Drop the defunct pending_credentials table |
| 10 | engage-modes |
010-engage-modes.ts |
messaging_group_agents: add engage_mode, engage_pattern, sender_scope, ignored_message_policy; backfill from trigger_rules/response_scope; drop those two legacy columns (see §1.3) |
| 11 | pending-sender-approvals |
011-pending-sender-approvals.ts |
pending_sender_approvals (see §1.16) |
| 12 | channel-registration |
012-channel-registration.ts |
messaging_groups.denied_at + pending_channel_approvals (see §1.17) |
| 13 | approval-render-metadata |
013-approval-render-metadata.ts |
title, options_json columns on pending_channel_approvals and pending_sender_approvals |
| 14 | container-configs |
014-container-configs.ts |
container_configs — per-agent-group container runtime config |
| 15 | cli-scope |
015-cli-scope.ts |
ALTER TABLE container_configs ADD COLUMN cli_scope |
| 16 | messaging-group-instance |
016-messaging-group-instance.ts |
messaging_groups gets an instance column (adapter-instance dimension); table recreate (disableForeignKeys: true) backfills instance = channel_type on every existing row and relaxes the UNIQUE to (channel_type, platform_id, instance) |
| 17 | agent-message-policies |
017-agent-message-policies.ts |
agent_message_policies (see §1.18) |
| 18 | approvals-approver-user-id |
018-approvals-approver-user-id.ts |
pending_approvals.approver_user_id — names a single required approver for a2a message-gate policies |
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
Session DB schemas (INBOUND_SCHEMA, OUTBOUND_SCHEMA) are not versioned here. They're CREATE TABLE IF NOT EXISTS so new columns land via the session-DB lazy migration helpers (migrateDeliveredTable() etc.) when a session file from an older build is reopened. See db-session.md.