Skip to main content
NanoClaw is one Node.js host process plus one Docker container per active session. The host owns the channels, the routing, and the container lifecycle; the agent runs inside the container and never touches the messaging platforms — that traffic is all host. (The agent’s own network egress is a separate matter: open by default, lockable.) Between them sits the core idea: every message is a row in SQLite. The shipped mailbox uses a SQLite pair: the host writes rows the container reads, and the container writes rows the host reads. Mailbox storage, the central database, and the session runtime each sit behind a separate implementation seam. Host-side message writes and runtime exit events also enqueue reconciliation; periodic polling and resync recover when those hints are lost. Each session owns a pair of database files in its session folder:
  • inbound.db — host writes, container reads. Inbound messages, scheduled tasks, routing defaults.
  • outbound.db — container writes, host reads. Agent replies, processing acknowledgements, session state.
One writer per file, opposite directions — so a crashed container can never corrupt the host’s queue. (One narrow host-side write to outbound.db bends the rule deliberately; the invariants section below explains why it’s safe.)

A message, end to end

One host process, one container, two SQLite files: the host routes platform messages into inbound.db, the container reads them, calls the provider, writes replies to outbound.db, and host delivery sends them back out One host process, one container, two SQLite files: the host routes platform messages into inbound.db, the container reads them, calls the provider, writes replies to outbound.db, and host delivery sends them back out Step by step on the inbound side (src/router.ts):
  1. The adapter normalizes a platform event and hands it to the router (see Channels overview for the adapter side).
  2. Non-threaded adapters collapse threadId to null; the router looks up the messaging group for (channel_type, platform_id, instance), auto-creating one on a mention or DM. Unwired channels escalate to the owner for approval instead of routing.
  3. The sender is resolved to a namespaced user ID, then the message fans out to every wired agent independently: engage mode (pattern, mention, mention-sticky), access gate, and sender scope decide per agent. Agents that decline but have ignored_message_policy='accumulate' still get the row stored as silent context (trigger=0).
  4. For each engaging agent, the router resolves a session (shared, per-thread, or agent-shared — see Entity model), writes the row to that session’s inbound.db, and wakes the container.
On the outbound side, src/delivery.ts drains messages_out: an in-flight set prevents the two polls from double-delivering, rows are filtered against the delivered table in inbound.db, then routed by kind — system actions are handled by the host itself, channel_type='agent' rows go to the agent-to-agent module, and everything else passes a destination permission check before adapter.deliver(). Three failed attempts marks a message permanently failed.

The host process

src/index.ts is a thin orchestrator. What it starts, in order:
  1. Circuit breaker — backs off on rapid restart loops, then an upgrade tripwire refuses to start if the install was updated outside /update-nanoclaw.
  2. Central DB — opens data/v2.db, runs migrations, and backfills container configs.
  3. Runtime check and host lease — proves the container runtime is reachable (NANOCLAW_RUNTIME_DRIVER selects the driver; Docker ships built-in and is the default, and an unknown value aborts startup), then registers this host process in durable coordination state and keeps its lease fresh.
  4. Channel adapters — every adapter registered via the channel barrel is initialized; adapters with missing credentials are skipped with a warning. Inbound messages are held until step 6 finishes, so nothing is routed before running sessions are adopted.
  5. Delivery adapter bridge — connects the delivery system to the adapter registry for deliver() and setTyping().
  6. Gateway approvals and session adoption — starts the selected gateway’s approval subscription and availability monitor, then adopts sessions that are still running from a previous host run instead of killing them; only true orphans are stopped. Containers spawned by a pre-seam release cannot be adopted and are removed at first upgraded startup. Held inbound messages are released after this.
  7. Host modules and delivery polls — runs the start callbacks that modules registered through the host lifecycle registry (onHostStart/onHostShutdown); a failed module start aborts host startup. Then starts the active poll (1s, sessions with a running container) and the sweep poll (60s, all active sessions).
  8. Host sweep — the 60-second maintenance loop described below.
  9. ncl CLI socket server — admin commands over a Unix socket at data/ncl.sock (see ncl CLI).
