A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
- Architecture
- Architecture: Channel System
- Folder Structure
- Configuration
- Memory System
- Session Management
- Message Flow
- Commands
- Scheduled Tasks
- MCP Servers
- Deployment
- Security Considerations
┌──────────────────────────────────────────────────────────────────────┐
│ 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 callsregisterChannel()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.tsimports 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:
- Adds a
src/channels/<name>.tsfile implementing theChannelinterface - Calls
registerChannel(name, factory)at module load - Returns
nullfrom the factory if credentials are missing - Adds an import line to
src/channels/index.ts
See 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("1234567890@g.us", {
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 startOr edit the default in src/config.ts. This changes:
- The trigger pattern (messages must start with
@YourName) - The response prefix (
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 viawhich node){{HOME}}- User's home directory
NanoClaw 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
- Agent runs with
cwdset togroups/{group-name}/ - Claude Agent SDK with
settingSources: ['project']automatically loads:../CLAUDE.md(parent directory = global memory)./CLAUDE.md(current directory = group memory)
- Agent runs with
-
Writing Memory
- When user says "remember this", agent writes to
./CLAUDE.md - When user says "remember this globally" (main channel only), agent writes to
../CLAUDE.md - Agent can create files like
notes.md,research.mdin the group folder
- When user says "remember this", agent writes to
-
Main Channel Privileges
- Only the "main" group (self-chat) can write to global memory
- Main can manage registered groups and schedule tasks for any group
- Main can configure additional directory mounts for any group
- All groups have Bash access (safe because it runs inside container)
Sessions enable conversation continuity - Claude remembers what you talked about.
- Each group has a session ID stored in SQLite (
sessionstable, keyed bygroup_folder) - Session ID is passed to Claude Agent SDK's
resumeoption - Claude continues the conversation with full context
- Session transcripts are stored as JSONL files in
data/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.
- Group Context: Tasks created in a group run with that group's working directory and memory
- Full Agent Capabilities: Scheduled tasks have access to all tools (WebSearch, file operations, etc.)
- Optional Messaging: Tasks can send messages to their group using the
send_messagetool, or complete silently - Main Channel Privileges: The main channel can schedule tasks for any group and view all tasks
| 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 task
From main channel:
@Andy list all tasks- View tasks from all groups@Andy schedule task for "Family Chat": [prompt]- Schedule for another group
The 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:
- Ensures container runtime is running - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
- Initializes the SQLite database (migrates from JSON files if they exist)
- Loads state from SQLite (registered groups, sessions, router state)
- Connects channels — loops through registered channels, instantiates those with credentials, calls
connect()on each - Once at least one channel is connected:
- Starts the scheduler loop
- Starts the IPC watcher for container messages
- Sets up the per-group queue with
processGroupMessages - Recovers any unprocessed messages from before shutdown
- Starts the message polling loop
launchd/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.logAll agents run inside containers (lightweight Linux VMs), providing:
- Filesystem isolation: Agents can only access mounted directories
- Safe Bash access: Commands run inside the container, not on your Mac
- Network isolation: Can be configured per-container if needed
- Process isolation: Container processes can't affect the host
- Non-root user: Container runs as unprivileged
nodeuser (uid 1000)
WhatsApp messages could contain malicious instructions attempting to manipulate Claude's behavior.
Mitigations:
- Container isolation limits blast radius
- Only registered groups are processed
- Trigger word required (reduces accidental processing)
- Agents can only access their group's mounted directories
- Main can configure additional directories per group
- Claude's built-in safety training
Recommendations:
- Only register trusted groups
- Review additional directory mounts carefully
- Review scheduled tasks periodically
- Monitor logs for unusual activity
| 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- stderr
Run manually for verbose output:
npm run dev
# or
node dist/index.js