Skip to content

Instantly share code, notes, and snippets.

@durandom
Last active February 23, 2026 13:52
Show Gist options
  • Select an option

  • Save durandom/56d2a642c46f96c41f63a887f93d6736 to your computer and use it in GitHub Desktop.

Select an option

Save durandom/56d2a642c46f96c41f63a887f93d6736 to your computer and use it in GitHub Desktop.
NanoClaw Architecture Exploration — deep-dive into internals, message flow, container isolation, session lifecycle, and provider support

NanoClaw Architecture Exploration

Deep-dive into NanoClaw internals, covering message flow, container isolation, session lifecycle, and provider support.

Overview

Single Node.js orchestrator process that bridges WhatsApp (or other channels) to Claude Code running inside isolated containers. Each group gets its own container with filesystem isolation, persistent sessions, and file-based IPC.

WhatsApp (Baileys) → SQLite buffer → Polling loop → Container (Claude Agent SDK) → Response

1. Inbound Communication

Message Path

  1. WhatsApp listener (src/channels/whatsapp.ts:149-203): Baileys library listens for messages.upsert events. For registered groups, text content is extracted (conversation, extended text, image/video captions) and passed to the orchestrator via onMessage callback.

  2. SQLite storage (src/index.ts:441): Every incoming message is written to the database, decoupling reception from processing.

  3. Polling loop (src/index.ts:309-398): Runs every 2 seconds (POLL_INTERVAL). Fetches unprocessed messages from SQLite via getNewMessages().

  4. Trigger filtering (src/index.ts:352-362): Non-main groups only trigger the agent if a message matches TRIGGER_PATTERN (e.g., @Andy). The main group processes all messages without requiring a trigger.

  5. Routing (src/index.ts:364-391): Messages are formatted as XML (src/router.ts:12-17) and either:

    • Piped via IPC to an already-running container (queue.sendMessage())
    • Enqueued in GroupQueue to spawn a new container
  6. Agent processing (container/agent-runner/src/index.ts:357-491): The agent-runner reads the prompt, calls the Claude Agent SDK's query() function, and streams results back through stdout markers (---NANOCLAW_OUTPUT_START--- / ---NANOCLAW_OUTPUT_END---).

Key Design Decisions

  • Polling over webhooks: Simpler, no exposed ports, works behind NAT.
  • SQLite as buffer: Decouples message reception from processing. Messages accumulate between triggers and are included as context when a trigger arrives.
  • XML message format: Structured format that preserves sender identity and timestamps.

2. Background Processes

OS-Level Service

Managed via launchd (macOS) or systemd (Linux):

Platform Config Key Properties
macOS launchd/com.nanoclaw.plist RunAtLoad: true, KeepAlive: true
Linux systemd user unit Restart=always

The service starts on boot and auto-restarts on crash.

Task Scheduler (src/task-scheduler.ts:208-244)

A separate infinite loop started at app startup (src/index.ts:453):

  • Polls the database every 60 seconds (SCHEDULER_POLL_INTERVAL)
  • getDueTasks() retrieves tasks where next_run <= now
  • Each due task is enqueued to GroupQueue and executed through the same container mechanism as regular messages

Supported schedule types:

Type Example Behavior
cron 0 9 * * 1-5 Next run calculated from cron expression
interval 3600000 Fixed millisecond interval from last run
once ISO timestamp Single execution, no repeat

Task Context Modes (src/task-scheduler.ts:108-112)

  • Group mode: Uses the group's existing session (has conversation history)
  • Isolated mode: Fresh session with no prior context

3. Container Isolation

What Runs Inside

Each container runs node:22-slim with:

  • Chromium (for browser automation)
  • @anthropic-ai/claude-code (globally installed)
  • The agent-runner (TypeScript entry point that wraps the Claude Agent SDK)

The agent has full Claude Code capabilities: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task (subagents), TeamCreate (agent swarms), and NanoClaw-specific MCP tools.

Mount Structure (src/container-runner.ts:53-179)

Main group:

/workspace/project   ← Project root (read-only)
/workspace/group     ← Group's own folder (read-write)
/workspace/ipc       ← IPC directory (read-write)
/home/node/.claude   ← Per-group Claude sessions (read-write)