Modules (permissions, scheduling, approvals, agent-to-agent, typing, and so on) self-register through barrel imports before main() runs — registration is inert until step 6 starts them — and they hook into the router and delivery pipeline rather than being called by name from core.
There is no HTTP API. The webhook server (src/webhook-server.ts) starts lazily when the first Chat SDK adapter registers and listens on WEBHOOK_PORT (default 3000). It routes by path: /webhook/{routingPath} reaches a Chat SDK adapter (the routing path defaults to the adapter name; a second instance of one platform gets its own path), and /webhook/{path} reaches a raw handler that a module registered with registerWebhookHandler() for non–Chat SDK webhooks — raw routes take priority. The admin surface is the ncl CLI over the Unix socket, not HTTP.

Inside the container

The container runs the agent-runner (container/agent-runner/src/) directly with Bun — no compile step, since the source is a read-only bind mount at /app/src. All IO goes through the session DB pair mounted at /workspace; there is no stdin, no stdout markers. The poll loop (poll-loop.ts) drives everything:
  • Every second it reads pending messages_in rows. Batches containing only trigger=0 (accumulated context) rows don’t wake the agent — they ride along with the next real trigger.
  • A batch is marked processing, formatted into XML (<message>, <task>, <webhook> blocks with a timezone header — formatter.ts), and sent to the provider as one prompt.
  • While the query streams, a 500ms follow-up poll pushes newly arrived messages into the open stream instead of restarting the provider subprocess. The push only fires when at least one new message is a trigger; accumulated context alone never engages a warm query.
  • Outbound content must be wrapped in <message to="name"> blocks; each complete block becomes a messages_out row addressed to a named destination, and bare text is treated as scratchpad and never sent. For providers that declare emitsMidTurnText (Claude does), the mid-turn stream is the only content door on chat turns: closed blocks are delivered as they are emitted (with cross-segment assembly of blocks split across streaming segments), and the final result never delivers content (error notices excepted). A chat turn that ends with undelivered text gets one wrap nudge, and the retry streams through the same door: that covers a result with text but no valid block, a block that never went out (unknown destination, or missed by the stream), and bare final text after a send_message send or a reaction, since a tool send may be only an “on it” and does not settle the turn by itself. When something already went out that turn, the nudge quotes it and the agent answers <internal>done</internal> or sends only what is missing; a retry block that repeats what the turn already sent is not sent again. A block to another agent does not count as the reply unless it goes back to the agent whose message woke the turn. Providers without the flag keep the final result as their single delivery door, and task runs stay one-door on every provider: a scheduled task’s only outbound path is the send_message tool.
  • The provider’s session ID (continuation) is persisted to outbound.db so the next container resumes the conversation, and a PreCompact hook injects instructions that preserve routing context through context compaction.
  • On every provider event the runner touches /workspace/.heartbeat, recording provider progress. Runtime status and exit events separately tell the host whether the container is alive.
The agent itself talks to NanoClaw through a built-in MCP server (send_message, ask_user_question, create_agent, and friends — see MCP tools; scheduling is not an MCP tool anymore, it moved to the ncl tasks CLI). The provider behind provider.query() is pluggable: Claude Code is the default, and others self-register the same way channels do (see Providers).

Composed at spawn

The container runner (src/container-runner.ts) rebuilds the agent’s world on every spawn. Concurrent wakes for the same session are deduplicated through an in-flight promise map. A durable session claim also prevents two live host processes from owning the same run. The runner:
  1. Refreshes the session’s destination map and default reply routing in inbound.db.
  2. Materializes container.json from the database and syncs skill symlinks to match its selection.
  3. Composes groups/<folder>/CLAUDE.md (src/project-doc-compose.ts) and builds the mount list. The composed document is one flat file, beginning with <!-- Composed at spawn - do not edit. Standing instructions: instructions.prepend.md. Memory: memory/. -->. Every instruction source is read on the host and inlined as a section: the group’s instructions.prepend.md first, then the shared base (container/CLAUDE.md), the provider’s own blocks, one section per enabled module manual, resident skill prose honoring the group’s skill selection, and instructions from user-added MCP servers. Nothing is an @-import anymore: Claude Code gates imports that resolve outside the project directory behind an approval a headless container can never grant, so pointer-based composition silently lost eight of nine sections; a test now pins that the composer emits no import lines. For Claude a 4 MiB cap guards the file-size cliff, with droppable sections evicted first. The files you (or the agent) edit are still instructions.prepend.md for standing instructions and the memory/ tree for durable memory; the composed CLAUDE.md is regenerated every spawn.
  4. Wires the selected credential gateway (the OneCLI Agent Vault, or Iron Proxy if you installed it): the gateway provider’s per-session contribution (HTTPS_PROXY, trust certificates, credential-stub mounts) is merged into the session spec before validation, so admission sees the whole session. If the vault is unreachable, the spawn fails rather than running without credentials, and the message stays pending for the next sweep.
  5. Composes a session spec and hands it to the driver: mounts, env, limits, labels, and the gateway contribution are assembled into a spec (composeSessionSpec), which the session driver validates and admission-checks on receipt, then realizes: the Docker driver runs docker create + start --attach under a key-derived ncl-… name, with the old human-readable nanoclaw-v2-<folder>-<timestamp> name preserved as the nanoclaw-container-name label. Egress-lockdown network topology is the driver’s own concern (see Security model).
