Skip to main content
NanoClaw stores everything in SQLite, split across two layers:
  • Central databasedata/v2.db. Holds the entity model (agent groups, messaging groups, users), the wiring between them, sessions, and pending approvals. Opened once at startup with journal_mode = WAL and foreign_keys = ON.
  • Per-session databases — one pair per session at data/v2-sessions/<agent_group_id>/<session_id>/. inbound.db is host-written, container-read; outbound.db is container-written, host-read. One writer per file eliminates SQLite write contention across the host–container mount boundary.
Session DBs deliberately use 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 in container_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 opaque trigger_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 by ensureUserDm(). 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

Interactive ask_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 a messaging_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 at data/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 with kind='task'; it owns no tables of its own).

delivered

Delivery outcomes for messages_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 from agent_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 updating messages_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.
  • Entity model — how agent groups, messaging groups, users, and sessions relate
  • Container configuration — full semantics of every container_configs field
  • ncl CLI — the commands that read and write these tables
Last modified on July 16, 2026