## File: README.md

---

## Why I Built NanoClaw

[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.

NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.

## Quick Start

```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```

`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (iMessage, Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.


**Migrating from NanoClaw v1?**

Run from a fresh v2 checkout next to your v1 install:

```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```

`migrate-v2.sh` finds your v1 install (sibling directory, or `NANOCLAW_V1_PATH=/path/to/nanoclaw`), migrates state into the v2 checkout, then `exec`s into Claude Code to finish the parts that need judgment (owner seeding, shared-memory migration, fork-customisation replay).

Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.

**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including the Baileys keystore for WhatsApp — LID mapping is now resolved per-message by the Baileys v7 adapter, not migrated), builds the agent container.

**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.

See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different and [docs/migration-dev.md](docs/migration-dev.md) for development notes.



## Philosophy

**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.

**Secure by isolation.** Agents run in Linux containers and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.

**Built for the individual user.** NanoClaw isn't a monolithic framework; it's software that fits each user's exact needs. Instead of becoming bloatware, NanoClaw is designed to be bespoke. You make your own fork and have Claude Code modify it to match your needs.

**Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that it's safe to make changes.

**AI-native, hybrid by design.** The install and onboarding flow is an optimized scripted path, fast and deterministic. When a step needs judgment, whether a failed install, a guided decision, or a customization, control hands off to Claude Code seamlessly. Beyond setup there's no monitoring dashboard or debugging UI either: describe the problem in chat and Claude Code handles it.

**Skills over features.** Trunk ships the registry and infrastructure, not specific channel adapters or alternative agent providers. Channels (Discord, Slack, Telegram, WhatsApp, …) live on a long-lived `channels` branch; alternative providers (OpenCode, Ollama) live on `providers`. You run `/add-telegram`, `/add-opencode`, etc. and the skill copies exactly the module(s) you need into your fork. No feature you didn't ask for.

**Best harness, best model.** NanoClaw natively uses Claude Code via Anthropic's official Claude Agent SDK, so you get the latest Claude models and Claude Code's full toolset, including the ability to modify and expand your own NanoClaw fork. Other providers are drop-in options: `/add-codex` for OpenAI's Codex (ChatGPT subscription or API key), `/add-opencode` for OpenRouter, Google, DeepSeek and more via OpenCode, and `/add-ollama-provider` for local open-weight models. Provider is configurable per agent group.

## What It Supports

- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
- **Scheduled tasks**: recurring jobs executed by the agent, with optional [script gates](docs/scheduled-tasks.md) that avoid waking it when there is no work
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker containers (macOS/Linux/WSL2)
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle via `ncl groups create --template <ref>`. Templates load from the local `templates/` folder; populate it by hand or by copying from the [public library](https://github.com/nanocoai/nanoclaw-templates). See [docs/templates.md](docs/templates.md).

## Accounts and what leaves your machine

NanoClaw has no user accounts. The only thing it reports is anonymous setup diagnostics, and
`NANOCLAW_NO_DIAGNOSTICS=1` turns those off. Your agents, messages, files and keys never leave
your machine.

One opt-in exception: you can [fetch a prebuilt agent image](docs/hardened-image.md) instead of
building it locally. Fetching ours needs a free account, so we see your email address and when
you ask for an image — nothing about your agents, and nothing after the image lands. Building
locally needs no account and contacts nothing, and is the default.

## Usage

Talk to your assistant with the trigger word (default: `@Andy`):

```
@Andy send an overview of the sales pipeline every weekday morning at 9am (has access to my Obsidian vault folder)
@Andy review the git history for the past week each Friday and update the README if there's drift
@Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing
```

From a channel you own or administer, you can manage groups and tasks:
```
@Andy list all scheduled tasks across groups
@Andy pause the Monday briefing task
@Andy join the Family Chat group
```

## Customizing

NanoClaw doesn't use configuration files. To make changes, just tell Claude Code what you want:

- "Change the trigger word to @Bob"
- "Remember in the future to make responses shorter and more direct"
- "Add a custom greeting when I say good morning"
- "Store conversation summaries weekly"

Or run `/customize` for guided changes.

The codebase is small enough that Claude can safely modify it.

## Contributing

**Don't add features. Add skills.**

If you want to add a new channel or agent provider, don't add it to trunk. New channel adapters land on the `channels` branch; new agent providers land on `providers`. Users install them in their own fork with `/add-<name>` skills, which copy the relevant module(s) into the standard paths, wire the registration, and pin dependencies.

This keeps trunk as pure registry and infra, and every fork stays lean — users get the channels and providers they asked for and nothing else.

### RFS (Request for Skills)

No channel or provider skills are currently requested — propose one via an issue.

## Requirements

- macOS or Linux (Windows via WSL2)
- Node.js 22+ and pnpm 10+ (the installer will install both if missing)
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) or Docker Engine (Linux)
- [Claude Code](https://claude.ai/download) for `/customize`, `/debug`, error recovery during setup, and all `/add-<channel>` skills

## Architecture

```
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
```

A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs the agent, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.

Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.

For the full architecture writeup see [docs/architecture.md](docs/architecture.md); for the three-level isolation model see [docs/isolation-model.md](docs/isolation-model.md).

Key files:
- `src/index.ts` — entry point: DB init, channel adapters, delivery polls, sweep
- `src/router.ts` — inbound routing: messaging group → agent group → session → `inbound.db`
- `src/delivery.ts` — polls `outbound.db`, delivers via adapter, handles system actions
- `src/host-sweep.ts` — 60s sweep: stale detection, due-message wake, recurrence
- `src/session-manager.ts` — resolves sessions, opens `inbound.db` / `outbound.db`
- `src/container-runner.ts` — spawns per-agent-group containers, OneCLI credential injection
- `src/db/` — central DB (users, roles, agent groups, messaging groups, wiring, migrations)
- `src/channels/` — channel adapter infra (adapters installed via `/add-<channel>` skills)
- `src/providers/` — host-side provider config (`claude` baked in; others via skills)
- `container/agent-runner/` — Bun agent-runner: poll loop, MCP tools, provider abstraction
- `groups/<folder>/` — per-agent-group filesystem (`CLAUDE.md`, skills, container config)

## FAQ

**Why Docker?**

Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem.

**Can I run this on Linux or Windows?**

Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `bash nanoclaw.sh`.

**Is this secure?**

Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. Credentials never enter the container — outbound API requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects authentication at the proxy level and supports rate limits and access policies. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model.

**Why no configuration files?**

We don't want configuration sprawl. Every user should customize NanoClaw so that the code does exactly what they want, rather than configuring a generic system. If you prefer having config files, you can tell Claude to add them.

**Can I use third-party or open-source models?**

Yes. The supported path is `/add-opencode` (OpenRouter, OpenAI, Google, DeepSeek, and more via OpenCode config) or `/add-ollama-provider` (local open-weight models via Ollama). Both are configurable per agent group, so different agents can run on different backends in the same install.

For one-off experiments, any Claude API-compatible endpoint also works via `.env`:

```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```

**How do I debug issues?**

Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw.

**Why isn't the setup working for me?**

If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill.

**How do I uninstall NanoClaw?**

```bash
bash nanoclaw.sh --uninstall
```

Every install is tagged with a per-checkout id, so the uninstaller removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself.

**What changes will be accepted into the codebase?**

Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all.

Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills: channel and provider code on the `channels`/`providers` registry branches, everything else as a self-contained skill. See [docs/customizing.md](docs/customizing.md) and [CONTRIBUTING.md](CONTRIBUTING.md).

This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want.

## Community

Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42).

## Changelog

See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release history](https://docs.nanoclaw.dev/changelog) on the documentation site.

## License

MIT

---

## File: docs/agent-runner-details.md

# NanoClaw Agent-Runner Details

Implementation-level details for the agent-runner inside the container. See [architecture.md](architecture.md) for the high-level design.

## Separation of Concerns

The agent-runner has two layers:

1. **Agent-runner core** — owns the poll loop, message formatting, DB reads/writes, MCP tool implementations, routing, status management, media handling. This is NanoClaw-specific and shared across all providers.

2. **Agent provider** — owns the SDK interaction. Takes formatted prompts, pushes them to the SDK, yields events back. Trunk ships the `claude` provider; additional providers (OpenCode, Codex, etc.) are installed by `/add-` skills from the `providers` branch.

The boundary: the agent-runner decides **what** to send and **what to do** with results. The provider decides **how** to talk to the SDK.

## AgentProvider Interface

Provider-wide settings (MCP servers, env, additional directories, model, effort,
assistant name) are passed to the provider **constructor** via `ProviderOptions`, not
per query. `QueryInput` carries only what changes turn to turn: the prompt, the
continuation token to resume, the working directory, and system context to inject.

```
/* Detailed source-code truncated for AI context efficiency. */
```

### What the interface does NOT include

- **Message formatting** — the agent-runner formats messages before passing to the provider. The provider receives a ready-to-send prompt string.
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreToolUse, PostToolUse, PreCompact). Other providers don't need them.
- **Tool allowlists** — Claude uses `allowedTools` + `disallowedTools`. Other SDKs use their own equivalents. Each provider configures this internally.
- **Session persistence** — the agent-runner stores one opaque `continuation` token per provider (see [Session Resume](#session-resume)) and passes it back as `QueryInput.continuation`. What it means is provider-private; Claude persists its own `.jsonl` transcript on disk keyed by the continuation (session ID).
- **Sandbox configuration** — provider-specific. Each provider configures its own sandbox internally.

### Provider event semantics

- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `continuation` and persists it for future resume.
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). `isError` is set when the SDK flagged the turn as an error (e.g. a non-retryable billing error) so the poll-loop still surfaces the text instead of dropping it. The agent-runner writes each result to messages_out.
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota').
- **`progress`** — optional, for logging. The agent-runner logs these but doesn't act on them.
- **`activity`** — a liveness signal. Providers MUST yield it on every underlying SDK event (tool call, thinking, partial message) so the poll-loop's idle timer stays honest during long tool runs.

## Provider Implementations

Only the `claude` provider ships in trunk. The Codex and OpenCode sections below document the provider interface for reference and for skills that install additional providers — they are not baked into the core image.

### Claude Provider

Wraps `@anthropic-ai/claude-agent-sdk`'s `query()`.

The provider takes its settings (`mcpServers`, `env`, `additionalDirectories`,
`model`, `effort`, `assistantName`) in its constructor via `ProviderOptions`; `query()`
only reads the per-turn `QueryInput`.

```
/* Detailed source-code truncated for AI context efficiency. */
```

`translateEvents` is an async generator that yields `{ type: 'activity' }` for **every**
SDK message (so the idle timer stays honest) and maps recognized messages to `ProviderEvent`:
- `system`/`init` → `{ type: 'init', continuation: session_id }`
- `result` → `{ type: 'result', text, isError }` — `text` is `result.result`, or the joined `result.errors[]` on error subtypes (billing/quota), so the notice still reaches the user
- `system`/`api_retry` → `{ type: 'error', retryable: true }`
- `system`/`rate_limit_event` → `{ type: 'error', retryable: false, classification: 'quota' }`
- `system`/`compact_boundary` → `{ type: 'result', text: 'Context compacted…' }`
- `system`/`task_notification` → `{ type: 'progress', message }`
- when the `aborted` flag is set → the generator returns immediately

**Claude-specific behavior inside the provider:**
- `MessageStream` for async iterable input (push-based follow-ups)
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
- **PreCompact** hook archives the transcript to `conversations/` before compaction
- `maybeRotateContinuation` drops an oversized/aged transcript (default caps 12 MB / 14 days, both operator-overridable) so a cold container isn't killed reloading days of `.jsonl` before the host idle ceiling; `isSessionInvalid` clears a continuation whose transcript is gone
- `additionalDirectories` for multi-directory access

### Codex Provider

Wraps `@openai/codex-sdk`.

```
/* Detailed source-code truncated for AI context efficiency. */
```

**Codex-specific behavior inside the provider:**
- `developer_instructions` for system prompt (loaded from CLAUDE.md)
- `git init` in workspace (Codex requires a git repo)
- Abort+restart pattern for follow-up messages
- `sandboxMode`, `approvalPolicy`, `networkAccessEnabled` from env vars
- Conversation archiving (Codex doesn't have PreCompact)

### OpenCode Provider

Wraps `@opencode-ai/sdk`.

```typescript
class OpenCodeProvider implements AgentProvider {
  query(input: QueryInput): AgentQuery {
    // OpenCode runs a local server — create it once, reuse across queries
    const { client, server } = await createOpencode({ config: this.buildConfig(input) });
    const { stream } = await client.event.subscribe();

    let aborted = false;
    let pendingFollowUp: string | null = null;

    return {
      push: (msg) => {
        pendingFollowUp = msg;
        server.close();  // interrupt current query
      },
      end: () => { /* no-op */ },
      abort: () => { aborted = true; server.close(); },
      events: this.run(client, server, stream, input, () => pendingFollowUp),
    };
  }

  private async *run(client, server, stream, input, getPendingFollowUp): AsyncIterable {
    const session = await client.session.create();
    yield { type: 'init', continuation: session.data.id };

    await client.session.promptAsync({
      path: { id: session.data.id },
      body: { parts: [{ type: 'text', text: input.prompt }] },
    });

    for await (const event of stream) {
      if (event.type === 'session.idle') {
        // Collect result text from accumulated message parts
        const resultText = this.extractResult(event);
        yield { type: 'result', text: resultText };

        const followUp = getPendingFollowUp();
        if (followUp) {
          await client.session.promptAsync({
            path: { id: session.data.id },
            body: { parts: [{ type: 'text', text: followUp }] },
          });
          continue;
        }

        return;
      }

      if (event.type === 'session.error') {
        yield { type: 'error', message: event.properties?.error?.data?.message, retryable: false };
        return;
      }
    }
  }
}
```

**OpenCode-specific behavior inside the provider:**
- Local gRPC/HTTP server lifecycle (`server.close()`)
- SSE event stream for output
- Provider/model selection via config (`OPENCODE_PROVIDER`, `OPENCODE_MODEL`)
- MCP config format translation (`type: 'local'`, `command: [cmd, ...args]`, `environment`)
- System prompt injected via `<system>` prefix in prompt text
- No resume support (sessions are always new or reused by ID)

## Agent-Runner Core

Everything below is handled by the agent-runner, not the provider.

### Poll Loop

```
┌─────────────────────────────────────────┐
│                                         │
│  1. Query messages_in for pending rows  │
│     WHERE status = 'pending'            │
│     AND (process_after IS NULL          │
│          OR process_after <= now())     │
│                                         │
│  2. If rows found:                      │
│     a. Set status = 'processing'        │
│     b. Format messages by kind          │
│     c. Strip routing fields             │
│     d. Call provider.query(prompt)      │
│     e. Process provider events          │
│     f. Write results to messages_out    │
│     g. Set status = 'completed'         │
│                                         │
│  3. While query is active:              │
│     - Continue polling messages_in      │
│     - New messages → provider.push()    │
│                                         │
│  4. When query finishes:                │
│     - Back to step 1                    │
│     - If no messages, sleep + re-poll   │
│                                         │
└─────────────────────────────────────────┘
```

**Concurrent polling during active query:** While the provider is running a query, the agent-runner continues polling messages_in on a short interval (~500ms). New pending messages are formatted and pushed into the active query via `provider.push()`. This lets follow-up messages arrive while the agent is processing — Claude handles this natively, Codex/OpenCode handle it via abort+restart internally.

**Idle behavior:** When no messages are pending and no query is active, the agent-runner sleeps briefly (1s) and re-polls. The container stays warm until the host kills it (idle timeout).

**Idle detection exceptions:** The container should NOT be considered idle when:
- An `ask_user_question` tool call is pending (waiting for user response in messages_in)
- The agent is actively working (tool calls in progress, subagents running)

The agent-runner signals "busy" status to the host. The mechanism for this is provider-specific — for Claude, the query AsyncGenerator is still yielding events. For others, the agent-runner can write a heartbeat or status indicator to the session DB that the host checks before killing.

### Message Formatting

The agent-runner transforms messages_in rows into a prompt string. The provider receives a ready-to-send string — it doesn't know about message kinds or routing.

**Routing field stripping:** `platform_id`, `channel_type`, `thread_id` are never included in the prompt. They're stored as context for writing messages_out.

Every kind renders to a single self-contained XML element. The `id` attribute is the
message's `seq` (the agent-facing message ID it passes to `edit_message` / `add_reaction`).
The `from` attribute is the origin destination name (resolved from the routing fields via
the destination map), so the agent always knows where a message came from — routing fields
themselves are never shown.

- **`chat`** — one `<message>` per row:
  ```xml
  <message id="5" from="family" sender="John" time="Jan 1, 10:00 AM">Check this PR</message>
  ```
  A reply carries a `reply_to` attribute and an inline `<quoted_message from="…">…</quoted_message>`.

- **`chat-sdk`** — same `<message>` shape, fields extracted from the serialized Chat SDK
  message. Attachments are appended inline: `[image: screenshot.png — saved to /workspace/…]`
  or `[image: screenshot.png (https://signed-url…)]`. Images/PDFs that Claude handles
  natively are also passed as content blocks (see Media Handling below).

- **`task`** — a `<task>` element, script output first when present:
  ```xml
  <task from="scheduler" time="Jan 1, 9:00 AM" current_time="Thursday, January 1, 2026 at 9:01 AM">Script output:
  {"data": …}

  Instructions:
  Review open PRs</task>
  ```

  `time` is the occurrence's effective scheduled time (`process_after`, falling
  back to its creation timestamp for legacy rows). `current_time` is generated
  when the task reaches the agent so relative instructions such as "today" stay
  correct after pauses or delayed execution. Both render in the agent group's
  timezone.

- **`webhook`** — a `<webhook>` element wrapping the JSON payload:
  ```xml
  <webhook from="github" source="github" event="pull_request">{"action": "opened", …}</webhook>
  ```

- **`system`** — host action result, rendered as `<system_response>`:
  ```xml
  <system_response from="host" action="create_agent" status="success">{"agent_group_id": "ag-456"}</system_response>
  ```

**Batch formatting:** All pending messages are combined into one prompt. The prompt opens
with a self-closing `<context timezone="" />` header (so the agent interprets every
timestamp — and every time it schedules — in the user's zone), then the chat messages
concatenated as consecutive `<message>` blocks, then any task/webhook/system elements,
joined by blank lines:

```xml
<context timezone="America/Los_Angeles" />
<message id="2" from="family" sender="John" time="10:00">Check this PR</message>
<message id="4" from="family" sender="Jane" time="10:01">Already on it</message>
```

There is **no** outer `<messages>` envelope — an earlier revision wrapped multi-message
batches that way, but the Claude Agent SDK answered the wrapped shape with a synthetic
"No response requested." stub instead of calling the API (#2555). Dropping the wrapper made
the single-message path just the N=1 case of the same concatenation.

**Command detection:** Messages starting with `/` are checked against a command list. Recognized commands bypass formatting and are passed raw to the provider (for Claude's slash command handling) or intercepted by the agent-runner (for NanoClaw-level commands like session reset).

### Routing

When the agent-runner picks up messages_in rows, it captures the routing fields from the batch:

```typescript
interface RoutingContext {
  platformId: string | null;
  channelType: string | null;
  threadId: string | null;
  inReplyTo: string | null;  // messages_in.id of the triggering message
}
```

When writing messages_out (either from provider results or MCP tool calls), the agent-runner copies this routing context by default. The agent never sees routing fields — it just produces text. The routing is implicit: "respond to whoever sent the message."

MCP tools that target a named destination (`send_message` / `send_file` with a `to`
argument) resolve routing through the session's destination map instead of the default
reply context — including agent-to-agent sends, which are just a `to` pointing at an
`agent`-type destination.

### Status Management

`inbound.db` is a read-only mount inside the container, so the agent-runner never writes
`messages_in`. It tracks processing status in the `processing_ack` table in the
container-owned `outbound.db`; the host reads `processing_ack` and mirrors completion
back onto `messages_in.status`.

```
processing_ack: (no row) → processing → completed
```

- **Pick up:** `INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', now())` for each claimed row (`markProcessing`). Pending queries skip any row already present in `processing_ack`.
- **Complete:** same upsert with `status = 'completed'` (`markCompleted`). Every consumed batch ends here — error outcomes included. On a provider error the poll-loop writes an error **chat message** to `messages_out` (so the user sees it), then still acks the batch completed; errors surface as messages, not as an ack status. (A `markFailed` helper exists in `messages-in.ts` but currently has no callers.)
- The host's `syncProcessingAcks` mirrors acked ids onto `messages_in.status = 'completed'`. Its stale/retry policy is driven off the `.heartbeat` file mtime and the `processing_ack` claim timestamps. On startup the agent-runner clears leftover `processing` acks (crash recovery) so orphaned claims re-process.

### MCP Tools

The agent-runner runs an MCP server (stdio) that exposes NanoClaw tools to the agent. The
tool modules use the same two-DB connection layer as the rest of the runner
(`container/agent-runner/src/mailbox/sqlite/connection.ts`): they read the host-written `inbound.db`
at `/workspace/inbound.db` **read-only** (destinations, session routing, question
responses, task lists) and write to the container-owned `outbound.db` at
`/workspace/outbound.db`. There is no shared single-file connection and no WAL — both files
are `journal_mode=DELETE` because WAL's memory-mapped `-shm` file does not stay coherent
across the VirtioFS host↔container mount.

#### send_message

Send a chat message to a named destination. Agents address destinations by name, never by
raw platform/channel/thread IDs — the destination map (`destinations` table in `inbound.db`,
written by the host) resolves the name to routing fields.

```typescript
{
  name: 'send_message',
  params: {
    text: string,    // message content (required)
    to?: string,     // destination name (e.g. "family", "worker-1").
                     // Optional when the agent has exactly one destination.
  }
}
```

Implementation: `resolveRouting(to)` looks up the destination. With no `to`, it defaults to
the session's own reply routing (`session_routing`); if the destination resolves to the same
channel the session is bound to, the session's `thread_id` is preserved so the reply lands
in-thread, otherwise `thread_id` is null. The tool then writes a `messages_out` row with
`kind: 'chat'` and content `{ text }`, and returns the new `seq` as the message id.

#### send_file

Send a file to a named destination (same destination model as `send_message`).

```typescript
{
  name: 'send_file',
  params: {
    path: string,          // file path (relative to /workspace/agent/ or absolute) (required)
    to?: string,           // destination name; optional if the agent has one destination
    text?: string,         // optional accompanying message
    filename?: string,     // display name (default: basename of path)
  }
}
```

Implementation:
1. Resolve routing via `resolveRouting(to)` (as `send_message`)
2. Generate a message ID and create `/workspace/outbox/{messageId}/`
3. Copy the file into that outbox directory
4. Write a `messages_out` row (`kind: 'chat'`) with content `{ text, files: [filename] }`

#### send_card

Send a structured card (interactive or display-only).

```typescript
{
  name: 'send_card',
  params: {
    card: CardElement,     // card structure (title, children, actions)
    fallbackText?: string, // text fallback for platforms without card support
  }
}
```

Implementation: write a `messages_out` row with `kind: 'chat-sdk'` and the card structure in content.

#### ask_user_question

Send an interactive question and wait for the user's response. This is a **blocking tool call** — the tool doesn't return until the user responds.

```typescript
{
  name: 'ask_user_question',
  params: {
    title: string,         // short card title, e.g. "Confirm deletion"
    question: string,
    options: (string | { label: string; selectedLabel?: string; value?: string })[],
    timeout?: number,      // seconds (default: 300)
  }
}
```

Implementation:
1. Generate a `questionId` and normalize each option to `{ label, selectedLabel, value }`
2. Write a `messages_out` row with `kind: 'chat-sdk'` and content `{ type: 'ask_question', questionId, title, question, options }`
3. Poll `inbound.db` (read-only) for a pending `messages_in` row whose content carries the matching `questionId` (`findQuestionResponse`), skipping any already in `processing_ack`
4. When found, `markCompleted` the response row (a `processing_ack` write in `outbound.db`) and return its `selectedOption` as the tool result
5. If the deadline passes, return a timeout error as the tool result

The agent's execution is paused at this tool call. The provider's query keeps running (Claude holds the tool call open). The agent-runner polls for the response in a separate loop.

#### edit_message

Edit a previously sent message.

```typescript
{
  name: 'edit_message',
  params: {
    messageId: string,     // integer ID as shown to the agent
    text: string,          // new content
  }
}
```

Implementation: write a `messages_out` row with `operation: 'edit'`, the message ID, and new text.

#### add_reaction

Add an emoji reaction to a message.

```typescript
{
  name: 'add_reaction',
  params: {
    messageId: string,     // integer ID as shown to the agent
    emoji: string,         // emoji name (e.g., 'thumbs_up')
  }
}
```

Implementation: write a `messages_out` row with `operation: 'reaction'`.

#### Agent-to-agent sends (no dedicated tool)

There is no `send_to_agent` tool. Agents and channels share one destination namespace, so
messaging another agent is just `send_message(to="<agent-name>")` where the named
destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row with
`channel_type: 'agent'` and `platform_id` set to the target agent group id; the host
validates the send and routes it into the target session's `inbound.db`.

#### ncl tasks

Schedule, inspect, and modify one-shot or recurring tasks.

```bash
ncl tasks create --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
ncl tasks list
ncl tasks update <series_id> --prompt "..."
ncl tasks cancel <series_id>
```

Implementation: the host writes `messages_in` task rows into the agent group's system session (`thread_id = system:tasks`). The host sweep wakes that system-session container when a task is due. The task agent chooses its destination at fire time by emitting `<message to="name">...</message>` or using `send_message`.

#### create_agent

Create a long-lived companion sub-agent. The `name` becomes a destination the creating
agent can address. (There is no `register_agent_group` tool — this replaced it.)

```typescript
{
  name: 'create_agent',
  params: {
    name: string,           // human-readable name; also the destination name (required)
    instructions?: string,  // CLAUDE.md content for the new agent (role, personality)
  }
}
```

Implementation: fire-and-forget. Writes a `messages_out` row with `kind: 'system'`,
`action: 'create_agent'`, `requestId`, `name`, and `instructions`. The container is
untrusted and does not gate itself; the host authorizes by CLI scope — trusted owner groups
(scope `global`) create directly, confined groups require admin approval
(`src/modules/agent-to-agent/create-agent.ts`) — then creates the entity rows and notifies
the agent via a chat message when the agent is ready.

#### Self-modification: install_packages, add_mcp_server

Two fire-and-forget system-action tools let an agent extend its own runtime (both require
admin approval, applied host-side):

- **`install_packages`** — `{ apt?: string[], npm?: string[], reason?: string }`. Package
  names are validated at the tool boundary and re-validated on the host. On approval the
  host rebuilds the per-agent image and restarts the container.
- **`add_mcp_server`** — `{ name, command, args?, env? }`. Wires an existing third-party MCP
  server into the agent's `container.json`; on approval the host updates the config and
  restarts (no rebuild — Bun runs the TS directly).

Both write a `messages_out` row with `kind: 'system'` and the matching `action`, then return
immediately; the host notifies the agent when approval resolves.

### Media Handling

#### Inbound (messages_in → agent prompt)

The agent-runner inspects attachments in chat/chat-sdk messages and handles them based on type and provider capability:

**Provider-native content blocks:**

| Type | Claude | Codex / OpenCode |
|------|--------|------------------|
| Images (JPEG, PNG, GIF, WebP) | Native image content block | Save to disk |
| PDFs | Native document content block | Save to disk |
| Audio | Native audio content block | Save to disk |
| Other files (code, data, video, archives) | Save to disk | Save to disk |

**"Save to disk"** means: download to `/workspace/downloads/{messageId}/`, reference in the prompt text:

```
<message sender="John" time="10:00">
  Check this spreadsheet
  [file available at: /workspace/downloads/msg-123/data.xlsx]
</message>
```

The agent can use tools (Read, Bash) to access saved files.

For channels where direct download isn't possible (e.g., WhatsApp buffered streams), the channel adapter serves the media via a local URL. The agent-runner downloads from that URL.

**Content block construction (Claude):** The agent-runner builds multi-part `MessageParam` content: `[{ type: 'image', source: { type: 'base64', media_type, data } }, { type: 'text', text: '...' }]`. The prompt passed to the provider is not a plain string in this case — the `QueryInput.prompt` field needs to support structured content for Claude. The provider's `query()` method handles the format-specific construction.

**Content block construction (Codex/OpenCode):** Everything is text. File references are inlined in the prompt string. The provider receives a plain string prompt.

#### Outbound (agent → messages_out)

Handled via the `send_file` MCP tool (see above). The agent explicitly decides to send a file — the agent-runner doesn't scan output for file references.

### Pre-Agent Scripts (Tasks)

For `task` kind messages with a `script` field in the content:

1. Agent-runner writes the script to a temp file
2. Executes with `bash` (30s timeout)
3. Parses last line of stdout as JSON: `{ wakeAgent: boolean, data?: unknown }`
4. If `wakeAgent === false`: mark message as completed, don't invoke the provider
5. If `wakeAgent === true`: enrich the prompt with script output, then invoke the provider

### Transcript Archiving

The agent-runner archives conversation transcripts before context compaction. For Claude, this is handled via the PreCompact hook (provider-internal). For other providers that don't have hooks, the agent-runner archives after each query completes based on the provider's output.

Archive location: `/workspace/agent/conversations/{date}-{summary}.md`

### Session Resume

The agent-runner tracks a single opaque `continuation` token per provider:

- Captured from `ProviderEvent { type: 'init', continuation }` and persisted to the
  `session_state` table in `outbound.db` under the key `continuation:` (keyed per
  provider because a continuation is provider-private — a Claude session id is meaningless to
  another provider).
- Passed back as `QueryInput.continuation` on the next query. For Claude that becomes the
  SDK `resume` option; the SDK reloads its on-disk `.jsonl` transcript for that session id.

Because it lives in the session folder's `outbound.db`, the continuation survives container
teardown and restart — a fresh container reads it back and resumes. `/clear` deletes the row
to start a clean session. Before resuming, `maybeRotateContinuation` may archive and drop an
oversized/aged transcript (so a cold container isn't killed reloading it), and
`isSessionInvalid` clears a continuation whose backing transcript has gone missing.

### Container Startup

The agent-runner receives configuration via:

- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
- **Fixed mount paths:** Host-written `inbound.db` (read-only) at `/workspace/inbound.db` and container-owned `outbound.db` at `/workspace/outbound.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.

The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.

### Provider Factory

```typescript
type ProviderName = 'claude' | string;

function createProvider(name: ProviderName, config: ProviderConfig): AgentProvider {
  // Trunk registers 'claude'; additional providers self-register when installed via skills.
  const factory = providerRegistry.get(name);
  if (!factory) throw new Error(`Unknown provider: ${name}`);
  return factory(config);
}
```

The provider name comes from the `provider` key in `/workspace/agent/container.json` (defaulting to `'claude'`), which the host materializes from the `container_configs` table — set it with `ncl groups config update --provider`. It is not an environment variable.

`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`.

## Agent-Runner Properties

- MCP servers are local processes or remote Streamable HTTP endpoints managed by the provider via `mcpServers`
- The MCP server binary is shared across providers — same tools, same DB access
- CLAUDE.md loading (global + per-group) — agent-runner reads and passes as `systemPrompt`
- Additional directories discovery (`/workspace/extra/*`)
- Logging via stderr (`[agent-runner] ...`)

## Related Documents

- **[architecture.md](architecture.md)** — High-level architecture (session DB schema, central DB, channel adapters, message flow)
- **[api-details.md](api-details.md)** — Channel adapter interface, message content examples, host delivery logic

---

## File: docs/api-details.md

# NanoClaw API Details

Implementation-level details for the architecture. See [architecture.md](architecture.md) for the high-level design.

## Channel Adapter Interface

### NanoClaw Channel Interface

```typescript
interface ChannelSetup {
  // Conversation configs from central DB — passed at setup, not queried by adapter
  conversations: ConversationConfig[];

  // Host callbacks
  onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
  // Admin-transport adapters (e.g. CLI) route to an arbitrary channel via this instead of onInbound
  onInboundEvent(event: InboundEvent): void;
  onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
}

interface ConversationConfig {
  platformId: string;
  agentGroupId: string;
  triggerPattern?: string;       // regex string (for native channels)
  requiresTrigger: boolean;
  sessionMode: 'shared' | 'per-thread';
}

interface ChannelAdapter {
  name: string;
  channelType: string;

  // Lifecycle
  setup(config: ChannelSetup): Promise<void>;
  teardown(): Promise<void>;
  isConnected(): boolean;

  // Outbound delivery
  deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;

  // Optional
  setTyping?(platformId: string, threadId: string | null): Promise<void>;
  syncConversations?(): Promise<ConversationInfo[]>;
  updateConversations?(conversations: ConversationConfig[]): void;
}

// Inbound message from adapter to host
interface InboundMessage {
  id: string;
  kind: 'chat' | 'chat-sdk';
  content: unknown;       // JSON blob — NanoClaw chat format or Chat SDK SerializedMessage
  timestamp: string;
}

// Outbound message from host to adapter
interface OutboundMessage {
  kind: 'chat' | 'chat-sdk';
  content: unknown;       // JSON blob — matches the kind
}
```

### Channel Defaults

Each adapter can declare static wiring-time defaults. There are exactly two levels: the adapter declaration, and the per-wiring/per-messaging-group values chosen at creation. There is no DB config table for defaults — install-wide changes mean editing the adapter copy (skill-installed, user-owned).

```typescript
// src/channels/adapter.ts
interface ChannelContextDefaults {
  engageMode: 'pattern' | 'mention' | 'mention-sticky';
  engagePattern?: string;   // required iff engageMode='pattern'; may contain the
                            // literal token {name} — creation helpers substitute the
                            // regex-escaped agent_group name
  threads: boolean;         // whether thread ids are honored in this context by default;
                            // must be false when the adapter's supportsThreads is false
  unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
}

interface ChannelDefaults {
  dm: ChannelContextDefaults;
  group: ChannelContextDefaults;
  mentions: 'platform' | 'dm-only' | 'never';
  // 'platform' — platform-confirmed mentions in groups, DMs flagged too
  // 'dm-only'  — only DMs flagged (no group mention metadata)
  // 'never'    — isMention never set: no auto-create card, mention wirings never engage
}

// ChannelAdapter and ChannelRegistration both carry an optional `defaults` field.
// The registration-level copy lets offline creation paths (setup/register.ts,
// scripts/init-first-agent.ts) resolve declarations without instantiating the
// adapter. ChatSdkBridgeConfig.defaults is copied verbatim onto the bridged
// adapter, like supportsThreads.
```

**Resolution chain** (`getChannelDefaults(key, channelType?)` in `src/channels/channel-registry.ts`, key = `mg.instance ?? mg.channel_type`, same discipline as `getChannelAdapter`):

1. Live adapter's `defaults`, instance-exact (lets an instance carry env-computed declarations, e.g. WhatsApp shared-number mode)
2. Live adapter of that channelType
3. Registration entry under the key
4. Registration entry under the channelType (from the live adapter's channelType, else the caller-supplied hint)
5. `fallbackChannelDefaults(supportsThreads)` — behavior-faithful to the pre-declaration router: dm `{ pattern '.', threads: supportsThreads, request_approval }`, group `{ mention-sticky, threads: supportsThreads, request_approval }`, mentions `'platform'`. `supportsThreads` is `false` when no adapter is live.

Never returns undefined. `hasDeclaredChannelDefaults()` reports whether tiers 1–4 hit; manual creation surfaces (`ncl`) gate declaration-derived defaults on it so stale (undeclared) adapters keep the legacy static schema defaults — a trunk update alone changes no behavior.

**Creation helpers** (`src/channels/channel-defaults.ts`): every wiring-creation path calls `resolveWiringDefaults(channelKey, isGroup, agentGroupName)` — it picks `decl.group` vs `decl.dm` by `isGroup = event.message.isGroup ?? (mg.is_group === 1)` (never `threadId !== null`), substitutes `{name}`, and downgrades `mention-sticky` → `mention` when the context's resolved threads value is false. `resolveUnknownSenderPolicy` does the same for auto-created messaging_groups rows.

**Runtime thread policy**: engage mode and sender policy are creation-time snapshots; threading is the one setting consulted live. `messaging_group_agents.threads` (migration 019) is the per-wiring override: `NULL` = inherit the adapter declaration for the wiring's context, `1`/`0` = explicit. `resolveThreadPolicy(wiring.threads, decl, isGroup, supportsThreads)` hard-ANDs the result with the adapter's raw capability — a wiring can opt out of threads on a threaded platform, never opt in on a non-threaded one. When the policy is off, event-derived thread ids are nulled at router fanout (sessions collapse, replies land top-level); `event.replyTo` is operator intent from the CLI transport and is never nulled.

### Chat SDK Bridge

Wraps a Chat SDK adapter + Chat instance to conform to the NanoClaw ChannelAdapter interface. Trunk ships the bridge and the channel registry only — platform-specific Chat SDK adapters (Discord, Slack, Telegram, etc.) and native adapters (WhatsApp/Baileys) are installed by the `/add-<channel>` skills from the `channels` branch.

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Native NanoClaw Channel (no Chat SDK)

Native channels implement the ChannelAdapter interface directly. The WhatsApp/Baileys adapter is the canonical example — it ships via the `/add-whatsapp` skill, not in trunk:

```typescript
function createWhatsAppChannel(): ChannelAdapter {
  let socket: WASocket;
  let config: ChannelSetup;

  return {
    name: 'whatsapp',
    channelType: 'whatsapp',

    async setup(setup) {
      config = setup;
      socket = await connectBaileys();

      socket.on('messages.upsert', (event) => {
        for (const msg of event.messages) {
          const jid = msg.key.remoteJid;
          const conv = config.conversations.find(c => c.platformId === jid);

          // Trigger check (native — adapter does this, not host)
          if (conv?.requiresTrigger && conv.triggerPattern) {
            if (!new RegExp(conv.triggerPattern).test(msg.message?.conversation || '')) {
              return; // Doesn't match trigger
            }
          }

          config.onInbound(jid, null, {
            id: msg.key.id,
            kind: 'chat',
            content: {
              sender: msg.pushName || msg.key.participant,
              senderId: msg.key.participant || msg.key.remoteJid,
              text: msg.message?.conversation || '',
              attachments: [],
              isFromMe: msg.key.fromMe,
            },
            timestamp: new Date(msg.messageTimestamp * 1000).toISOString(),
          });
        }
      });
    },

    async deliver(platformId, threadId, message) {
      const content = message.content as { text: string };
      await socket.sendMessage(platformId, { text: content.text });
    },

    async setTyping(platformId) {
      await socket.sendPresenceUpdate('composing', platformId);
    },

    async teardown() {
      await socket.logout();
    },

    isConnected() { return !!socket; },
  };
}
```

## Session DB Schema Details

### messages_in content examples

**`chat`** — simple NanoClaw format:
```json
{
  "sender": "John",
  "senderId": "user123",
  "text": "Check this PR",
  "attachments": [{ "type": "image", "url": "https://signed-url..." }],
  "isFromMe": false
}
```

**`chat-sdk`** — full Chat SDK `SerializedMessage`:
```json
{
  "_type": "chat:Message",
  "id": "msg-1",
  "threadId": "slack:C123:1234.5678",
  "text": "Check this PR",
  "formatted": { "type": "root", "children": [...] },
  "author": { "userId": "U123", "userName": "john", "fullName": "John", "isBot": false, "isMe": false },
  "metadata": { "dateSent": "2024-01-01T00:00:00Z", "edited": false },
  "attachments": [{ "type": "image", "url": "https://...", "name": "screenshot.png" }],
  "isMention": true,
  "links": []
}
```

**Question response** (from user clicking an interactive card):
```json
{
  "sender": "John",
  "senderId": "user123",
  "text": "Yes",
  "questionId": "q-123",
  "selectedOption": "Yes",
  "isFromMe": false
}
```

### messages_out content examples

**Normal chat message:**
```json
{ "text": "LGTM, merging now" }
```

**Chat SDK markdown:**
```json
{ "markdown": "## Review Summary\n**Status**: Approved\n\nNo issues found." }
```

**Card:**
```json
{
  "card": {
    "type": "card",
    "title": "Deployment Approval",
    "children": [
      { "type": "text", "content": "Deploy 2.1.0 to production?" },
      { "type": "actions", "children": [
        { "type": "button", "id": "approve", "label": "Approve", "style": "primary" },
        { "type": "button", "id": "reject", "label": "Reject", "style": "danger" }
      ]}
    ]
  },
  "fallbackText": "Deployment Approval: Deploy 2.1.0 to production? [Approve] [Reject]"
}
```

**Ask user question:**
```json
{
  "type": "ask_question",
  "questionId": "q-123",
  "title": "Failing Test",
  "question": "How should we handle the failing test?",
  "options": [
    "Skip it",
    { "label": "Fix and retry", "selectedLabel": "✅ Fixing", "value": "fix" },
    { "label": "Abort deployment", "selectedLabel": "❌ Aborted", "value": "abort" }
  ]
}
```

**Edit message:**
```json
{ "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments on line 42" }
```

**Reaction:**
```json
{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" }
```

**System action:**
```json
{ "action": "reset_session", "payload": { "session_id": "sess-123", "reason": "Skills updated" } }
```

## Host Delivery Logic

The host reads messages_out and dispatches based on `kind` and `operation`:

```typescript
async function deliverMessage(row: MessagesOutRow, adapter: ChannelAdapter) {
  const content = JSON.parse(row.content);

  // System actions — host handles internally
  if (row.kind === 'system') {
    await handleSystemAction(content);
    return;
  }

  // Agent-to-agent — write to target session DB
  if (isAgentDestination(row)) {
    await writeToAgentSession(row);
    return;
  }

  // Channel delivery — delegate to adapter
  await adapter.deliver(row.platform_id, row.thread_id, {
    kind: row.kind,
    content,
  });
}
```

The adapter's `deliver()` method handles operation dispatch internally (post vs edit vs reaction).

---

## File: docs/architecture.md

# NanoClaw Architecture (Draft)

> **Draft — design intent, not a line-by-line spec.** Some passages predate the current implementation and can drift from it. The root [CLAUDE.md](../CLAUDE.md) and the cited source files (`src/`, `container/agent-runner/src/`) are the source of truth; when this doc and the code disagree, trust the code. Notably, scheduling MCP tools do **not** write `inbound.db` directly — they emit `messages_out` system actions that the host applies (see [agent-runner-details.md](agent-runner-details.md) and `src/modules/scheduling/`).

## Core Idea

Each agent session has a **pair** of mounted SQLite DBs. They are the one and only IO
mechanism between host and container. No IPC files, no stdin piping. `inbound.db` carries
host → agent-runner messages (`messages_in`); `outbound.db` carries agent-runner → host
messages (`messages_out`) plus the container's processing acks. Everything is a message.

The split exists so each file has exactly one writer: the host writes `inbound.db` (the
container opens it read-only) and the container writes `outbound.db` (the host opens it
read-only). One writer per file means no cross-process lock contention over the
host↔container mount. Both files run `journal_mode=DELETE`, **not** WAL: WAL's memory-mapped
`-shm` coherency does not propagate across VirtioFS, so a WAL reader in the guest would
freeze on an early snapshot and never see new host writes.

## Two-Level DB

**Central DB (host process):**
- Agent groups, conversations, routing tables
- Maps platform IDs → agent groups → sessions
- Channel adapters don't touch this directly — the host does the lookup

**Per-session DBs (mounted into container):**
- `inbound.db` → `messages_in` (written by host, read-only in container) plus host-written
  lookup tables the container reads live: `destinations`, `session_routing`, `delivered`
- `outbound.db` → `messages_out` (written by agent-runner, read by host) plus
  `processing_ack`, `session_state`, and `container_state` (all container-owned)
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use
  `messages_in` / `messages_out`
- One pair per session, not per agent group

## Agent Groups vs Sessions

An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own `inbound.db`/`outbound.db` pair mounted at known paths. Each session = a separate container with the same agent group's filesystem but a different DB pair.

## Message Flow

```
Platform event
  → Channel adapter (trigger check, ID extraction)
  → Returns: { platformChannelId, platformThreadId, triggered }
  → Host maps platformChannelId + platformThreadId → agent group + session
  → Host writes messages_in row to the session's inbound.db
  → Host calls wakeUpAgent(session)
  → Container spins up (or is already running)
  → Agent-runner polls inbound.db (read-only), finds new messages
  → Agent-runner processes with the configured provider
  → Agent-runner writes response to messages_out in outbound.db
  → Host polls active sessions' outbound.db for responses
  → Host reads response, looks up conversation, delivers through channel adapter
```

## Channel Adapters

Channel adapters are responsible for:
1. Receiving platform events (webhooks, polling, websockets — platform-specific)
2. **Filtering**: deciding which messages to forward to the host for processing. This can be stateless (regex trigger match) or stateful (e.g., "was the bot mentioned in this thread at some point? If so, forward all subsequent messages"). The adapter receives a stream of unfiltered platform messages and decides which ones to pass on. How it decides is an implementation detail — NanoClaw doesn't know or care.
3. Extracting and standardizing two IDs:
   - **Platform channel ID** — identifies the conversation (WhatsApp group, Slack channel, email thread)
   - **Platform thread ID** — optional sub-context (Slack thread, GitHub PR comment thread)
4. Outbound delivery — sending responses back to the platform

The channel adapter does NOT know about agent group IDs or session IDs. It returns platform-level identifiers. The host maps those to the entity model.

The two-level ID scheme (channel ID + thread ID) gives flexibility:
- Want every Slack thread to be a separate session? Return unique thread IDs.
- Want all messages in a Slack channel to share a session? Return the same thread ID (or null).
- This is configured per-channel, not globally.

### Channel Adapter Configuration

Adapters are stateless — they receive config from the host at setup time, not from the DB directly.

**What lives in code (per channel type, doesn't change at runtime):**
- Auto-registration behavior (enabled/disabled, how it works)
- Sender allowlist rules
- Whether allowlisted senders can auto-register groups
- Platform-specific connection and message handling

These are decisions made when setting up the channel adapter. Change them = change the code.

**What lives in the DB (per group, varies group to group):**
- Which agent group handles it
- Trigger / filter rules (regex, @mention-only, exclude certain senders, etc.)
- Response scope (respond to all messages vs only triggered/allowlisted)
- Session mode (shared vs per-thread)

The host reads per-group config from the DB and passes it to the adapter at setup. If config changes at runtime (admin agent registers a new group, changes a trigger), the host calls the adapter's update method.

### Auto-Registration

When the adapter forwards a message from an unknown group, the host needs to decide whether to create the group and a session for it.

**The adapter controls whether to forward unknown messages** — based on its code-level auto-registration rules (sender allowlist, group-add detection, etc.). If the adapter forwards it, the host creates the group + session.

**Session creation for known groups:**
- Shared session mode: host finds the existing session or creates one if it's the first message
- Per-thread session mode: host looks up by threadId. If no session exists for this thread, auto-creates one with the same agent group

**The code-level rules are channel-specific:**
- WhatsApp: if an allowlisted number adds the bot to a group → auto-register. If an unknown number DMs → depends on the adapter's configuration.
- Email: if the sender is known → auto-register the thread. If unknown → drop.
- Slack: if someone @mentions the bot in a new channel → adapter decides whether to forward based on its rules.

No `channel_configs` table — channel-type-level behavior is baked into the adapter code.

### Chat SDK Integration

Chat SDK adapters are wrapped per-channel:
- Each Chat SDK adapter gets its own Chat instance
- Concurrency mode is configured per-channel (concurrent for chat, queue for tasks, debounce for webhooks)
- A bridge wraps the Chat instance + adapter to conform to NanoClaw's standard channel interface
- Chat SDK handles: webhook parsing, dedup, message history, platform API calls, rich content delivery
- NanoClaw handles: routing, agent lifecycle, session management

**Chat SDK's subscription model:**

Chat SDK has its own thread-level subscription concept (distinct from NanoClaw's channel-level registration):
- `onNewMention` / `onNewMessage(regex)` — fires on first contact (e.g., @mention in a Slack thread)
- `thread.subscribe()` — opts into all future messages in that thread
- `onSubscribedMessage` — fires for all messages in subscribed threads

This is sub-channel granularity. NanoClaw registers at the channel level ("listen to this Discord channel"). Chat SDK subscribes at the thread level ("track this specific Slack thread"). The bridge lets Chat SDK manage its own subscriptions internally — NanoClaw doesn't interfere with or replicate this.

**Platform capability differences:**

Capabilities vary significantly across adapters (see [Chat SDK adapter docs](https://chat-sdk.dev/docs/adapters)):
- **Slack**: Full rich content (Block Kit cards, modals, streaming, reactions, ephemeral messages)
- **Discord**: Embeds, buttons, streaming via post+edit
- **WhatsApp (Cloud API)**: DMs only, interactive reply buttons, no streaming, no reactions
- **GitHub/Linear**: Markdown comments, no interactive elements
- **Telegram**: Inline keyboard buttons, streaming via post+edit

The host/bridge handles graceful degradation — if an agent posts a card on a platform that doesn't support cards, it falls back to text.

Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) implement the NanoClaw channel interface directly — no bridge, no Chat SDK types.

## Container Lifecycle

The host is an orchestrator:
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
2. **Idle kill** — when a container has no unprocessed messages for some timeout period

When a container spins up, the agent-runner immediately starts polling `inbound.db`. Messages are already there waiting.

## Media Handling

### Inbound

Media is not downloaded by the host. Instead:
- Messages include download URLs (signed URLs where possible)
- Agent-runner downloads and processes media inside the container
- For channels where signed URLs don't work (e.g., WhatsApp with buffered streams), the channel adapter downloads the media and serves it via a local URL/server that the container can access

**Native content blocks (provider-dependent):**

The agent-runner detects file types and passes supported types as native content blocks where the provider supports it:

| Type | Claude | Codex | OpenCode |
|------|--------|-------|----------|
| Images (JPEG, PNG, GIF, WebP) | Native image content block | Save to disk, reference in prompt | Save to disk, reference in prompt |
| PDFs | Native document content block | Save to disk | Save to disk |
| Audio | Native audio content block | Save to disk | Save to disk |
| Other files (code, data, video, archives) | Save to disk | Save to disk | Save to disk |

"Save to disk" means downloaded to `/workspace/downloads/{messageId}/` and referenced in the prompt text as an available file path. The agent can use tools (Read, Bash) to access it.

The agent-runner builds the prompt differently per provider. For Claude, it constructs multi-part `MessageParam` content with image/document blocks. For Codex/OpenCode, everything is text with file path references.

### Outbound

Outbound file delivery is tool-based. The agent calls a tool (e.g., `send_file`) with a file path. The agent-runner moves the file to the outbox and writes the messages_out row.

```
/workspace/
  outbox/
    {message_id}/        ← one dir per messages_out row
      chart.png
      report.pdf
```

messages_out content references filenames only:

```json
{ "text": "Here's the chart", "files": ["chart.png", "report.pdf"] }
```

No paths in the DB — the convention is the contract. The host reads files from `outbox/{message_id}/` in the mounted session folder and delivers them via the adapter (Chat SDK `FileUpload` with buffer data, or platform-specific upload for native channels). Host cleans up the outbox directory after successful delivery.

Outbound files use a dedicated `send_file` MCP tool (separate from `send_message`). See [agent-runner-details.md](agent-runner-details.md) for the tool interface.

### Message Deduplication

Dedup is the channel adapter's responsibility. Chat SDK handles this internally. Native adapters track platform message IDs as needed. The host does not deduplicate — if the adapter forwards it, the host writes it.

## Session DB Schema

Split across the two files. JSON blobs for content — schema-free, format varies by `kind`.
`seq` is a global ordering counter with a **disjoint parity**: the host writes even seqs to
`messages_in`, the container writes odd seqs to `messages_out`. Each side reads the other's
MAX(seq) to pick its next value, so seq is a single monotonic message id across both tables —
which is why the agent-facing message id it returns from `send_message` (and accepts in
`edit_message` / `add_reaction`) is unambiguous.

```
/* Detailed source-code truncated for AI context efficiency. */
```

Delivery is tracked host-side in a `delivered` table in `inbound.db` (not a column on
`messages_out`, which the host can't write). `inbound.db` also holds the host-written
`destinations` and `session_routing` tables the container reads live; `outbound.db` also
holds `session_state` (the resume continuation, per provider) and `container_state`
(current tool in flight).

### Scheduling

One-shot and recurring tasks use the same tables — no separate scheduler.

**One-shot:** `process_after` (inbound) or `deliver_after` (outbound) with `recurrence = NULL`.

**Recurring:** Same, plus a `recurrence` cron expression. After the host marks a row as handled/delivered, if `recurrence` is set, it inserts a new row with `process_after`/`deliver_after` advanced to the next cron occurrence. Next time is computed from the scheduled time (not wall clock) to prevent drift.

**Host sweep** (every ~60s across all sessions):
- `inbound.db` → `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
- A `processing_ack` (in `outbound.db`) whose claim, or the `.heartbeat` mtime, is older than the stale threshold → stale detection, increment tries, reschedule `process_after` with backoff
- `outbound.db` → due `messages_out` rows not yet in the host's `delivered` table (in `inbound.db`) → deliver
- After completing/delivering a row with `recurrence`, insert next occurrence

**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.

**Agent-runner creates schedules** by emitting a `messages_out` row with `kind: 'system'` and an `action` (`schedule_task`, `cancel_task`, …) — it cannot write host-owned `inbound.db` directly. The host applies the action during delivery (`src/modules/scheduling/actions.ts`), inserting/updating the `kind: 'task'` `messages_in` row with `process_after` and optionally `recurrence`.

### messages_in content by kind

**`chat`** — simple NanoClaw format. Any channel can produce this.
```json
{
  "sender": "John",
  "senderId": "user123",
  "text": "Check this PR",
  "attachments": [{ "type": "image", "url": "https://signed-url..." }],
  "isFromMe": false
}
```

**`chat-sdk`** — full Chat SDK `SerializedMessage`, passed through from bridge adapter. Includes `author`, `text`, `formatted` (mdast AST), `attachments`, `isMention`, `links`, `metadata`.

**`task`** — scheduled task firing.
```json
{ "prompt": "Review open PRs", "script": "scripts/review.sh" }
```

**`webhook`** — raw webhook payload.
```json
{ "source": "github", "event": "pull_request", "payload": { ... } }
```

**`system`** — host action result (response to a system action the agent requested).
```json
{ "action": "register_group", "status": "success", "result": { "agent_group_id": "ag-456" } }
```

### messages_out content by kind

Output `kind` determines the format and delivery adapter. Default: agent-runner copies `kind` and routing fields from the messages_in row it's responding to.

**`chat`** — simple NanoClaw format. NanoClaw channel delivers via `sendMessage(text)`.
```json
{ "text": "LGTM, merging now" }
```

**`chat-sdk`** — Chat SDK `AdapterPostableMessage`. Bridge adapter delivers via `thread.post()`. Can be markdown, card, or raw — adapter handles platform conversion.
```json
{ "markdown": "## Review\n**LGTM**", "attachments": [...] }
```
```json
{ "card": { "type": "card", "title": "Review", "children": [...] }, "fallbackText": "..." }
```

**`task`** — task result. Host logs and optionally notifies.
```json
{ "result": "3 PRs reviewed", "status": "success" }
```

**`webhook`** — webhook response. Host sends HTTP response or notifies.
```json
{ "response": { "status": 200, "body": { ... } } }
```

**`system`** — host action request (register group, reset session, etc.). Host reads, validates permissions, executes, writes result back as a `system` messages_in row.
```json
{ "action": "reset_session", "payload": { "session_id": "sess-123" } }
```

### Interactive Operations (Cards, Reactions, Edits)

All interactive operations flow through messages_in/out — the DB is the only IO boundary for the container. The agent uses MCP tools; the agent-runner translates tool calls into structured messages_out rows; the host delivers through the appropriate adapter method.

**Cards with user interaction (e.g., "Ask User Question"):**

1. Agent calls `ask_user_question` tool with question + options
2. Agent-runner writes messages_out with the question card
3. Host delivers as interactive card through adapter (e.g., Slack Block Kit buttons)
4. User clicks an option
5. Platform sends event back to adapter → host writes messages_in with the response
6. Agent-runner reads messages_in, matches to pending tool call, returns selection to agent as tool result

The agent-runner holds the tool call open while waiting for the user's response in messages_in. The round-trip goes: agent → messages_out → host → platform → user clicks → platform → host → messages_in → agent-runner → agent.

**Approvals:**

Two patterns, both handled at the host level:
- **Implicit**: Agent calls a tool that requires approval. Host intercepts, sends approval card to admin, waits for response, then executes or rejects. The agent doesn't know about the approval step.
- **Explicit**: Agent explicitly requests approval via a tool. Agent-runner writes the approval request to messages_out. Same flow as "ask user question" — response comes back through messages_in.

In both cases, the approval and action execution happen on the host side, not the agent side.

**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/modules/permissions/access.ts` and `src/modules/permissions/user-dm.ts` (`ensureUserDm`); the approver-picking primitives live in `src/modules/approvals/primitive.ts`.

**Editing a sent message:**

Agent calls an `edit_message` tool with the message ID and new content. Agent-runner writes messages_out with an edit operation. Host calls `adapter.editMessage()`. Messages in the agent's context include integer IDs so the agent can reference them.

**Reactions:**

Agent calls `add_reaction` tool with message ID and emoji. Agent-runner writes messages_out with a reaction operation. Host calls `adapter.addReaction()`.

**Operations in messages_out content:**

```json
// Normal message (default)
{ "text": "LGTM" }

// Interactive card
{ "operation": "ask_question", "title": "Deploy", "question": "Approve deployment?", "options": ["Yes", "No", "Defer"] }

// Edit existing message
{ "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments" }

// Reaction
{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" }
```

The host reads the `operation` field (if present) and calls the right adapter method. No operation field = normal message delivery. Platform capabilities vary — the host/bridge handles graceful degradation (e.g., reaction on a platform that doesn't support it → skip or send as text).

### Agent-to-Agent Communication

Sending a message to another agent uses the same routing fields as channel delivery. The agent-runner sets `channel_type: 'agent'` and `platform_id` to the target agent group ID. Optionally, `thread_id` can target a specific session (null = find or create the default session).

From the sending agent's perspective, it's the same mechanism as sending to Slack or WhatsApp — just a messages_out row with different routing. The host reads it, checks that this agent group has permission to message the target, resolves the target session, and writes a messages_in row to that session's DB.

```json
// messages_out routing fields
{ "kind": "chat", "channel_type": "agent", "platform_id": "pr-worker", "thread_id": null }
// messages_out content
{ "text": "Reset your session and re-review", "sender": "Supervisor", "senderId": "agent:pr-admin" }
```

The receiving agent gets a normal chat message. It doesn't need to know the source is another agent unless that's relevant context.

### Routing

**Default behavior:** Agent-runner copies routing fields (`kind`, `platform_id`, `channel_type`, `thread_id`) from the messages_in row to messages_out. Response goes back where it came from.

**Host validation:** Before delivering, the host checks that this agent group is permitted to send to the destination. The agent-runner copies routing; the host validates.

**Multi-destination pattern (customization):** An agent may need to send to a different channel than the origin (e.g., a webhook triggers a Slack notification). This is supported via custom code, not built into the core:

1. Add a `destinations` table to the session DB mapping logical names to routing fields
2. Populate it from the host when setting up the session
3. Modify the agent's prompt to list available destinations
4. Agent chooses a destination by name; agent-runner resolves to routing fields
5. Host validates as usual

This is documented as a pattern, not a built-in feature.

## Core Properties
- Container isolation via filesystem mounts
- Credential proxy (OneCLI)
- Per-agent-group workspace (folder, CLAUDE.md, skills)
- Polling-based (not event-driven)
- Per-agent-group agent-runner recompilation on container startup (agent can modify its own source, request rebuild/restart, changes persist across teardowns)
- Host ↔ container IO through mounted session DBs (`messages_in` / `messages_out`) — no stdin piping, no IPC files
- Agent commands are `messages_out` rows with `kind: 'system'`
- Agent-to-agent supported via target-agent routing on `messages_out`
- Scheduling uses `process_after` / `deliver_after` + `recurrence` on the same message tables
- Media via signed URLs, downloaded in the container
- Channel adapters use the Chat SDK bridge + a standard interface (trunk ships only the bridge/registry; platform adapters install via `/add-<channel>` skills)
- Routing: channel adapter extracts IDs, host maps to entities
- Concurrency: Chat SDK per-channel + container limits
- Session scoping: per-session DB, multiple sessions per agent group

## Design Decisions

**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `inbound.db`, `outbound.db`, and — when using the claude provider — the SDK's `.claude/` directory. The session identity IS the folder. The SDK's resume token (session id) is persisted in `outbound.db`'s `session_state` table, so a fresh container picks the conversation back up without the host tracking it centrally.

**Container mount structure:**

```
/workspace/                 ← mount: session folder (read-write)
  .claude/                  ← Claude SDK session data / transcripts (auto-created)
  inbound.db                ← host writes, container reads (opened read-only)
  outbound.db               ← container writes, host reads (opened read-only)
  .heartbeat                ← container touches this; host watches its mtime
  outbox/                   ← agent-runner writes outbound files here
  agent/                    ← mount: agent group folder (nested, read-write)
    CLAUDE.md               ← agent instructions
    skills/                 ← agent skills
    ... working files
```

Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace).

The runtime is Docker (`src/container-runtime.ts` hardcodes the `docker` binary); nested bind mounts make this layout straightforward. The layout deliberately sticks to directory mounts (no file-level mounts) so it stays portable to runtimes that only support directory mounts.

**Cross-mount DB access:** The two files exist precisely so each has a single writer — the
host writes `inbound.db`, the container writes `outbound.db` — which removes writer
contention across the mount. Both files use `journal_mode=DELETE`, **not** WAL: WAL keeps its
index in a memory-mapped `-shm` file, and VirtioFS does not propagate that mmap coherency
from host to guest, so a WAL reader in the container would freeze on an early snapshot and
silently never see new host writes. Readers that must see fresh host writes promptly (the
`messages_in` poll) open `inbound.db` with `mmap_size = 0` to bypass SQLite's page cache.

**Session management:** Host-managed. The host creates session folders and mounts them. The container only sees its own session folder.

**Session creation (no race condition):**

1. Message arrives, host checks central DB for a session matching this group + thread
2. No session exists → host atomically creates session row in central DB, creates the session folder, creates the session DB, writes the message
3. More messages arrive before container starts → host finds the existing session, writes to the same session DB
4. Container starts, mounts the folder, agent-runner finds messages waiting

The central DB session row creation is the serialization point. No provider session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.

**System actions:** The agent uses MCP tools (register group, reset session, schedule task, etc.). The agent-runner handles these tool calls and writes a structured, deterministic messages_out row with `kind: 'system'`. This is not natural language — it's a programmatic, structured payload that the host processes deterministically. Host validates permissions, executes, and writes the result back as a `system` messages_in row.

**Container lifecycle:** No warm pool. Containers are spawned on demand (wakeUpAgent) and torn down from the outside by the host when idle. Existing idle detection + teardown mechanism carries over.

## Operational Behavior

### Output Delivery

NanoClaw does not stream tokens to users. The provider's query interface yields complete results, but a result's text is not delivered as-is: the agent-runner parses it for `<message to="name">...</message>` blocks (`dispatchResultText` in poll-loop.ts) and writes one messages_out row per block, addressed to that destination with its thread context resolved per destination. Everything outside a block — including `...` — is scratchpad: logged, never sent. A block naming an unknown destination is dropped into the scratchpad log.

If a result produced text but no valid block, the agent-runner pushes a one-time `<system>` nudge into the live turn asking the agent to re-wrap its response. The exception is a non-retryable error result (e.g. a billing error) with no envelope, which is delivered as an error notice instead of being dropped as scratchpad. Mid-turn interim updates go out through the `send_message` MCP tool; the final-text envelope parsing is how a turn's reply reaches the user. The host delivers complete messages_out rows to channels.

Message editing is supported as an explicit operation (agent calls an `edit_message` tool), not as a streaming mechanism.

Typing indicators: host sets typing when a container is active for a session, clears when the container exits or a response appears in messages_out.

### Message Batching

When multiple messages arrive while the container is down, they accumulate as `status = 'pending'` rows in `messages_in`. When the container wakes up, the agent-runner reads all pending messages (those not yet in `processing_ack`) and processes them as a batch — formatted as a `<context timezone="…" />` header followed by the messages concatenated as consecutive `<message>` blocks. (There is no `<messages>` wrapper element; see [agent-runner-details.md](agent-runner-details.md#message-formatting).)

### Message Lifecycle

```
messages_in.status:   pending ──────────► completed (mirrored from ack)
                              └─────────► failed (host-set, retries exhausted)
processing_ack.status:         processing → completed
```

Because `inbound.db` is read-only in the container, the agent-runner never mutates
`messages_in.status`. It records lifecycle in `processing_ack` (in `outbound.db`); the host
reads that and mirrors completion back.

- **pending**: Host writes the `messages_in` row. Ready to be picked up (if `process_after` is null or past).
- **processing**: Agent-runner upserts a `processing_ack` row (`status = 'processing'`) when it claims the message. Subsequent polls skip any id already in `processing_ack`, so it isn't re-picked.
- **completed**: Agent-runner sets `processing_ack.status = 'completed'` for **every** consumed batch, error outcomes included — a provider error is surfaced to the user as an error chat message in `messages_out`, then the batch is still acked completed. The host's `syncProcessingAcks` copies it onto `messages_in.status`.
- **failed**: Set by the **host** (sweep's `markMessageFailed`) when retries are exhausted — never by the container.

**Liveness / stale detection**: The container touches a `/workspace/.heartbeat` file rather
than writing the DB. The host sweep watches that mtime (widening its tolerance when
`container_state` shows a long-declared Bash running) to decide a container has crashed, then
increments `tries` and reschedules `process_after` with exponential backoff. On the next
container startup, leftover `processing` acks are cleared so orphaned claims re-process.

### Error Handling and Retries

Retries use `process_after` with exponential backoff. Each retry increments `tries` and pushes `process_after` further out:

- Try 1: immediate
- Try 2: +5s
- Try 3: +10s
- Try 4: +20s
- Try 5: +40s
- After max retries: status set to `failed`

The host computes this — not the agent-runner. When the host detects a stale `processing` message or the container exits with an error, it increments `tries`, computes the next `process_after`, and resets status to `pending`.

**Output-sent protection**: If messages_out already has delivered rows for a batch, don't retry (prevents duplicate messages to user).

### Host Polling

Two tiers:
- **Active containers (~1s)**: Poll session DBs for new messages_out rows to deliver
- **All sessions (~60s)**: Sweep all session DBs for due `process_after` / `deliver_after` timestamps, handle recurrence

## Flexibility Model

The architecture is **flexible for code changes, not configurable for everything**. Advanced setups (like the PR Factory below) use custom routing logic and host-side hooks — not database config columns.

### Code Structure for Skill Customization

NanoClaw is customized via skills — branches that get merged into the user's installation. Different skills add different capabilities (channels, integrations, behaviors). The code must be structured so that:

1. **Different customizations don't conflict.** Adding Slack and adding Telegram should not produce merge conflicts. Adding a new MCP tool should not conflict with adding a channel. Each type of customization should touch its own file(s).

2. **Core blocks of functionality are in separate files.** Channel registration, message formatting, MCP tools, routing logic, container management — each in its own file. A skill that changes how messages are formatted doesn't touch the file that handles container spawning.

3. **The index file is thin.** It wires things together (init DB, start adapters, start poll loops) but contains no business logic. All logic lives in purpose-specific modules that skills can modify independently.

4. **Don't over-split.** A simple change (e.g., adding a new message kind) shouldn't require edits across 5 files. Group related logic together. The goal is that each skill touches 1-2 files for its core change.

5. **Registration patterns over switch statements.** Channels, MCP tools, and providers should use registration/plugin patterns. A skill adds a channel by adding a file and a registration call — not by editing a central switch statement alongside every other channel.

**Practical example:** Adding a new channel via skill should require:
- One new file (the channel adapter or Chat SDK config)
- One line in the barrel file (`channels/index.ts`) to import the self-registering module
- Zero changes to routing, formatting, delivery, or container code

### Conflict Hotspots and Solutions

Analysis of 33 skill branches shows these files cause the most merge conflicts:

| Hotspot | Why it conflicts | Solution |
|-----------|-----------------|-------------|
| `src/index.ts` (2000 LOC) | Every skill patches the main loop, imports, init logic | Thin index that wires modules. Logic lives in purpose-specific files (router, delivery, session-manager, host-sweep). |
| `src/config.ts` | Every skill adds env vars to a central file | Config declared where it's used. Each module reads its own env vars. No central config registry that every skill edits. |
| `src/container-runner.ts` | Channel skills add mounts, env vars, credential setup | Declarative mount registration. Channels declare their mounts in their own file. Container runner reads from a registry, not a hardcoded list. |
| `src/db.ts` (750 LOC) | Schema, migrations, and all CRUD in one file | Split by entity. Numbered migrations. Skills add a migration file + edit one entity file. |
| `container/agent-runner/src/index.ts` | Agent protocol, IPC handling, formatting all in one file | Split into poll-loop, formatter, providers/, mcp-tools/. Session DB replaces IPC. |
| `src/ipc.ts` | Every MCP tool addition patches one file | `mcp-tools/` directory with barrel. Skills add a tool file + barrel line. |
| `src/channels/index.ts` | Every channel adds an import line at the same location | Barrel file with comment slots per channel (current pattern works, keep it). |

**Mount registration pattern:** Instead of every channel skill editing `buildVolumeMounts()`, channels declare mounts that the container runner collects:

```typescript
// channels/gmail.ts
registerChannel('gmail', {
  factory: createGmailAdapter,
  mounts: [
    { hostPath: '~/.gmail-mcp', containerPath: '/home/node/.gmail-mcp', readonly: false }
  ],
  env: ['GMAIL_OAUTH_TOKEN'],
});
```

The container runner reads registered mounts from the channel registry — no need to edit `container-runner.ts`.

**Config pattern:** Skills don't patch `config.ts` or `.env.example`. Skill-specific env vars are documented in the skill's SKILL.md — the setup process reads those instructions. Each module reads its own env vars directly:

```typescript
// channels/discord.ts
const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;

// channels/gmail.ts  
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
```

Shared config (DATA_DIR, TIMEZONE) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.

### Code Style

**Line width: 120 characters.** Most statements fit on one line without sacrificing readability.

**Concise logging.** A thin wrapper keeps every log call on one line:

```typescript
log.info('IPC message sent', { chatJid, sourceGroup });
log.warn('Unauthorized IPC attempt', { chatJid });
log.error('Error processing', { file, err });
```

### DB File Structure

The DB layer is split by entity rather than kept in one monolithic file:

```
src/db/
  connection.ts              ← singleton, init, WAL mode
  schema.ts                  ← CREATE TABLE statements (current state, for reference)
  migrations/
    index.ts                 ← runner: checks version, applies pending
    001-initial.ts           ← initial schema
    002-pending-questions.ts ← example: adds pending_questions table
    ...                      ← skills append new numbered files
  agent-groups.ts            ← CRUD for agent_groups
  messaging-groups.ts        ← CRUD for messaging_groups + messaging_group_agents
  sessions.ts                ← CRUD for sessions + pending_questions
  index.ts                   ← barrel: re-exports everything
```

**Principles:**
- **Split by entity, not by layer.** Each entity file has its own CRUD functions (~50-100 lines). A skill that adds a column to messaging_groups edits `messaging-groups.ts` — doesn't touch sessions or agent groups.
- **Schema as current state + migrations as history.** `schema.ts` documents what the DB looks like now (read this to understand the schema). Migrations are append-only numbered files that describe how we got here.
- **No inline ALTER TABLE.** A migration runner with a `schema_version` table replaces `try { ALTER TABLE } catch { /* exists */ }` blocks. On startup, it checks the current version and applies pending migrations in order. Each migration is a function: `(db: Database) => void`.
- **Skills add migrations.** A skill that needs a new column adds a new numbered migration file. No conflicts with other skills' migrations as long as numbers don't collide (use timestamps or high-enough numbers for skill branches).

**Agent-runner session DBs** use the same pattern but lighter — no migrations needed since the DB files are created fresh by the host:

```
container/agent-runner/src/db/
  connection.ts          ← open inbound.db (read-only) + outbound.db (DELETE mode) at fixed paths
  messages-in.ts         ← read pending from inbound.db, ack via processing_ack in outbound.db
  messages-out.ts        ← write results/outbox rows to outbound.db (odd seq)
  session-state.ts       ← resume continuation, keyed per provider
  session-routing.ts     ← read the host-written default reply routing
  index.ts               ← barrel
```

### What the base architecture must support primitively

These are the building blocks. None require special abstractions — they fall out of per-session DBs, host-managed routing, and messages_out with `kind: 'system'`:

1. **Multiple agent groups on the same channel with content-based routing.** Different messages in the same thread can route to different agent groups based on content (e.g., @mention routes to supervisor, normal messages route to worker). The channel adapter's routing logic — custom code — decides.

2. **Per-thread sessions from a shared agent group.** Multiple sessions share the same agent group (filesystem, skills, CLAUDE.md) but each gets its own session DB. Standard for worker pools.

3. **Session reset and replay.** Create a new session for the same thread. Mark old messages as unhandled so the poll picks them up again. Old output stays visible in the platform (e.g., Discord thread) for comparison. This is an action an agent can request — not automatic.

4. **Cross-session read access.** Some agents can query other sessions' data. Different access levels: manager sees messages_in/messages_out (review content). Supervisor sees full internals (agent logs, tool calls, debug traces). This is just filesystem/DB access — mount or query the right paths.

5. **Context duplication into new sessions.** When a supervisor is invoked in a worker's thread, a new session is created with relevant messages copied in. Custom host-side code handles this.

6. **Agent-initiated host actions.** The agent uses MCP tools (reset session, update skills, etc.). The agent-runner handles the tool call and writes a structured `system` messages_out row. The host reads and executes with permission checks. The agent can request, but the host decides.

### Example: PR Factory

Three agent groups, one Discord channel (PR Factory), plus an admin channel:

| Role | Agent Group | Where | Session model |
|------|-------------|-------|---------------|
| **Worker** | pr-worker | PR Factory threads | One session per thread (per PR) |
| **Manager** | pr-manager | PR Factory channel | Single session, queries across worker sessions |
| **Supervisor** | pr-admin | Admin channel + PR Factory (when @tagged) | Main session in admin channel; per-thread session when invoked in worker threads |

**Worker flow:** GitHub PR → Discord thread → worker agent reviews (triage, review, test plan). Each thread gets a session from the shared pr-worker group.

**Feedback flow:** User @tags supervisor in worker threads → custom routing sends to supervisor with a new session containing the thread's messages (duplicated). Supervisor collects feedback to filesystem. Worker doesn't see supervisor messages.

**Iteration flow:** User discusses feedback with supervisor in admin channel → supervisor suggests skill changes (shown as rich card with diff) → user approves → supervisor applies changes via host action → supervisor requests session reset + replay → workers re-review same PRs with updated skills in same threads but fresh sessions → user compares reviews side by side.

**Manager flow:** User talks to manager in PR Factory main channel (not in threads). Manager can search across all worker session DBs (messages_in/messages_out) to answer questions like "how many PRs today?" or "what topics are trending?" Can request actions (close PR, re-open).

**What's custom code vs. base architecture:**

| Capability | Base architecture | Custom code (PR Factory) |
|-----------|-------------------|-------------------------|
| Per-thread sessions | ✓ platformThreadId → session | |
| Shared agent group across sessions | ✓ Multiple sessions, one group | |
| Writing messages to session DB | ✓ Standard flow | |
| @mention routing to different agent | | ✓ Channel adapter routing logic |
| Context duplication into supervisor session | | ✓ Host-side hook on supervisor invocation |
| Session reset + replay | ✓ Primitives (new session, mark unhandled) | ✓ Supervisor action triggers it |
| Skill updates | ✓ Filesystem writes | ✓ Supervisor action applies changes |
| Cross-session queries | ✓ DB/filesystem access | ✓ Manager's tools know where to look |
| Rich card output | ✓ Structured output in messages_out | |

## Central DB Schema

The central DB handles routing and entity management. All content and execution state lives in per-session DBs.

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Pending Question Flow

When the host delivers a messages_out row with `operation: 'ask_question'`:
1. Host delivers the card via the channel adapter
2. Host writes a `pending_questions` row mapping `question_id` → `session_id`

When a Chat SDK `ActionEvent` (button click) arrives:
1. Bridge extracts `actionId` from the event
2. Host looks up `pending_questions` by `question_id` (derived from actionId — the bridge maintains the mapping)
3. Host finds the target session, writes a messages_in row with `questionId` + `selectedOption`
4. Host deletes the `pending_questions` row
5. Agent-runner picks up the messages_in row, matches to the pending tool call, returns the selection

This avoids scanning session DBs. The central DB is the routing lookup — same pattern as message routing.

Also used for host-generated approval cards: when the host sends an approval request to the admin's DM, it writes a `pending_questions` row. The admin's response is routed back to the originating session.

### Container lifecycle states

```
stopped → running → idle → stopped
                  ↗
            idle → running (new message while warm)
```

- **stopped**: No container. Swept at 60s for due scheduled messages.
- **running**: Actively processing. Polled at 1s for messages_out.
- **idle**: Done processing, container still warm (up to 30 min timeout). Polled at 1s so new messages are picked up quickly.
- After idle timeout → host kills container → stopped.

## Agent-Runner Architecture

The agent-runner is the process inside the container. It mediates between the session DB and the agent provider — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.

### IO Model

All IO goes through the session DB. No stdin, no stdout markers, no IPC files.

- Initial input and follow-ups: poll `messages_in`
- Output: write `messages_out` rows
- MCP tools: write DB rows (no IPC files)
- Shutdown: host kills the container on idle timeout, or the agent-runner exits when there's no pending work

### Poll Loop

1. Query `inbound.db` (read-only) for `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`, skipping any id already in `processing_ack`
2. If rows found: upsert `processing_ack` rows with `status = 'processing'` in `outbound.db` (the container can't write `messages_in`)
3. Batch messages into a single prompt (strip routing fields, format by kind)
4. Push into the provider's input stream
5. Process agent output → write `messages_out` rows
6. Set the processed ids' `processing_ack.status = 'completed'` (the host mirrors that onto `messages_in.status`)
7. Back to step 1. If no messages found, sleep briefly and re-poll (container stays warm for idle timeout)

### Message Formatting by Kind

Agent-runner strips routing fields (`platform_id`, `channel_type`, `thread_id`) before formatting. The agent never sees routing info — it only sees content.

- **`chat`** — format into a `<message id="…" from="…" sender="…" time="…">` element
- **`chat-sdk`** — extract text, author, attachments from serialized message; same `<message>` element
- **`task`** — run the pre-script if present, then format as `<task from="…" time="…" current_time="…">` (script output first).
- **`webhook`** — format as a `<webhook source="…" event="…">` element wrapping the JSON payload
- **`system`** — host action results, formatted as `<system_response action="…" status="…">`, not chat

Mixed batches (e.g., a chat message + a system result both pending) are combined into one prompt with clear delimiters.

### MCP Tools

MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, create an agent, self-modify) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.

**Messaging & interaction:**

| Tool | What it does |
|------|-------------|
| `send_message` | Resolve `to` (destination name) → routing, write `messages_out` row, `kind: 'chat'`. Omit `to` to reply in place. Also the agent-to-agent path: a `to` naming an `agent`-type destination. |
| `send_file` | Copy file to `outbox/{msg_id}/`, write `messages_out` (`kind: 'chat'`) with filenames, same `to` resolution |
| `send_card` | Write `messages_out`, `kind: 'chat-sdk'`, content `{ type: 'card', … }` |
| `ask_user_question` | Write `messages_out` (`kind: 'chat-sdk'`, `type: 'ask_question'`). Hold tool call open, poll `inbound.db` for the response matching `questionId`. Return selection as tool result. |
| `edit_message` | Write `messages_out` with `operation: 'edit'` (targets the original message's destination) |
| `add_reaction` | Write `messages_out` with `operation: 'reaction'` |

(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)

**Scheduling**: scheduled-task management is not an MCP surface — it lives on
`ncl tasks` (create/list/get/update/cancel/pause/resume/run/append-log). Due
task rows live in the agent group's system session and are woken by the host
sweep.

**Central-DB / self-modification** (`kind: 'system'` actions; host authorizes, often via admin approval):

| Tool | What it does |
|------|-------------|
| `create_agent` | `action: 'create_agent'` (name + instructions); host creates the agent group (replaces the old `register_agent_group`) |
| `install_packages` | `action: 'install_packages'`; on approval host rebuilds the per-agent image and restarts |
| `add_mcp_server` | `action: 'add_mcp_server'`; on approval host updates `container.json` and restarts |

See [agent-runner-details.md](agent-runner-details.md) for full MCP tool parameter definitions.

### Cards

**Agent-initiated (outbound):** Tool-based. Agent calls `ask_user_question` (interactive card with options) or `send_card` (structured card). Agent-runner writes the card structure to messages_out. Host/adapter handles platform-specific rendering (Slack Block Kit, Discord embeds, Telegram inline keyboard, text fallback).

**Host-initiated (approval cards):** When an action requires approval, the host generates a standardized approval card and sends it to the admin's DM. These are not agent-initiated — the agent doesn't know about the approval step. The card format is fixed (action description + approve/deny buttons).

**Inbound (card responses):** Not a card — it's a messages_in row with `questionId` + `selectedOption` in the content. Agent-runner matches to the pending `ask_user_question` tool call and returns the selection as the tool result.

### Commands

Messages starting with `/` are checked against three lists:

**Whitelisted commands (pass-through to agent):**
- Standard slash commands that the agent provider handles natively (e.g., Claude's built-in commands)
- Passed raw, no `<messages>` XML wrapping

**Admin-only commands (require admin sender):**
- `/remote-control` — remote control session
- `/clear` — clear session context
- `/compact` — force context compaction
- If sent by a non-admin user, the command is rejected with an error message. Not forwarded to the agent.

**Filtered commands (dropped entirely):**
- Commands that don't make sense in the NanoClaw context or could cause issues
- Silently dropped — no error, no forwarding

The command lists are hardcoded in the agent-runner. Admin verification happens host-side before the message ever reaches the container: `src/command-gate.ts` queries `user_roles` (owner / global admin / scoped-admin-of-this-agent-group) and either passes the message through, drops it, or routes it elsewhere. The container has no notion of admin identity — no env var, no DB query, no per-message check.

### Recurring Tasks

The agent-runner processes recurring task messages like any other messages_in row. After the agent-runner marks a recurring message as `completed`, the **host** handles inserting the next occurrence (new messages_in row with `process_after` advanced to next cron time). The agent-runner doesn't manage recurrence — it just processes what it finds.

Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking the provider.

### Agent-to-Agent Messaging

**Outbound:** Agent calls `send_message(to="<agent-name>")` where the named destination is of type `agent` → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to the target session's `inbound.db` (recording `source_session_id` so the reply routes back to this exact session).

**Inbound:** Messages from other agents arrive as normal `chat` messages_in rows. The content includes `sender` and `senderId` (e.g., `"senderId": "agent:pr-admin"`). No special formatting — the agent sees it as a chat message.

### Agent-Runner Properties

- AgentProvider interface wraps SDK-specific query logic (trunk ships the `claude` provider; additional providers like OpenCode install via `/add-` skills)
- Session resume via provider-specific mechanisms
- System prompt loading from CLAUDE.md files
- PreCompact hook for transcript archiving (Claude provider)
- Script execution for task-kind messages

## Open Questions

- **Approval routing** — how does the host find the admin's DM conversation? What if no DM channel exists? Is the approval list configurable per agent group or global?
- **MCP server lifecycle** — does the MCP server process persist across multiple queries in the same container, or restart each time?
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The DB files are at fixed mount paths. System prompt comes from CLAUDE.md. Provider name comes from `container.json` (materialized from the `container_configs` table), not env. What else?
- **Idle detection with pending questions** — when `ask_user_question` is waiting for a response, the container should not be considered idle. Also need to detect when the agent is still working (active tool calls, subagents) and avoid killing the container even if no messages_out have been written recently.

## Related Documents

- **[api-details.md](api-details.md)** — Channel adapter interface (NanoClaw + Chat SDK bridge), message content examples, host delivery logic
- **[agent-runner-details.md](agent-runner-details.md)** — AgentProvider interface, MCP tools, message formatting, media handling, provider implementations

---

## File: docs/architecture-diagram.md

# NanoClaw Architecture Diagram

## System Overview

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Message Flow (inbound -> agent -> outbound)

```mermaid
sequenceDiagram
  participant P as Platform (e.g. Telegram)
  participant B as Chat SDK Bridge
  participant R as Router
  participant SM as Session Manager
  participant IDB as inbound.db
  participant C as Container (agent-runner)
  participant ODB as outbound.db
  participant D as Delivery Poller

  P->>B: new message
  B->>R: routeInbound(platformId, threadId, msg)
  R->>R: resolve messaging_group -> agent_group -> session(agent-shared | shared | per-thread)
  R->>SM: ensure session + DBs exist
  R->>IDB: INSERT messages_in (even seq)
  R->>C: wake container (docker run / already running)
  C->>IDB: poll messages_in
  C->>C: format xml, stream to selected provider
  C->>ODB: INSERT messages_out (odd seq)parse <message to="name"> blocks
  D->>ODB: 1s poll (active) / 60s (sweep)
  D->>D: hasDestination() re-validate
  D->>B: deliver via adapter
  B->>P: send message / edit / react / file / card
```

## Named Destinations + Agent-to-Agent

```mermaid
flowchart LR
  subgraph AgentA["Agent Group A (main)"]
    A_out["output:&lt;message to='slack'&gt;...&lt;/message&gt;&lt;message to='browser-agent'&gt;...&lt;/message&gt;&lt;internal&gt;scratchpad&lt;/internal&gt;"]
  end

  subgraph Dests["inbound.db.destinations (per agent)"]
    D1["slack -> messaging_group 42"]
    D2["browser-agent -> agent_group 7(bidirectional row)"]
    D3["github -> messaging_group 13"]
  end

  subgraph AgentB["Agent Group B (browser sub-agent)"]
    B_session["own inbound.db / outbound.dbinherited destination back to A"]
  end

  Slack[Slack channel]
  GitHub[GitHub PR thread]

  A_out -->|parse + lookup| Dests
  D1 -->|deliver| Slack
  D2 -->|write to B's inbound.db| B_session
  D3 -->|deliver| GitHub
  B_session -.reply via 'parent'.-> Dests
```

## Entity Model + Isolation Levels

```mermaid
erDiagram
  agent_groups ||--o{ messaging_group_agents : wired
  messaging_groups ||--o{ messaging_group_agents : wired
  agent_groups ||--o{ sessions : runs
  messaging_groups ||--o{ sessions : context
  agent_groups ||--o{ agent_destinations : owns
  agent_groups ||--o{ pending_approvals : requests

  agent_groups {
    int id
    string name
    string folder
    string agent_provider
  }
  messaging_groups {
    int id
    string channel_type
    string platform_id
    string name
    bool is_group
    string unknown_sender_policy "strict | request_approval | public"
  }
  users {
    string id PK "namespaced <channel>:<handle>"
    string kind
    string display_name
  }
  user_roles {
    string user_id FK
    string role "owner | admin"
    string agent_group_id FK "null = global"
  }
  agent_group_members {
    string user_id FK
    string agent_group_id FK
  }
  user_dms {
    string user_id FK
    string channel_type
    string messaging_group_id FK
  }
  messaging_group_agents {
    int messaging_group_id
    int agent_group_id
    string session_mode "agent-shared | shared | per-thread"
    string engage_mode "pattern | mention | mention-sticky"
    string sender_scope "all | known"
    int priority
  }
  sessions {
    int id
    int agent_group_id
    int messaging_group_id
    string thread_id
    string status
  }
```

### Isolation Level Cheatsheet

| Level | `session_mode` | What's shared | Example |
|---|---|---|---|
| 1. Shared session | `agent-shared` | Workspace + memory + conversation | Slack + GitHub webhooks in one thread |
| 2. Same agent, separate sessions | `shared` / `per-thread` | Workspace + memory only | One agent across 3 Telegram chats |
| 3. Separate agent groups | (different `agent_group_id`) | Nothing | Personal vs work channels |

## Two-DB Split (why)

```mermaid
flowchart LR
  subgraph Mount["/workspace (volume mounted into container)"]
    In[("inbound.db")]
    Out[("outbound.db")]
    HB["/.heartbeat (file touch)"]
  end

  Host[Host process] -->|"writes only(even seq)"| In
  Host -->|reads| Out
  Container[agent-runner] -->|reads| In
  Container -->|"writes only(odd seq)"| Out
  Container -->|touch every poll| HB
  HostSweep[Host sweep] -->|stat mtime| HB
  HostSweep -->|reads processing_ack| Out

  note1["Each file has exactly ONE writer.Eliminates SQLite cross-process write contention.Collision-free seq numbering."]
```

---

## File: docs/BRANCH-FORK-MAINTENANCE.md

# Branch and fork maintenance

How the long-lived branches on `nanocoai/nanoclaw` relate to `main` and how to keep them in sync. This is the maintainer view: [skills-model.md](skills-model.md) explains the customization model itself, and [CONTRIBUTING.md](../CONTRIBUTING.md) covers contributing a channel or provider.

## Structure

**`main`** — core engine plus skill definitions (`.claude/skills/`). It carries the shared channel machinery every install needs (`src/channels/`: adapter interface, channel registry, Chat SDK bridge, CLI channel, ask-question flow, channel defaults) and the default Claude provider — but no optional channel adapters and no alternative providers.

**Registry branches** (`channels`, `providers`) — long-lived branches carrying the code that channel and provider skills install. `channels` holds every channel adapter with its tests (`src/channels/telegram.ts`, `src/channels/telegram-registration.test.ts`, …); `providers` holds the alternative agent providers (OpenCode, Codex). The `/add-*` skills on `main` fetch files from these branches (`git show origin/channels: > `) — an additive copy into the user's clone, never a merge. (Not every provider needs branch code: `/add-ollama-provider` is configuration-only and redirects the built-in Claude path.)

**Legacy mechanisms** — the channel fork repos (`nanoclaw-whatsapp`, `nanoclaw-telegram`, …) and the `skill/*` branches (`skill/compact`, `skill/apple-container`, …) are the pre-skills delivery model: applied code that users merged into their clones. They are frozen (no forward merges since spring 2026) and superseded by the registry branches and `/add-*` skills. Don't build on them and don't forward-merge them.

## How users add capabilities

```
user clones upstream main
  ├── runs /add-whatsapp   → skill copies the adapter in from the channels branch
  ├── runs /add-opencode   → skill copies the provider in from the providers branch
  └── runs /add-<tool>     → skill copies files in from its own folder
```

Registry-backed installs are additive fetch-and-copies; other skills ship their files in their own folder or are instruction-only. Either way a user's clone never merges a registry branch, and registry branches are never merged back into `main`. [skills-model.md](skills-model.md) explains why.

## Merge directions

```
upstream main ──→ channels     (forward merge to keep adapters building against current core)
upstream main ──→ providers    (forward merge, same reason)
```

Fixes to existing adapters and providers land as PRs based directly on the registry branch. New channels and providers are contributed from a branch off `main` (see [Adding a new channel or provider](#adding-a-new-channel-or-provider)); maintainers land the code portion on the registry branch. Nothing merges back into `main`.

## Forward merge procedure

```bash
# In your local nanoclaw checkout
git checkout main && git pull

git checkout -B channels origin/channels
git merge main
# Resolve conflicts (see below)
git push origin channels
git checkout main && git branch -D channels
```

Same procedure for `providers`.

This procedure assumes the branch is reasonably current. A registry branch left unmerged for months will conflict far beyond the table below — treat a large catch-up as its own reviewed effort, build and test both branches afterward, and update the table from that merge's actual receipts.

## Conflict resolution

Files with known mechanical resolutions:

| File | Resolution |
|------|------------|
| `package.json` | Take main's version + keep branch-specific deps |
| `pnpm-lock.yaml` | `git checkout main -- pnpm-lock.yaml && pnpm install` |
| `.env.example` | Combine: main's entries + branch-specific entries |
| `repo-tokens/badge.svg` | Take main's version (auto-generated) |

Source code changes (e.g. `src/types.ts`, `src/index.ts`) usually auto-merge cleanly, but can conflict if both sides modify the same lines. **Always build and test after every forward merge** — auto-merged code can be silently wrong (e.g. referencing a renamed function or using a removed parameter) even when git reports no conflicts.

## When to merge forward

After any main change that touches shared files (`package.json`, `src/index.ts`, `CLAUDE.md`, etc.). Small frequent merges = trivial conflicts. Large infrequent merges = painful. A registry branch that drifts far behind main also means every `/add-*` install copies in code written against an old core.

## Adding a new channel or provider

Skills replaced fork setup. The short version ([CONTRIBUTING.md](../CONTRIBUTING.md) has the full flow):

1. Build the adapter or provider following [skill-guidelines.md](skill-guidelines.md): a self-registering module, one appended barrel import, and a registration test that imports the real barrel.
2. Write the `/add-<name>` skill in `.claude/skills/` on `main` — a SKILL.md with the fetch-and-copy steps and a REMOVE.md that reverses them.
3. Open a PR from a branch off `main`; maintainers land the code portion on the registry branch.

## Dependencies

Registry branches add their own deps on top of upstream's. Skill `nc:dep` directives pin exact versions at install time (the supply-chain policy rejects ranges and `latest`). When upstream adds or removes a dependency, verify the registry branches still build after the next forward merge — transitive dependency changes can break adapter code.

---

## File: docs/build-and-runtime.md

# Build & Runtime

NanoClaw runs a split stack: the host is Node + pnpm, the agent container is Bun. They communicate exclusively through two SQLite files per session — there are no shared modules between them, which is what lets them use different runtimes cleanly.

## Why the split

- **Host stays on Node** because Baileys (WhatsApp) depends on `libsignal-node` native bindings and a long-tested WebSocket/HTTP stack. Bun's Node-API compat has improved, but this isn't where we want risk.
- **Container runs Bun** because `bun:sqlite` is built-in (no native compile of `better-sqlite3` per image rebuild), source runs directly (no tsc build step at image build or session wake), and `bun install` is ~5-10× faster than `npm install`.

Host and container each have their own package tree:

```
/                             pnpm + Node 22
  pnpm-lock.yaml              host deps (channels, Chat SDK, Baileys, better-sqlite3, etc.)
  pnpm-workspace.yaml         minimumReleaseAge + onlyBuiltDependencies policy

/container/agent-runner/      Bun 1.3+
  bun.lock                    agent-runner runtime deps (Claude Agent SDK, MCP SDK, zod, etc.)
  package.json                @types/bun, typescript devDeps for type-checking
```

The container image also has pnpm + Node inside for global CLIs (`@anthropic-ai/claude-code`, `agent-browser`, `vercel`). Those are Node binaries the agent invokes at runtime, not library deps. Keeping them on pnpm preserves the supply-chain policy for CLI versions.

## Lockfiles

| Tree | Lockfile | Manager | Regenerate after dep change |
|------|----------|---------|----------------------------|
| Host | `pnpm-lock.yaml` | pnpm 10 | `pnpm install` |
| Agent-runner | `container/agent-runner/bun.lock` | Bun 1.3+ | `cd container/agent-runner && bun install` |

Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — any drift between `package.json` and lockfile fails the build.

## Supply chain

- **Host + global CLIs** (pnpm): `minimumReleaseAge: 4320` (3-day hold on new versions), `onlyBuiltDependencies` allowlist for postinstall scripts. See `pnpm-workspace.yaml` and `docs/SECURITY.md`.
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus a version-pinned Bun itself via a Dockerfile ARG (global CLIs are pinned separately in `container/cli-tools.json`). When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.

## Image build surface

`container/Dockerfile` is a single-stage build on `node:22-slim`:

- **Pinned ARGs** — `BUN_VERSION`, `PNPM_VERSION`, `INSTALL_CJK_FONTS`. Bump deliberately in PRs. Global CLI versions (`@anthropic-ai/claude-code`, `agent-browser`, `vercel`) are pinned separately in `container/cli-tools.json`, not as ARGs.
- **CJK fonts** — `ARG INSTALL_CJK_FONTS=false`. `container/build.sh` reads `INSTALL_CJK_FONTS` from `.env` and passes it through. Default build saves ~200MB; opt in when the user works with Chinese/Japanese/Korean content.
- **BuildKit cache mounts** — `/var/cache/apt`, `/var/lib/apt`, `/root/.bun/install/cache`, `/root/.cache/pnpm`. Rebuilds where `package.json`/`bun.lock` haven't changed are fast. Requires BuildKit (default on Docker 23+, Apple Container-compat).
- **`tini` as init** — reaps Chromium zombies, forwards signals so in-flight `outbound.db` writes finalize on SIGTERM.
- **`entrypoint.sh`** (extracted) — `exec bun run /app/src/index.ts` under tini. Readable and diffable.
- **No compiled `/app/dist`** — Bun runs TS directly. The host also mounts fresh source over `/app/src` at session start, so host edits take effect without rebuilding the image.

## Session wake (two paths)

1. **Base image ENTRYPOINT** — used for stdin-piped test invocations like the sample in `container/build.sh`: `tini --> entrypoint.sh` captures stdin to `/tmp/input.json`, then `exec bun run src/index.ts`.
2. **Host-spawned session** — `src/container-runner.ts` at line ~503 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.

Both paths end with Bun running the same source file from `/app/src/index.ts`.

## CI shape

`.github/workflows/ci.yml` installs both Node (with pnpm cache) and Bun, then runs in order:

1. `pnpm install --frozen-lockfile` (host)
2. `bun install --frozen-lockfile` in `container/agent-runner/` (container)
3. `pnpm run format:check`
4. `pnpm exec tsc --noEmit` (host typecheck)
5. `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit` (container typecheck)
6. `pnpm exec vitest run` (host tests)
7. `bun test` in `container/agent-runner/` (container tests)

Any failure fails the PR.

## Key invariants

- **Session DBs must use `journal_mode=DELETE`.** WAL's `-shm` memory-map doesn't cross VirtioFS between host and guest. See `container/agent-runner/src/mailbox/sqlite/connection.ts` and `src/mailbox/sqlite/session-db.ts`.
- **Named SQL parameters in the container require the prefix in JS object keys.** `bun:sqlite` does not auto-strip `@`/`$`/`:` the way `better-sqlite3` does on the host. Use `$name` in both SQL and keys: `.run({ $id: msg.id })`. Positional `?` params work normally.
- **Agent-runner tests run under `bun:test`, not vitest.** `vitest.config.ts` excludes the `container/agent-runner/` tree because vitest runs on Node and can't load `bun:sqlite`.
- **No tsc build step in the container image.** Re-adding one would reintroduce the ~200-500ms per-session-wake cost we removed.
- **Global container CLIs stay on pnpm, not Bun.** `agent-browser`, `@anthropic-ai/claude-code`, `vercel` and any future Node CLIs the agent invokes should be pinned versions under the Dockerfile's pnpm global-install block. `bun install -g` would bypass the pnpm supply-chain policy.

## Migration history

This structure replaced a uniform npm-on-Node stack across both host and container. The pnpm migration landed first (PR #1771) to bring the host under supply-chain policy, then the container moved to Bun to eliminate native-module compilation and the per-wake tsc step. The split was chosen over going full-Bun because Baileys' native deps are the main risk surface on the host — the container has no such deps, so it benefits from Bun without taking the risk.

---

## File: docs/customizing.md

# Customizing NanoClaw

NanoClaw is made to be forked and changed. The catch with most projects is that once you edit the code, every upstream update turns into a merge fight, and the more you customized, the worse it gets.

NanoClaw avoids that with one simple idea: **every change you make is a skill.**

## The idea in a minute

- A **skill** is a small, self-contained add-on. It brings its own code and knows how to install itself.
- Your **fork is just a list of skills**, plus one "recipe" that says which skills you have and how they fit together.
- Because your changes live beside the core instead of tangled into it, **pulling in updates stays easy**.

## What makes it work

A good skill mostly **adds** things: new files, a line appended to an existing file, a dependency. It avoids rewriting existing code in place.

And it ships a test for each spot where it touches the rest of the system. When an update moves something your skill depends on, that test fails and points at the fix, instead of you finding out when things break in production.

## How you actually work

You don't have to think in skills while you're building. **Edit the code directly, get it working, then turn your changes into skills afterward.** A coding agent does the conversion for you, following [skill-guidelines.md](skill-guidelines.md).

The only rule worth remembering: **a change isn't really part of your fork until it's a skill**, because that's the form that survives an upgrade.

## Upgrading

Always upgrade by running `/update-nanoclaw`. **Don't just `git pull`.** The
command stages upstream in an isolated worktree, refreshes installed skills,
runs the test gates, snapshots mutable state, and health-checks the cutover. Its
rollback restores SQLite and local configuration as well as Git.

## The deal

We keep the core small and stable, and every breaking change ships with its migration. You keep your changes as skills, with tests. Do that, and upgrades won't break you. Changes edited directly into the core are the one thing the model can't protect.

## Go deeper

- **[The skills model in full](skills-model.md)**: how skills, recipes, tests, and upgrades work under the hood.
- **[Skill guidelines](skill-guidelines.md)**: the authoritative checklist for writing one.

---

## File: docs/db.md

# NanoClaw Database Architecture — Overview

Orientation for the data model: the three databases, how they fit together, and the invariants that hold across them. For table-level schemas, follow the links below.

- **[db-central.md](db-central.md)** — every central table (identity, wiring, approvals, Chat SDK state), the driver boundary, and the migration system.
- **[db-session.md](db-session.md)** — the per-session `inbound.db` + `outbound.db` pair, seq parity, and session folder layout.

Related: [architecture.md](architecture.md) for the high-level design; [api-details.md](api-details.md) for inbound/outbound message content shapes; [isolation-model.md](isolation-model.md) for channel-to-agent wiring modes.

---

## 1. The three databases

NanoClaw uses one backend-neutral central database and two SQLite session mailboxes:

| DB | Location | Writer | Readers | Purpose |
|----|----------|--------|---------|---------|
| **Central** | `data/v2.db` by default; configured backend otherwise | host | host | Identity, permissions, routing, wiring — the admin plane |
| **Session inbound** | `data/v2-sessions/<agent_group_id>/<session_id>/inbound.db` | host | host (sync), container (read-only) | Host → container messages + routing projections |
| **Session outbound** | `data/v2-sessions/<agent_group_id>/<session_id>/outbound.db` | container | host (poll), container | Container → host messages + processing status |

**Single-writer mailbox rule.** Every session SQLite file has exactly one writer. Host writes every `inbound.db`; container writes only its own `outbound.db`. The default SQLite central DB is host-only, while alternate central backends provide their own concurrency controls.

**Everything is a message.** There is no IPC, stdin piping, or file watcher between host and container. The two session DBs are the sole IO surface. Heartbeat is a file `touch(2)` on `.heartbeat`, not a DB write.

**Journal mode.** Session DBs use `journal_mode = DELETE` (not WAL). Cross-mount WAL visibility is a bug farm; DELETE mode + open-write-close forces the page cache to flush so the other side sees changes.

---

## 2. Database map

```
data/
  v2.db                                   ← default central backend
  v2-sessions/
    <agent_group_id>/
      .claude-shared/                     ← shared Claude state for the agent group
      <session_id>/
        inbound.db                        ← host writes, container reads
        outbound.db                       ← container writes, host reads
        .heartbeat                        ← mtime touched by container
        inbox/<message_id>/               ← decoded user attachments
        outbox/<message_id>/              ← attachments the agent produced
```

Path helpers: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()` — all in `src/session-manager.ts`.

---

## 3. Central vs. session: what goes where

| Kind of data | Where | Why |
|--------------|-------|-----|
| Identities, roles, memberships | central | Stable, cross-session, rarely written |
| Channel wiring, routing rules | central | Admin plane |
| Destination ACL | central (+ projection per session) | Source of truth centrally; fast local lookup per session |
| Session registry (ids, status) | central | Host orchestrates lifecycle |
| Approvals & pending questions | central | Survive container restarts, admin-visible |
| Dropped-message audit | central | Global ops view |
| Inbound messages, retry state | session `inbound.db` | Per-session workload; host is sole writer |
| Outbound messages, agent state | session `outbound.db` | Container is sole writer; host polls |
| Delivery outcome | session `inbound.db` (`delivered`) | Host writes on success; container reads for edit targeting |
| Processing status | session `outbound.db` (`processing_ack`) | Container can't write to `inbound.db` |

Heuristic: if the value is a message, routing projection, or runtime ack, it goes per-session. Everything else is central.

---

## 4. Cross-mount visibility

Session DBs are bind-mounted into the container. A few rules you need to know before touching the DB code:

- **`journal_mode = DELETE`, not WAL.** WAL files don't reliably cross the mount and the container can read stale pages. DELETE mode forces each writer to flush the main file.
- **Open-write-close on the host.** Host-side writes to `inbound.db` open a connection, write, and close it. Keeping a handle open makes cached pages invisible to the container.
- **Container reads read-only.** The container opens `inbound.db` with `readonly: true` and never writes — all container→host state goes through `outbound.db` (see `processing_ack` in [db-session.md](db-session.md#52-processing_ack)).
- **Heartbeat is a file touch.** `.heartbeat` mtime is the liveness signal, not a DB column. A DB write per heartbeat would serialize behind other writers.

These rules are enforced by convention in `src/session-manager.ts` and `container/agent-runner/src/db/`. If you change how the DBs are opened, re-read that code first.

---

## 5. Design patterns at a glance

1. **Two-DB session split.** `inbound.db` and `outbound.db` each have one writer, one direction of flow — no cross-mount lock contention.
2. **Seq parity.** Even = host, odd = container. Disjoint namespace across both tables lets the agent reference any message by `seq` alone. Details in [db-session.md §3](db-session.md#3-sequence-numbering-invariant).
3. **Projection pattern.** `agent_destinations` and `session_routing` are projected from the central DB into each session's `inbound.db` on container wake — the container gets a fast, local read path without querying across the mount.
4. **Ack via reverse channel.** Container never writes to `inbound.db`. Status sync happens through `processing_ack` in `outbound.db`, which the host polls and reconciles.
5. **Heartbeat out of band.** File `touch` on `.heartbeat`, not a DB write, so liveness doesn't serialize behind other writers.
6. **Lazy session-DB migrations.** Central DB uses numbered migrations; per-session DBs use `IF NOT EXISTS` + ad-hoc `ALTER TABLE` helpers for older session folders.
7. **ACL = row existence.** `agent_destinations` membership is itself the permission — no separate `permissions` table.
8. **Async central boundary.** Host code reaches central state only through `DbDriver`; backend composition is registered once by `src/db/compose.ts`. Session mailbox access remains a separate seam.

---

## 6. Readers & writers — at a glance

| Table | DB | Writer(s) | Reader(s) |
|-------|----|-----------|-----------|
| `agent_groups` | central | `src/db/agent-groups.ts` | session resolver, delivery, router |
| `messaging_groups` | central | `src/db/messaging-groups.ts`, channel setup | router, delivery, session resolver |
| `messaging_group_agents` | central | `src/db/messaging-groups.ts` | router |
| `users` | central | `src/db/users.ts`, auth flows | permission checks |
| `user_roles` | central | `src/db/user-roles.ts` | `src/access.ts`, all permission gates |
| `agent_group_members` | central | `src/db/agent-group-members.ts` | membership checks |
| `user_dms` | central | `src/user-dm.ts` (`ensureUserDm`) | approval + pairing delivery |
| `sessions` | central | `src/db/sessions.ts`, `src/session-manager.ts` | delivery, sweep, container runner |
| `pending_questions` | central | `src/db/sessions.ts` (via `ask_user_question`) | container response matcher |
| `agent_destinations` | central | `src/db/agent-destinations.ts`, migration 004 backfill | `writeDestinations()`, delivery ACL |
| `pending_approvals` | central | `src/db/sessions.ts`, `src/onecli-approvals.ts` | admin-card delivery, sweep |
| `unregistered_senders` | central | `src/db/dropped-messages.ts` | ops tooling |
| `chat_sdk_*` | central | `src/state-sqlite.ts` | Chat SDK bridge |
| `schema_version` | central | `src/db/migrations/index.ts` | migration runner |
| `messages_in` | inbound | `src/mailbox/sqlite/` | `container/agent-runner/src/mailbox/sqlite/` |
| `delivered` | inbound | `src/mailbox/sqlite/session-db.ts` (`markDelivered`) | container edit/reaction targeting |
| `destinations` | inbound | `writeDestinations()` in `src/session-manager.ts` | container routing / ACL |
| `session_routing` | inbound | `writeSessionRouting()` in `src/session-manager.ts` | container `send_message` defaults |
| `messages_out` | outbound | `container/agent-runner/src/db/messages-out.ts` | `src/delivery.ts` poll loop |
| `processing_ack` | outbound | `container/agent-runner/src/db/messages-in.ts` | `src/host-sweep.ts` (`syncProcessingAcks`) |
| `session_state` | outbound | `container/agent-runner/src/db/session-state.ts` | container on startup |

---

## File: docs/db-central.md

# NanoClaw — Central DB Schema

The central store is accessed through the asynchronous `DbDriver` contract in
`src/db/driver.ts`. `src/db/compose.ts` performs the one backend choice and
registers it through `src/db/driver-registry.ts`; callers never import a backend
directly. SQLite at `data/v2.db` remains the default composition.

Complete reference for `data/v2.db`, the host-owned admin-plane database. Start with [db.md](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.

```sql
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.

```sql
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 backfills `instance = channel_type` and `createMessagingGroup` stamps 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](isolation-model.md)).

```sql
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 opaque `trigger_rules` JSON + `response_scope` enum. `engage_mode='pattern'` requires `engage_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.

```sql
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.**

```sql
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 in `grantRole()`.
- `role = 'admin'` → global (NULL) or scoped to one agent group.
- Admin @ A implies membership in A — no `agent_group_members` row 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.

```sql
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.

```sql
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.

```sql
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()` in `src/session-manager.ts`.
- Creating a session also provisions the session folder and both session DBs via `initSessionFolder()` — see [db-session.md](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`.

```sql
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`.

```sql
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](db-session.md#23-destinations)). 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_id` is set.
- **OneCLI credential approvals** — `session_id` may be NULL; `agent_group_id` + `channel_type` + `platform_id` route the admin card.

```sql
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 '',
  question            TEXT NOT NULL DEFAULT '',       -- added by migration 021
  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_id` lets 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.

```sql
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](api-details.md)). NanoClaw code rarely touches these directly — they're owned by `src/state-sqlite.ts`.

```sql
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).

```sql
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, timezone, etc. Materialized to `groups/<folder>/container.json` at spawn time.

```sql
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
  timezone               TEXT,                            -- IANA id; NULL = install-global TZ (added by migration 20)
  updated_at             TEXT NOT NULL
);
```

`timezone` overrides the install-global timezone for one agent group: host-side scheduling (cron interpretation, `--process-after`, run-log stamps) resolves it live via `resolveGroupTimezone` (`src/container-config.ts`); the container gets it as its `TZ` env on next respawn. Set via `ncl groups config update --timezone ` (`""` clears back to NULL) or `ncl groups create --timezone`.

- **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.

```sql
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
  question           TEXT NOT NULL DEFAULT '',       -- added by migration 021
  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.

```sql
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
  question           TEXT NOT NULL DEFAULT '',       -- added by migration 021
  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.

```sql
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 in `pending_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:

1. Creates `schema_version` if absent.
2. Reads every already-applied `name` from `schema_version` into a `Set` and filters the `migrations` barrel array down to the ones whose `name` isn't in that set — dedup is by **name**, not by the numeric `version` field.
3. Runs each pending migration's `up(db)` inside a transaction, in the barrel array's literal order (which is *not* sorted by `version`), then inserts a `schema_version` row.
4. The `version` column stored in `schema_version` is **not** the migration's own `version` field — it's `COALESCE(MAX(version), 0) + 1`, i.e. an auto-assigned applied-order number computed at insert time. The `version` field on the `Migration` object 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 |
| 19 | `wiring-threads-override` | `019-wiring-threads.ts` | `messaging_group_agents.threads` — per-wiring thread-policy override (NULL = adapter default) |
| 20 | `container-config-timezone` | `020-container-config-timezone.ts` | `container_configs.timezone` — per-agent-group timezone override (NULL = install-global) |
| 21 | `approval-question-render-metadata` | `021-approval-question.ts` | `question` card-body column on all three approval tables so terminal edits retain the original request |
| 22 | `messaging-group-detached-at` | `022-messaging-group-detached.ts` | `messaging_groups.detached_at` — records when the bot left a channel without deleting its wiring |

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](db-session.md).

## 3. Portable SQL rules

Central-DB runtime SQL must work on SQLite and installed remote backends. Session mailbox SQL is outside this rule because `inbound.db` and `outbound.db` remain direct SQLite.

- Use `INSERT ... ON CONFLICT (...) DO NOTHING` instead of `INSERT OR IGNORE`.
- Use `INSERT ... ON CONFLICT (...) DO UPDATE SET ... = excluded....` instead of `INSERT OR REPLACE`; replacement deletes and recreates a row and is not portable.
- Never use `rowid` for runtime ordering. Declare a stable domain-column order, with a deterministic key as the final tie-breaker.
- Use snake_case aliases because remote SQL engines may fold unquoted identifiers to lowercase.
- Use `IS NOT DISTINCT FROM ?` when nullable equality is required. `IS ?` is SQLite-only, while `=` does not match two NULL values.
- Keep named parameters in the existing `@name` form. The backend driver owns placeholder rewriting.
- Store timestamps as ISO-8601 UTC text produced by `new Date().toISOString()`. Portable central-DB SQL compares the consistently shaped text directly; SQLite-only operational snippets may use `datetime()` around both sides.
- Central migrations added after the async DB boundary must use the backend-neutral driver API and portable SQL. Backend-specific schema work belongs in a named migration override.

## 4. Migration execution

`runMigrations()` has three modes:

- `auto` migrates SQLite and validates non-SQLite backends.
- `validate` performs no DDL and refuses startup when the ledger is missing or pending.
- `migrate` applies migrations and is used by `pnpm run migrate` under the migration-owner role.

Backends may provide three narrow hooks: baseline bootstrap, name-keyed
migration overrides, and a lock around the complete migration run. Legacy
SQLite-only migrations are frozen by name; a non-SQLite backend must cover
them in its baseline or provide an override. Foreign-key PRAGMA handling is
never attempted outside SQLite.

Host startup uses `auto`; production schema changes are a separate operator
step. `scripts/q.ts` sends only the canonical `data/v2.db` path through the
installed composition. Explicit `inbound.db` and `outbound.db` paths always
remain local SQLite files and retain their journal mode.