The mounts define what the agent can touch: the session folder at /workspace (read-write), the group folder at /workspace/agent (read-write, with container.json and the composed CLAUDE.md re-mounted read-only on top), and shared read-only code at /app/src and /app/skills. Container lifecycle covers spawn-to-exit in detail.

What runs when

The host sweep (src/host-sweep.ts) feeds a keyed workqueue: message writes and runtime exit hints enqueue sessions promptly, and a periodic resync catches lost hints. The resync also re-heals the egress network and stops any container whose session or agent group has been deleted, so ncl groups delete takes the agent’s container down within about a minute. The per-session work lives in src/reconcile-session.ts:
  1. Syncs processing_ack from outbound.db into messages_in status, so completed work is recorded on the host side.
  2. Wakes containers for due work — rows whose process_after has elapsed (scheduled tasks, retries) with no container running.
  3. Enforces the running-container SLA — kills a container whose heartbeat is older than max(30 minutes, its declared Bash timeout), or one that claimed a message and showed no heartbeat for over max(60 seconds, its declared Bash timeout) since the claim. If no heartbeat file exists yet, the ceiling uses the tracked spawn or adoption time. NANOCLAW_ABSOLUTE_CEILING_MS and NANOCLAW_CLAIM_STUCK_MS change the 30-minute and 60-second values install-wide, each on its own; a longer declared Bash timeout still wins over either.
  4. Cleans up after crashes — processing rows left by a dead container are reset to pending with exponential backoff (5s × 2^tries); after 5 tries a message is marked failed.
  5. Advances recurring tasks — completed recurring rows are cloned into their next occurrence (see Scheduled tasks).
There is deliberately no limit on total run time: liveness is judged from the heartbeat file and claim age, so long-running work that keeps reporting events is not killed. A model that stays silent for longer than the ceiling is killed; NANOCLAW_ABSOLUTE_CEILING_MS raises that limit.

The two database layers

The shipped storage implementations use SQLite, split across two layers — the database schema reference documents every table.
  • Central database (data/v2.db) — the entity model: agent groups, messaging groups, users, wirings, sessions, approvals. Host-only, opened once, WAL mode.
  • Session pairs (data/v2-sessions/<agent_group_id>/<session_id>/) — inbound.db and outbound.db, shared with exactly one container via bind mount.
The shipped SQLite mailbox uses the session pair for host-container message transport. Files in inbox/ and outbox/ carry attachments alongside their message rows, and .heartbeat carries liveness. Three invariants govern the shipped SQLite mailbox across a Docker mount (implemented in src/mailbox/sqlite/session-db.ts):
  1. journal_mode=DELETE, not WAL — WAL’s memory-mapped -shm file doesn’t refresh across the host-to-container mount, so a WAL-mode container would silently miss every new message.
  2. The host opens, writes, and closes per operation — a long-lived host connection would freeze the container’s view of the file at first read.
  3. One writer per file — DELETE-mode journal unlinking isn’t atomic across the mount, so concurrent writers would corrupt the database. Hence the split into two files with opposite ownership. The host itself writes to outbound.db in just one deliberate case: clearing a container’s orphaned processing claims right after the host sweep kills it, opened with the same DELETE journal and busy_timeout discipline. A command-gate denial does not go through the file: the router sends the “Permission denied” reply straight to the chat through the channel adapter, without waking the container. That reply is sent once; a failed send is logged, not retried. (Changed after v2026.10.0, which wrote the denial into outbound.db.)
Last modified on October 19, 2026