Deep-dive into NanoClaw internals, covering message flow, container isolation, session lifecycle, and provider support.
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
-
WhatsApp listener (
src/channels/whatsapp.ts:149-203): Baileys library listens formessages.upsertevents. For registered groups, text content is extracted (conversation, extended text, image/video captions) and passed to the orchestrator viaonMessagecallback. -
SQLite storage (
src/index.ts:441): Every incoming message is written to the database, decoupling reception from processing. -
Polling loop (
src/index.ts:309-398): Runs every 2 seconds (POLL_INTERVAL). Fetches unprocessed messages from SQLite viagetNewMessages(). -
Trigger filtering (
src/index.ts:352-362): Non-main groups only trigger the agent if a message matchesTRIGGER_PATTERN(e.g.,@Andy). The main group processes all messages without requiring a trigger. -
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
GroupQueueto spawn a new container
- Piped via IPC to an already-running container (
-
Agent processing (
container/agent-runner/src/index.ts:357-491): The agent-runner reads the prompt, calls the Claude Agent SDK'squery()function, and streams results back through stdout markers (---NANOCLAW_OUTPUT_START---/---NANOCLAW_OUTPUT_END---).
- 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.
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.
A separate infinite loop started at app startup (src/index.ts:453):
- Polls the database every 60 seconds (
SCHEDULER_POLL_INTERVAL) getDueTasks()retrieves tasks wherenext_run <= now- Each due task is enqueued to
GroupQueueand 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 |
- Group mode: Uses the group's existing session (has conversation history)
- Isolated mode: Fresh session with no prior context
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.
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.
| 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 |
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
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.
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);
}Each query() call uses resume: sessionId and resumeSessionAt: lastAssistantUuid. This gives the agent full conversation history across multiple query rounds — equivalent to claude --resume.
The session ends when:
_closesentinel is written toipc/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
Bidirectional communication uses file-based IPC on a shared Docker volume mount (/workspace/ipc).
| 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.
| 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).
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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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) │
│ │
└────────────────────────────────────────────────────────────────────────────┘
The critical insight is the query() call at agent-runner/src/index.ts:417-456. It does not call the Anthropic API directly. Instead:
- The Agent SDK (
@anthropic-ai/claude-agent-sdk) spawns a real Claude Code process — the same binary you run withclaudein your terminal. - Claude Code boots up with its full tool set:
Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Task(subagents),TeamCreate(agent swarms). - It reads
CLAUDE.mdfiles from the working directory and additional directories, loading project-specific instructions. - 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. - The session is persistent:
resume: sessionIdandresumeSessionAt: lastAssistantUuidgive the agent full conversation history across multiple query rounds — exactly like runningclaude --resumein a terminal.
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 theMessageStream, 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
AsyncIterableinstead of a plain string prompt, the SDK keepsisSingleUserTurn = false, which allows subagents (Task tool) and agent swarms (TeamCreate) to run to completion without being cut off.
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
envorprintenv - A
PreToolUsehook on every Bash command (agent-runner/src/index.ts:193-210) additionally prependsunsetcommands as a defense-in-depth measure - The stdin input file is deleted immediately after reading (
agent-runner/src/index.ts:500)
Anthropic only. No multi-provider abstraction exists.
| 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.
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.
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.