Non-main groups:

/workspace/group     ← Only their own folder (read-write)
/workspace/global    ← Global memory (read-only)
/workspace/ipc       ← IPC directory (read-write)
/home/node/.claude   ← Per-group Claude sessions (read-write)

Non-main groups cannot see the project root or other groups' data.

Security Model

Layer Mechanism
Filesystem Per-group mount isolation; non-main groups see only their own folder
Secrets ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN passed only via SDK env parameter, never in process.env. A PreToolUse hook prepends unset to every Bash command (agent-runner/src/index.ts:193-210)
Process Runs as node user (UID 1000), not root
Cross-group Per-group IPC namespaces; non-main groups can only send messages to their own JID
Permissions bypassPermissions: true inside container (no interactive prompts possible via WhatsApp), but restricted to mounted directories
Container lifecycle --rm flag auto-deletes container on exit

4. Interactive Session Lifecycle

Not Fire-and-Forget

Unlike claude --print (single prompt → single response → exit), NanoClaw runs a persistent conversation loop inside the container:

Container starts
  │
  ▼
┌─── while (true) ───────────────────────────────┐
│                                                 │
│  query(prompt, { resume: sessionId })           │
│    ├── Agent works (tools, reasoning, output)   │
│    ├── IPC poll: inject messages mid-query  ◄───┼── User sends message while agent works
│    └── Agent finishes → stream results          │
│                                                 │
│  waitForIpcMessage()                            │
│    ├── New message arrives → loop continues ◄───┼── User sends follow-up message
│    └── _close sentinel → break                  │
│                                                 │
└─────────────────────────────────────────────────┘
  │
  ▼
Container exits (--rm cleans up)

Source: container/agent-runner/src/index.ts:538-574

Two Message Injection Paths

During active query (agent-runner/src/index.ts:368-386): Messages are polled from /workspace/ipc/input/ every 500ms and pushed directly into the active MessageStream. The agent sees them as additional user turns in the same conversation context.

Between queries (agent-runner/src/index.ts:566): waitForIpcMessage() blocks until a new IPC file appears. The host writes these via GroupQueue.sendMessage() (src/group-queue.ts:149-166), which atomically creates JSON files using write-then-rename.

Host-Side Message Routing (src/index.ts:375-390)

if (queue.sendMessage(chatJid, formatted)) {
    // Message piped to active container via IPC file
} else {
    // No active container — enqueue for a new one
    queue.enqueueMessageCheck(chatJid);
}

Session Persistence

Each query() call uses resume: sessionId and resumeSessionAt: lastAssistantUuid. This gives the agent full conversation history across multiple query rounds — equivalent to claude --resume.

Session Termination

The session ends when:

  • _close sentinel is written to ipc/input/_close (src/group-queue.ts:171-182)
  • Triggered by: idle timeout, scheduled task preemption, or container timeout
  • The agent-runner detects it via shouldClose() and breaks the loop

5. IPC: Container ↔ Host Communication

Bidirectional communication uses file-based IPC on a shared Docker volume mount (/workspace/ipc).

Container → Host

