Personal Claude assistant on WhatsApp. Single Node.js process that routes messages to Claude Agent SDK running in Apple Container (Linux VMs). Each chat gets an isolated filesystem, persistent memory, and scheduled tasks.
Built from scratch as a learning exercise. ~600 lines of code. See COMPARISON.md for a detailed comparison with NanoClaw, nanobot, and OpenClaw.
WhatsApp ──→ SQLite ──→ Polling Loop ──→ Apple Container ──→ Claude Agent SDK
↑ │
└───────────── IPC (filesystem) ────────────┘
Host (Node.js): Connects to WhatsApp, stores messages in SQLite, spawns containers per request.
Container (Linux VM): Runs Claude with full tool access (Bash, Read, Write, Edit, WebSearch, etc.) plus custom MCP tools for messaging and scheduling. Destroyed after each invocation.
IPC: Bidirectional communication via atomic file writes in mounted directories. Host writes follow-up messages to inbox; agent writes commands (send_message, schedule_task) to outbox.
theo/
├── src/
│ ├── index.ts # Orchestrator: message loop, follow-up handling
│ ├── whatsapp.ts # WhatsApp connection (baileys)
│ ├── container-runner.ts # Spawns agent containers with mounts
│ ├── db.ts # SQLite: messages, groups, sessions, tasks
│ ├── ipc.ts # Host-side IPC watcher (agent → host)
│ └── task-scheduler.ts # Cron/interval/one-time task execution
├── container/
│ ├── Dockerfile
│ ├── entrypoint.sh # DNS fix + drop to non-root user
│ └── runner/src/
│ ├── index.ts # Claude Agent SDK runner
│ └── ipc-mcp-stdio.ts # MCP server: send_message, schedule_task, check_new_messages
├── groups/{name}/ # Per-chat isolated workspace (created at runtime)
│ └── CLAUDE.md # Persistent memory per chat
├── .data/ # Runtime data (created at runtime)
│ ├── ipc/{group}/ # IPC directories (messages, tasks, inbox)
│ └── sessions/{group}/ # Claude session transcripts
├── auth/ # WhatsApp auth state (created on first connect)
└── .env # ANTHROPIC_API_KEY
- macOS 26+ with Apple Silicon
- Apple Container installed and running (
container system start) - Node.js 22+
- Anthropic API key
# Install dependencies
npm install
cd container/runner && npm install && cd ../..
# Add your API key
echo 'ANTHROPIC_API_KEY=sk-ant-...' > .env
# Build the runner (compiled on host, copied into container)
cd container/runner && npx tsc && cd ../..
# Build the container image
container build -t theo:latest container/
# Start
npm run devScan the QR code with WhatsApp on first run. Send @theo hello from any chat to auto-register it and start talking.
- Trigger: Messages starting with
@theoactivate the agent - Container: Each invocation spawns a fresh Linux VM with the chat's workspace mounted at
/workspace - Agent: Claude runs with full tool access, reads/writes files in the workspace, can search the web
- Memory:
CLAUDE.mdin each group folder persists across conversations. Session transcripts enable Claude to remember prior context viaresume - Follow-ups: Messages sent while the agent is working get queued. After the agent finishes, queued messages trigger a follow-up invocation with session resume
- Tasks: The agent can schedule recurring or one-time tasks via MCP tools. A scheduler on the host executes them automatically
| Tool | Description |
|---|---|
send_message |
Send a WhatsApp message to the current chat |
schedule_task |
Schedule a task: cron ("09:00", "mon 09:00"), interval (ms), or once (ISO timestamp) |
list_tasks |
List scheduled tasks for this chat |
check_new_messages |
Check if the user sent follow-up messages while working |
Each invocation gets:
/workspace— the chat's own directory (read-write)/home/node/.claude— session transcripts (persistent)/workspace/ipc— IPC communication directory- Non-root user (
node) - Ephemeral (
--rm) — destroyed after completion - DNS override to
8.8.8.8(Apple Container 0.9.0 workaround)
- Builder has no network: TypeScript is compiled on the host and copied into the container.
npm installalso runs on the host - Container DNS broken: Apple Container 0.9.0's gateway DNS forwarder doesn't work.
entrypoint.shoverrides/etc/resolv.confat runtime - SDK exits with code 1: Claude Agent SDK sometimes throws after yielding a result. The runner captures the result before the error and uses it anyway
- Builder cache: Apple Container caches aggressively. To force a clean rebuild:
container builder stop && container builder rm && container builder start