docs/SPEC.md
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
┌──────────────────────────────────────────────────────────────────────┐
│ HOST (macOS / Linux) │
│ (Main Node.js Process) │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ Channels │─────────────────▶│ SQLite Database │ │
│ │ (self-register │◀────────────────│ (messages.db) │ │
│ │ at startup) │ store/send └─────────┬──────────┘ │
│ └──────────────────┘ │ │
│ │ │
│ ┌─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │
│ │ (polls SQLite) │ │ (checks tasks) │ │ (file-based) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │
│ │ │ │
│ └───────────┬───────────┘ │
│ │ spawns container │
│ ▼ │
├──────────────────────────────────────────────────────────────────────┤
│ CONTAINER (Linux VM) │
├──────────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ AGENT RUNNER │ │
│ │ │ │
│ │ Working directory: /workspace/group (mounted from host) │ │
│ │ Volume mounts: │ │
│ │ • groups/{name}/ → /workspace/group │ │
│ │ • groups/global/ → /workspace/global/ (non-main only) │ │
│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │
│ │ • Additional dirs → /workspace/extra/* │ │
│ │ │ │
│ │ Tools (all groups): │ │
│ │ • Bash (safe - sandboxed in container!) │ │
│ │ • Read, Write, Edit, Glob, Grep (file operations) │ │
│ │ • WebSearch, WebFetch (internet access) │ │
│ │ • agent-browser (browser automation) │ │
│ │ • mcp__nanoclaw__* (scheduler tools via IPC) │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
| Component | Technology | Purpose |
|---|---|---|
| Channel System | Channel registry (src/channels/registry.ts) | Channels self-register at startup |
| Message Storage | SQLite (better-sqlite3) | Store messages for polling |
| Container Runtime | Containers (Linux VMs) | Isolated environments for agent execution |
| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers |
| Browser Automation | agent-browser + Chromium | Web interaction and screenshots |
| Runtime | Node.js 20+ | Host process for routing and scheduling |
The core ships with no channels built in — each channel (WhatsApp, Telegram, Slack, Discord, Gmail) is installed as a Claude Code skill that adds the channel code to your fork. Channels self-register at startup; installed channels with missing credentials emit a WARN log and are skipped.
graph LR
subgraph Channels["Channels"]
WA[WhatsApp]
TG[Telegram]
SL[Slack]
DC[Discord]
New["Other Channel (Signal, Gmail...)"]
end
subgraph Orchestrator["Orchestrator — index.ts"]
ML[Message Loop]
GQ[Group Queue]
RT[Router]
TS[Task Scheduler]
DB[(SQLite)]
end
subgraph Execution["Container Execution"]
CR[Container Runner]
LC["Linux Container"]
IPC[IPC Watcher]
end
%% Flow
WA & TG & SL & DC & New -->|onMessage| ML
ML --> GQ
GQ -->|concurrency| CR
CR --> LC
LC -->|filesystem IPC| IPC
IPC -->|tasks & messages| RT
RT -->|Channel.sendMessage| Channels
TS -->|due tasks| CR
%% DB Connections
DB <--> ML
DB <--> TS
%% Styling for the dynamic channel
style New stroke-dasharray: 5 5,stroke-width:2px
The channel system is built on a factory registry in src/channels/registry.ts:
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
const registry = new Map<string, ChannelFactory>();
export function registerChannel(name: string, factory: ChannelFactory): void {
registry.set(name, factory);
}
export function getChannelFactory(name: string): ChannelFactory | undefined {
return registry.get(name);
}
export function getRegisteredChannelNames(): string[] {
return [...registry.keys()];
}
Each factory receives ChannelOpts (callbacks for onMessage, onChatMetadata, and registeredGroups) and returns either a Channel instance or null if that channel's credentials are not configured.
Every channel implements this interface (defined in src/types.ts):
interface Channel {
name: string;
connect(): Promise<void>;
sendMessage(jid: string, text: string): Promise<void>;
isConnected(): boolean;
ownsJid(jid: string): boolean;
disconnect(): Promise<void>;
setTyping?(jid: string, isTyping: boolean): Promise<void>;
syncGroups?(force: boolean): Promise<void>;
}
Channels self-register using a barrel-import pattern:
Each channel skill adds a file to src/channels/ (e.g. whatsapp.ts, telegram.ts) that calls registerChannel() at module load time:
// src/channels/whatsapp.ts
import { registerChannel, ChannelOpts } from './registry.js';
export class WhatsAppChannel implements Channel { /* ... */ }
registerChannel('whatsapp', (opts: ChannelOpts) => {
// Return null if credentials are missing
if (!existsSync(authPath)) return null;
return new WhatsAppChannel(opts);
});
The barrel file src/channels/index.ts imports all channel modules, triggering registration:
import './whatsapp.js';
import './telegram.js';
// ... each skill adds its import here
At startup, the orchestrator (src/index.ts) loops through registered channels and connects whichever ones return a valid instance:
for (const name of getRegisteredChannelNames()) {
const factory = getChannelFactory(name);
const channel = factory?.(channelOpts);
if (channel) {
await channel.connect();
channels.push(channel);
}
}
| File | Purpose |
|---|---|
src/channels/registry.ts | Channel factory registry |
src/channels/index.ts | Barrel imports that trigger channel self-registration |
src/types.ts | Channel interface, ChannelOpts, message types |
src/index.ts | Orchestrator — instantiates channels, runs message loop |
src/router.ts | Finds the owning channel for a JID, formats messages |
To add a new channel, contribute a skill to .claude/skills/add-<name>/ that:
src/channels/<name>.ts file implementing the Channel interfaceregisterChannel(name, factory) at module loadnull from the factory if credentials are missingsrc/channels/index.tsSee existing skills (/add-whatsapp, /add-telegram, /add-slack, /add-discord, /add-gmail) for the pattern.
nanoclaw/
├── CLAUDE.md # Project context for Claude Code
├── docs/
│ ├── SPEC.md # This specification document
│ ├── REQUIREMENTS.md # Architecture decisions
│ └── SECURITY.md # Security model
├── README.md # User documentation
├── package.json # Node.js dependencies
├── tsconfig.json # TypeScript configuration
├── .mcp.json # MCP server configuration (reference)
├── .gitignore
│
├── src/
│ ├── index.ts # Orchestrator: state, message loop, agent invocation
│ ├── channels/
│ │ ├── registry.ts # Channel factory registry
│ │ └── index.ts # Barrel imports for channel self-registration
│ ├── ipc.ts # IPC watcher and task processing
│ ├── router.ts # Message formatting and outbound routing
│ ├── config.ts # Configuration constants
│ ├── types.ts # TypeScript interfaces (includes Channel)
│ ├── logger.ts # Pino logger setup
│ ├── db.ts # SQLite database initialization and queries
│ ├── group-queue.ts # Per-group queue with global concurrency limit
│ ├── mount-security.ts # Mount allowlist validation for containers
│ ├── whatsapp-auth.ts # Standalone WhatsApp authentication
│ ├── task-scheduler.ts # Runs scheduled tasks when due
│ └── container-runner.ts # Spawns agents in containers
│
├── container/
│ ├── Dockerfile # Container image (runs as 'node' user, includes Claude Code CLI)
│ ├── build.sh # Build script for container image
│ ├── agent-runner/ # Code that runs inside the container
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts # Entry point (query loop, IPC polling, session resume)
│ │ └── ipc-mcp-stdio.ts # Stdio-based MCP server for host communication
│ └── skills/
│ └── agent-browser.md # Browser automation skill
│
├── dist/ # Compiled JavaScript (gitignored)
│
├── .claude/
│ └── skills/
│ ├── setup/SKILL.md # /setup - First-time installation
│ ├── customize/SKILL.md # /customize - Add capabilities
│ ├── debug/SKILL.md # /debug - Container debugging
│ ├── add-telegram/SKILL.md # /add-telegram - Telegram channel
│ ├── add-gmail/SKILL.md # /add-gmail - Gmail integration
│ ├── add-voice-transcription/ # /add-voice-transcription - Whisper
│ ├── x-integration/SKILL.md # /x-integration - X/Twitter
│ ├── convert-to-apple-container/ # /convert-to-apple-container - Apple Container runtime
│ └── add-parallel/SKILL.md # /add-parallel - Parallel agents
│
├── groups/
│ ├── CLAUDE.md # Global memory (all groups read this)
│ ├── {channel}_main/ # Main control channel (e.g., whatsapp_main/)
│ │ ├── CLAUDE.md # Main channel memory
│ │ └── logs/ # Task execution logs
│ └── {channel}_{group-name}/ # Per-group folders (created on registration)
│ ├── CLAUDE.md # Group-specific memory
│ ├── logs/ # Task logs for this group
│ └── *.md # Files created by the agent
│
├── store/ # Local data (gitignored)
│ ├── auth/ # WhatsApp authentication state
│ └── messages.db # SQLite database (messages, chats, scheduled_tasks, task_run_logs, registered_groups, sessions, router_state)
│
├── data/ # Application state (gitignored)
│ ├── sessions/ # Per-group session data (.claude/ dirs with JSONL transcripts)
│ ├── env/env # Copy of .env for container mounting
│ └── ipc/ # Container IPC (messages/, tasks/)
│
├── logs/ # Runtime logs (gitignored)
│ ├── nanoclaw.log # Host stdout
│ └── nanoclaw.error.log # Host stderr
│ # Note: Per-container logs are in groups/{folder}/logs/container-*.log
│
└── launchd/
└── com.nanoclaw.plist # macOS service configuration
Configuration constants are in src/config.ts:
import path from 'path';
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy';
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
// Paths are absolute (required for container mounts)
const PROJECT_ROOT = process.cwd();
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
// Container configuration
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default
export const IPC_POLL_INTERVAL = 1000;
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
Note: Paths must be absolute for container volume mounts to work correctly.
Groups can have additional directories mounted via containerConfig in the SQLite registered_groups table (stored as JSON in the container_config column). Example registration:
setRegisteredGroup("[email protected]", {
name: "Dev Team",
folder: "whatsapp_dev-team",
trigger: "@Andy",
added_at: new Date().toISOString(),
containerConfig: {
additionalMounts: [
{
hostPath: "~/projects/webapp",
containerPath: "webapp",
readonly: false,
},
],
timeout: 600000,
},
});
Folder names follow the convention {channel}_{group-name} (e.g., whatsapp_family-chat, telegram_dev-team). The main group has isMain: true set during registration.
Additional mounts appear at /workspace/extra/{containerPath} inside the container.
Mount syntax note: Read-write mounts use -v host:container, but readonly mounts require --mount "type=bind,source=...,target=...,readonly" (the :ro suffix may not work on all runtimes).
Configure authentication in a .env file in the project root. Two options:
Option 1: Claude Subscription (OAuth token)
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
The token can be extracted from ~/.claude/.credentials.json if you're logged in to Claude Code.
Option 2: Pay-per-use API Key
ANTHROPIC_API_KEY=sk-ant-api03-...
Only the authentication variables (CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY) are extracted from .env and written to data/env/env, then mounted into the container at /workspace/env-dir/env and sourced by the entrypoint script. This ensures other environment variables in .env are not exposed to the agent. This workaround is needed because some container runtimes lose -e environment variables when using -i (interactive mode with piped stdin).
Set the ASSISTANT_NAME environment variable:
ASSISTANT_NAME=Bot npm start
Or edit the default in src/config.ts. This changes:
@YourName)YourName: added automatically)Files with {{PLACEHOLDER}} values need to be configured:
{{PROJECT_ROOT}} - Absolute path to your nanoclaw installation{{NODE_PATH}} - Path to node binary (detected via which node){{HOME}} - User's home directoryNanoClaw uses a hierarchical memory system based on CLAUDE.md files.
| Level | Location | Read By | Written By | Purpose |
|---|---|---|---|---|
| Global | groups/CLAUDE.md | All groups | Main only | Preferences, facts, context shared across all conversations |
| Group | groups/{name}/CLAUDE.md | That group | That group | Group-specific context, conversation memory |
| Files | groups/{name}/*.md | That group | That group | Notes, research, documents created during conversation |
Agent Context Loading
cwd set to groups/{group-name}/settingSources: ['project'] automatically loads:
../CLAUDE.md (parent directory = global memory)./CLAUDE.md (current directory = group memory)Writing Memory
./CLAUDE.md../CLAUDE.mdnotes.md, research.md in the group folderMain Channel Privileges
Sessions enable conversation continuity - Claude remembers what you talked about.
sessions table, keyed by group_folder)resume optiondata/sessions/{group}/.claude/1. User sends a message via any connected channel
│
▼
2. Channel receives message (e.g. Baileys for WhatsApp, Bot API for Telegram)
│
▼
3. Message stored in SQLite (store/messages.db)
│
▼
4. Message loop polls SQLite (every 2 seconds)
│
▼
5. Router checks:
├── Is chat_jid in registered groups (SQLite)? → No: ignore
└── Does message match trigger pattern? → No: store but don't process
│
▼
6. Router catches up conversation:
├── Fetch all messages since last agent interaction
├── Format with timestamp and sender name
└── Build prompt with full conversation context
│
▼
7. Router invokes Claude Agent SDK:
├── cwd: groups/{group-name}/
├── prompt: conversation history + current message
├── resume: session_id (for continuity)
└── mcpServers: nanoclaw (scheduler)
│
▼
8. Claude processes message:
├── Reads CLAUDE.md files for context
└── Uses tools as needed (search, email, etc.)
│
▼
9. Router prefixes response with assistant name and sends via the owning channel
│
▼
10. Router updates last agent timestamp and saves session ID
Messages must start with the trigger pattern (default: @Andy):
@Andy what's the weather? → ✅ Triggers Claude@andy help me → ✅ Triggers (case insensitive)Hey @Andy → ❌ Ignored (trigger not at start)What's up? → ❌ Ignored (no trigger)When a triggered message arrives, the agent receives all messages since its last interaction in that chat. Each message is formatted with timestamp and sender name:
[Jan 31 2:32 PM] John: hey everyone, should we do pizza tonight?
[Jan 31 2:33 PM] Sarah: sounds good to me
[Jan 31 2:35 PM] John: @Andy what toppings do you recommend?
This allows the agent to understand the conversation context even if it wasn't mentioned in every message.
| Command | Example | Effect |
|---|---|---|
@Assistant [message] | @Andy what's the weather? | Talk to Claude |
| Command | Example | Effect |
|---|---|---|
@Assistant add group "Name" | @Andy add group "Family Chat" | Register a new group |
@Assistant remove group "Name" | @Andy remove group "Work Team" | Unregister a group |
@Assistant list groups | @Andy list groups | Show registered groups |
@Assistant remember [fact] | @Andy remember I prefer dark mode | Add to global memory |
NanoClaw has a built-in scheduler that runs tasks as full agents in their group's context.
send_message tool, or complete silently| Type | Value Format | Example |
|---|---|---|
cron | Cron expression | 0 9 * * 1 (Mondays at 9am) |
interval | Milliseconds | 3600000 (every hour) |
once | ISO timestamp | 2024-12-25T09:00:00Z |
User: @Andy remind me every Monday at 9am to review the weekly metrics
Claude: [calls mcp__nanoclaw__schedule_task]
{
"prompt": "Send a reminder to review weekly metrics. Be encouraging!",
"schedule_type": "cron",
"schedule_value": "0 9 * * 1"
}
Claude: Done! I'll remind you every Monday at 9am.
User: @Andy at 5pm today, send me a summary of today's emails
Claude: [calls mcp__nanoclaw__schedule_task]
{
"prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.",
"schedule_type": "once",
"schedule_value": "2024-01-31T17:00:00Z"
}
From any group:
@Andy list my scheduled tasks - View tasks for this group@Andy pause task [id] - Pause a task@Andy resume task [id] - Resume a paused task@Andy cancel task [id] - Delete a taskFrom main channel:
@Andy list all tasks - View tasks from all groups@Andy schedule task for "Family Chat": [prompt] - Schedule for another groupThe nanoclaw MCP server is created dynamically per agent call with the current group's context.
Available Tools:
| Tool | Purpose |
|---|---|
schedule_task | Schedule a recurring or one-time task |
list_tasks | Show tasks (group's tasks, or all if main) |
get_task | Get task details and run history |
update_task | Modify task prompt or schedule |
pause_task | Pause a task |
resume_task | Resume a paused task |
cancel_task | Delete a task |
send_message | Send a message to the group via its channel |
NanoClaw runs as a single macOS launchd service.
When NanoClaw starts, it:
connect() on eachprocessGroupMessageslaunchd/com.nanoclaw.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>{{NODE_PATH}}</string>
<string>{{PROJECT_ROOT}}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>{{PROJECT_ROOT}}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>{{HOME}}</string>
<key>ASSISTANT_NAME</key>
<string>Andy</string>
</dict>
<key>StandardOutPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
</dict>
</plist>
# Install service
cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/
# Start service
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Stop service
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
# Check status
launchctl list | grep nanoclaw
# View logs
tail -f logs/nanoclaw.log
All agents run inside containers (lightweight Linux VMs), providing:
node user (uid 1000)WhatsApp messages could contain malicious instructions attempting to manipulate Claude's behavior.
Mitigations:
Recommendations:
| Credential | Storage Location | Notes |
|---|---|---|
| Claude CLI Auth | data/sessions/{group}/.claude/ | Per-group isolation, mounted to /home/node/.claude/ |
| WhatsApp Session | store/auth/ | Auto-created, persists ~20 days |
The groups/ folder contains personal memory and should be protected:
chmod 700 groups/
| Issue | Cause | Solution |
|---|---|---|
| No response to messages | Service not running | Check `launchctl list |
| "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; NanoClaw auto-starts container runtime but may fail |
| "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to /home/node/.claude/ not /root/.claude/ |
| Session not continuing | Session ID not saved | Check SQLite: sqlite3 store/messages.db "SELECT * FROM sessions" |
| Session not continuing | Mount path mismatch | Container user is node with HOME=/home/node; sessions must be at /home/node/.claude/ |
| "QR code expired" | WhatsApp session expired | Delete store/auth/ and restart |
| "No groups registered" | Haven't added groups | Use @Andy add group "Name" in main |
logs/nanoclaw.log - stdoutlogs/nanoclaw.error.log - stderrRun manually for verbose output:
npm run dev
# or
node dist/index.js