Channel Path Purpose
stdout Process stdout with markers Streaming agent responses back to user
Messages ipc/{group}/messages/*.json Agent sending WhatsApp messages (via MCP tool)
Tasks ipc/{group}/tasks/*.json Agent creating/managing scheduled tasks

The host-side IPC watcher (src/ipc.ts:34-152) polls these directories and processes the JSON files. Authorization is enforced: non-main groups can only send messages to their own JID.

Host → Container

Channel Path Purpose
IPC input ipc/{group}/input/*.json Follow-up user messages
Close sentinel ipc/{group}/input/_close Signal to terminate session

Atomicity is ensured via write-to-temp-then-rename (group-queue.ts:159-161).


6. Claude Code Inside the Container — A Real Session

NanoClaw does not shell out to claude --print for one-shot answers. It runs a full, persistent Claude Code session inside each container via the Agent SDK. This is the architectural core that makes NanoClaw fundamentally different from a simple LLM API wrapper.

The Full Picture

┌─────────────────────────────────────────────────────────────────────────────┐
│                        HOST (Node.js Orchestrator)                         │
│                                                                            │
│  ┌──────────────┐   ┌──────────────────┐   ┌───────────────────────────┐   │
│  │  WhatsApp     │   │  SQLite          │   │  GroupQueue               │   │
│  │  Channel      │──▶│  Message Buffer  │──▶│                           │   │
│  │  (Baileys)    │   │                  │   │  Per-group state machine: │   │
│  └──────────────┘   └──────────────────┘   │  - active/idle tracking   │   │
│                                             │  - concurrency control    │   │
│                                             │  - retry with backoff     │   │
│                                             └───────────┬───────────────┘   │
│                                                         │                   │
│              ┌──────────────────────────────────────────┐│                   │
│              │          container-runner.ts             ││                   │
│              │                                          ││                   │
│              │  1. Build volume mounts (per-group)     ◀┘                   │
│              │  2. Sync skills → .claude/skills/                            │
│              │  3. Spawn: docker run -i --rm ...                            │
│              │  4. Write secrets to stdin (JSON)  ─────────┐                │
│              │  5. Parse stdout markers for results        │                │
│              └──────────────────────────────────────────┘  │                │
│                         ▲                                  │                │
│           stdout:       │              ┌───────────────────┘                │
│  ---NANOCLAW_OUTPUT     │              │  stdin (one-shot, then closed)     │
│  _START---              │              │                                    │
│  { result: "..." }      │              │  ┌──────────────────────────┐      │
│  ---NANOCLAW_OUTPUT     │              │  │ IPC Filesystem           │      │
│  _END---                │              │  │ data/ipc/{group}/        │      │
│                         │              │  │                          │      │
│                         │              │  │ input/*.json  (host→ctr) │      │
│                         │              │  │ input/_close  (sentinel) │      │
│                         │              │  │ messages/*.json (ctr→host│      │
│                         │              │  │ tasks/*.json  (ctr→host) │      │
│                         │              │  └──────────────────────────┘      │
│                         │              │         ▲           │              │
└─────────────────────────┼──────────────┼─────────┼───────────┼──────────────┘
                          │              │         │           │
══════════════════════════╪══════════════╪═════════╪═══════════╪══════════════
  Docker/Apple Container  │              │         │           │
  Boundary                │              │         │           │
══════════════════════════╪══════════════╪═════════╪═══════════╪══════════════
                          │              │         │           │
┌─────────────────────────┼──────────────┼─────────┼───────────┼──────────────┐
│                         │              ▼         │           ▼              │
│  CONTAINER (node:22-slim + Chromium + Claude Code CLI)                     │
│                                                                            │
│  ┌──────────────────────────────────────────────────────────────────────┐   │
│  │                     agent-runner/src/index.ts                        │   │
│  │                                                                      │   │
│  │  main()                                                              │   │
│  │    │                                                                 │   │
│  │    ├── Read ContainerInput from stdin (includes secrets)             │   │
│  │    ├── Delete /tmp/input.json (scrub secrets from disk)              │   │
│  │    ├── Build sdkEnv: { ...process.env, ...secrets }                 │   │
│  │    │   (secrets in SDK env only — never in process.env)              │   │
│  │    │                                                                 │   │
│  │    └── QUERY LOOP ◀──────────────────────────────────────────┐       │   │
│  │         │                                                     │       │   │
│  │         ▼                                                     │       │   │
│  │    ┌─────────────────────────────────────────────────────┐    │       │   │
│  │    │  runQuery(prompt, sessionId, ...)                   │    │       │   │
│  │    │                                                     │    │       │   │
│  │    │  ┌─────────────────────────────────────────────┐    │    │       │   │
│  │    │  │  MessageStream (AsyncIterable)              │    │    │       │   │
│  │    │  │                                             │    │    │       │   │
│  │    │  │  stream.push(prompt)  ◀── initial message   │    │    │       │   │
│  │    │  │  stream.push(text)   ◀── IPC poll (500ms)   │    │    │       │   │
│  │    │  │  stream.end()        ◀── _close sentinel    │    │    │       │   │
│  │    │  └─────────────────────────────────────────────┘    │    │       │   │
│  │    │           │                                         │    │       │   │
│  │    │           ▼                                         │    │       │   │
│  │    │  ┌─────────────────────────────────────────────┐    │    │       │   │
│  │    │  │  Claude Agent SDK: query({                  │    │    │       │   │
│  │    │  │    prompt: stream,        ◀── AsyncIterable │    │    │       │   │
│  │    │  │    cwd: '/workspace/group',                 │    │    │       │   │
│  │    │  │    resume: sessionId,     ◀── session cont. │    │    │       │   │
│  │    │  │    resumeSessionAt: uuid, ◀── exact point   │    │    │       │   │
│  │    │  │    env: sdkEnv,           ◀── secrets here  │    │    │       │   │
│  │    │  │    permissionMode: 'bypassPermissions',     │    │    │       │   │
│  │    │  │    allowedTools: [Bash, Read, Write, Edit,  │    │    │       │   │
│  │    │  │      Glob, Grep, WebSearch, WebFetch, Task, │    │    │       │   │
│  │    │  │      TeamCreate, mcp__nanoclaw__*, ...],     │    │    │       │   │
│  │    │  │    hooks: { PreCompact, PreToolUse(Bash) },  │    │    │       │   │
│  │    │  │    mcpServers: { nanoclaw: ipc-mcp-stdio }   │    │    │       │   │
│  │    │  │  })                                          │    │    │       │   │
│  │    │  │         │                                    │    │    │       │   │
│  │    │  │         ▼                                    │    │    │       │   │
│  │    │  │  ┌──────────────────────────────────────┐    │    │    │       │   │
│  │    │  │  │  REAL CLAUDE CODE PROCESS            │    │    │    │       │   │
│  │    │  │  │  (spawned by Agent SDK)              │    │    │    │       │   │
│  │    │  │  │                                      │    │    │    │       │   │
│  │    │  │  │  • Full agentic loop                 │    │    │    │       │   │
│  │    │  │  │  • Tool use (Bash, Read, Write, etc) │    │    │    │       │   │
│  │    │  │  │  • Subagent spawning (Task tool)     │    │    │    │       │   │
│  │    │  │  │  • Agent swarms (TeamCreate)         │    │    │    │       │   │
│  │    │  │  │  • Web search & fetch                │    │    │    │       │   │
│  │    │  │  │  • MCP tools (nanoclaw IPC bridge)   │    │    │    │       │   │
│  │    │  │  │  • Context compaction (auto)         │    │    │    │       │   │
│  │    │  │  │  • CLAUDE.md loading (project+user)  │    │    │    │       │   │
│  │    │  │  │  • Skills (/workspace/.claude/skills)│    │    │    │       │   │
│  │    │  │  └──────────────────────────────────────┘    │    │    │       │   │
│  │    │  │         │                                    │    │    │       │   │
│  │    │  │         ▼ streams messages back              │    │    │       │   │
│  │    │  │  for await (message of query(...))           │    │    │       │   │
│  │    │  │    ├── type: 'system/init' → capture sessionId    │    │       │   │
│  │    │  │    ├── type: 'assistant'   → capture UUID    │    │    │       │   │
│  │    │  │    └── type: 'result'      → writeOutput()  ─┼──stdout─┼──▶ HOST│   │
│  │    │  └─────────────────────────────────────────────┘    │       │   │
│  │    └─────────────────────────────────────────────────────┘       │   │
│  │         │                                                        │   │
│  │         ▼                                                        │   │
│  │    waitForIpcMessage()  ◀── blocks on /workspace/ipc/input/      │   │
│  │         │                                                        │   │
│  │         ├── New message file → prompt = nextMessage ──────────────┘   │
│  │         └── _close sentinel → break → container exits                │   │
│  │                                                                      │   │
│  └──────────────────────────────────────────────────────────────────────┘   │
│                                                                            │
│  ┌──────────────────────────────────────────────────────────────────────┐   │
│  │  ipc-mcp-stdio.ts (MCP Server — nanoclaw tools)                     │   │
│  │                                                                      │   │
│  │  Runs as a child process of Claude Code (via mcpServers config).     │   │
│  │  Provides tools the agent uses to communicate back to the host:      │   │
│  │                                                                      │   │
│  │  • send_message(jid, text)  → writes to /workspace/ipc/messages/    │   │
│  │  • create_task(schedule)    → writes to /workspace/ipc/tasks/        │   │
│  │  • pause_task(id)           → writes to /workspace/ipc/tasks/        │   │
│  │  • resume_task(id)          → writes to /workspace/ipc/tasks/        │   │
│  │  • delete_task(id)          → writes to /workspace/ipc/tasks/        │   │
│  └──────────────────────────────────────────────────────────────────────┘   │
│                                                                            │
│  Filesystem:                                                               │
│  /workspace/group      ← agent's working directory (read-write)            │
│  /workspace/project    ← host project root (read-only, main only)          │
│  /workspace/global     ← shared memory (read-only, non-main only)          │
│  /workspace/ipc        ← bidirectional IPC (read-write)                    │
│  /home/node/.claude    ← Claude sessions, settings, skills (read-write)    │
│  /app/src              ← agent-runner source (writable, per-group copy)    │
│                                                                            │
└────────────────────────────────────────────────────────────────────────────┘

Why This Matters: It's Not an API Call

The critical insight is the query() call at agent-runner/src/index.ts:417-456. It does not call the Anthropic API directly. Instead:

  1. The Agent SDK (@anthropic-ai/claude-agent-sdk) spawns a real Claude Code process — the same binary you run with claude in your terminal.
  2. Claude Code boots up with its full tool set: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task (subagents), TeamCreate (agent swarms).
  3. It reads CLAUDE.md files from the working directory and additional directories, loading project-specific instructions.
  4. It has access to NanoClaw-specific MCP tools (mcp__nanoclaw__*) via a sidecar MCP server (ipc-mcp-stdio.ts) that bridges agent actions to the host via IPC files.
  5. The session is persistent: resume: sessionId and resumeSessionAt: lastAssistantUuid give the agent full conversation history across multiple query rounds — exactly like running claude --resume in a terminal.

The MessageStream Trick

The MessageStream class (agent-runner/src/index.ts:66-96) is an AsyncIterable<SDKUserMessage> that the SDK consumes as its input source. This enables a key capability:

  • Mid-query message injection: While Claude Code is actively working (running tools, reasoning), the agent-runner polls /workspace/ipc/input/ every 500ms. New user messages are pushed into the MessageStream, and Claude sees them as additional user turns within the same active query. The user can say "actually, also do X" while the agent is still working on the original request.
  • Keeping the session alive: By using an AsyncIterable instead of a plain string prompt, the SDK keeps isSingleUserTurn = false, which allows subagents (Task tool) and agent swarms (TeamCreate) to run to completion without being cut off.

Secret Isolation (agent-runner/src/index.ts:511-516)

Secrets (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN) are passed via stdin as part of the ContainerInput JSON, then injected only into the sdkEnv object passed to query(). They are never set in process.env, so:

  • Bash commands spawned by Claude Code cannot read them via env or printenv
  • A PreToolUse hook on every Bash command (agent-runner/src/index.ts:193-210) additionally prepends unset commands as a defense-in-depth measure
  • The stdin input file is deleted immediately after reading (agent-runner/src/index.ts:500)

7. Provider Support

Anthropic only. No multi-provider abstraction exists.

Supported Authentication Methods

Method Environment Variable Use Case
API Key ANTHROPIC_API_KEY Pay-per-use API billing
OAuth Token CLAUDE_CODE_OAUTH_TOKEN Claude Pro/Max subscription

Both are functionally equivalent. The Claude Agent SDK wraps Claude Code, which handles authentication natively for both methods.

Why Subscriptions Work

The Agent SDK (@anthropic-ai/claude-agent-sdk) is not a direct API client — it is a programmatic interface for Claude Code. The SDK spawns a Claude Code process and passes the env object through. Claude Code itself recognizes CLAUDE_CODE_OAUTH_TOKEN and uses the subscription.

NanoClaw → Agent SDK query() → Claude Code process → detects OAUTH_TOKEN → uses subscription

Setup: Run claude setup-token in a terminal, copy the token, add CLAUDE_CODE_OAUTH_TOKEN=<token> to .env.

Why No Multi-Provider

Intentional design choice. From the README: "Best harness, best model. Claude Code is (IMO) the best harness available." Multi-provider support would multiply complexity (different tool formats, capabilities, rate limits) for marginal benefit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment