Initial commit: NanoClaw - Personal Claude assistant via WhatsApp

A minimal Node.js application that connects Claude Agent SDK to WhatsApp
using baileys. Features per-group memory via CLAUDE.md files, session
continuity, scheduled tasks, and Gmail integration via MCP.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-01-31 18:54:24 +02:00
commit c17823a732
16 changed files with 4375 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
---
name: customize
description: Add new capabilities or modify NanoClaw behavior. Use when user wants to add channels (Telegram, Slack, email input), change triggers, add integrations, modify the router, or make any other customizations. This is an interactive skill that asks questions to understand what the user wants.
---
# NanoClaw Customization
This skill helps users add capabilities or modify behavior. Use AskUserQuestion to understand what they want before making changes.
## Workflow
1. **Understand the request** - Ask clarifying questions
2. **Plan the changes** - Identify files to modify
3. **Implement** - Make changes directly to the code
4. **Test guidance** - Tell user how to verify
## Key Files
| File | Purpose |
|------|---------|
| `src/config.py` | Assistant name, trigger pattern, settings |
| `src/router.py` | Message routing, polling, agent invocation |
| `src/scheduler_worker.py` | Scheduled task execution |
| `src/commands.py` | Command handlers |
| `.mcp.json` | MCP server configuration |
| `groups/CLAUDE.md` | Global memory/persona |
## Common Customization Patterns
### Adding a New Input Channel (e.g., Telegram, Slack, Email)
Questions to ask:
- Which channel? (Telegram, Slack, Discord, email, SMS, etc.)
- Same trigger word or different?
- Same memory hierarchy or separate?
- Should messages from this channel go to existing groups or new ones?
Implementation pattern:
1. Find/add MCP server for the channel to `.mcp.json`
2. Add polling function in `router.py` (similar to `get_new_messages()`)
3. Add to main loop to poll both sources
4. Ensure responses route back to correct channel
### Adding a New MCP Integration
Questions to ask:
- What service? (Calendar, Notion, database, etc.)
- What operations needed? (read, write, both)
- Which groups should have access?
Implementation:
1. Add MCP server config to `.mcp.json`
2. Add tools to `allowed_tools` in `router.py`
3. Document in `groups/CLAUDE.md`
### Changing Assistant Behavior
Questions to ask:
- What aspect? (name, trigger, persona, response style)
- Apply to all groups or specific ones?
Simple changes → edit `src/config.py`
Persona changes → edit `groups/CLAUDE.md`
Per-group behavior → edit specific group's `CLAUDE.md`
### Adding New Commands
Questions to ask:
- What should the command do?
- Available in all groups or main only?
- Does it need new MCP tools?
Implementation:
1. Add handler function in `src/commands.py`
2. Claude will recognize natural language and call the function
### Changing Deployment
Questions to ask:
- Target platform? (Linux server, Docker, different Mac)
- Service manager? (systemd, Docker, supervisord)
Implementation:
1. Create appropriate service files
2. Update paths in config
3. Provide setup instructions
## After Changes
Always tell the user:
```bash
# Restart to apply changes
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.router.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.router.plist
```
## Example Interaction
User: "Add Telegram as an input channel"
1. Ask: "Should Telegram use the same @Andy trigger, or a different one?"
2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?"
3. Find Telegram MCP (e.g., telegram-mcp)
4. Add polling for Telegram in router.py
5. Update .mcp.json
6. Tell user how to authenticate and test

View File

@@ -0,0 +1,186 @@
---
name: setup
description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate WhatsApp/Gmail, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests.
---
# NanoClaw Setup
**IMPORTANT**: Run all commands automatically. Only pause for user action when physical interaction is required (scanning QR codes). Give clear instructions for exactly what the user needs to do.
## 1. Check Prerequisites
Run these checks. Install any that are missing:
```bash
python3 --version # Need 3.10+
node --version # Need 18+
uv --version
```
If missing, install automatically:
- **uv**: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- **node**: `brew install node`
- **python**: `brew install python@3.10`
## 2. Install Dependencies
Run all of these automatically:
```bash
# Python dependencies
uv venv && source .venv/bin/activate && uv pip install -r requirements.txt
```
```bash
# WhatsApp bridge dependencies
cd bridge && npm install
```
```bash
# Create logs directory
mkdir -p logs
```
## 3. WhatsApp Authentication
**USER ACTION REQUIRED**
Run the bridge in background and monitor for connection:
```bash
cd bridge && node bridge.js > /tmp/bridge_output.log 2>&1 &
BRIDGE_PID=$!
```
Tell the user:
> A QR code will appear below. On your phone:
> 1. Open WhatsApp
> 2. Tap **Settings → Linked Devices → Link a Device**
> 3. Scan the QR code
Then poll for either QR code or successful connection (check every 2 seconds for up to 3 minutes):
```bash
cat /tmp/bridge_output.log # Look for QR code or "Connected to WhatsApp!"
```
When you see "Connected to WhatsApp!" in the output, stop the bridge:
```bash
kill $BRIDGE_PID
```
Session persists until logged out from WhatsApp.
## 4. Gmail Authentication (Optional)
**Skip this step** unless user specifically needs Gmail integration. It requires Google Cloud Platform OAuth credentials setup.
If needed, user must first:
1. Create a GCP project
2. Enable Gmail API
3. Create OAuth 2.0 credentials
4. Download credentials to `~/.gmail-mcp/gcp-oauth.keys.json`
Then run:
```bash
npx -y @gongrzhe/server-gmail-autoauth-mcp
```
## 5. Register Main Channel
Ask the user:
> Do you want to use a **personal chat** (message yourself) or a **WhatsApp group** as your main channel?
For personal chat:
> Send a test message to yourself in WhatsApp. Tell me when done.
For group:
> Send a message in the WhatsApp group you want to use. Tell me when done.
After user confirms, find the JID:
```bash
# For personal chat
sqlite3 bridge/store/messages.db "SELECT DISTINCT chat_jid FROM messages WHERE chat_jid NOT LIKE '%@g.us' ORDER BY rowid DESC LIMIT 5"
# For group
sqlite3 bridge/store/messages.db "SELECT DISTINCT chat_jid FROM messages WHERE chat_jid LIKE '%@g.us' ORDER BY rowid DESC LIMIT 5"
```
Read the assistant name from `src/config.py` (look for `ASSISTANT_NAME = "..."`).
Then update `data/registered_groups.json`:
```json
{
"THE_JID_HERE": {
"name": "main",
"folder": "main",
"trigger": "@AssistantName",
"added_at": "CURRENT_TIMESTAMP_ISO"
}
}
```
## 6. Configure launchd
First, detect the actual paths:
```bash
which node # Get actual node path (may be nvm, homebrew, etc.)
```
Create plist files directly in `~/Library/LaunchAgents/` with:
**com.nanoclaw.bridge.plist:**
- ProgramArguments: `[actual_node_path, /Users/.../nanoclaw/bridge/bridge.js]`
- WorkingDirectory: `/Users/.../nanoclaw/bridge`
- StandardOutPath/StandardErrorPath: `/Users/.../nanoclaw/logs/bridge.log` and `bridge.error.log`
**com.nanoclaw.router.plist:**
- ProgramArguments: `[/Users/.../nanoclaw/.venv/bin/python, -u, /Users/.../nanoclaw/src/router.py]`
- The `-u` flag is required for unbuffered output (so logs appear immediately)
- WorkingDirectory: `/Users/.../nanoclaw`
- EnvironmentVariables:
- `PATH`: `/Users/USERNAME/.local/bin:/usr/local/bin:/usr/bin:/bin` (must include path to `claude` CLI)
- `HOME`: `/Users/USERNAME` (required for Claude CLI to find its config)
- StandardOutPath/StandardErrorPath: `/Users/.../nanoclaw/logs/router.log` and `router.error.log`
**NOTE**: Do NOT set ANTHROPIC_API_KEY - the Claude CLI handles its own authentication.
Then load the services:
```bash
launchctl load ~/Library/LaunchAgents/com.nanoclaw.bridge.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.router.plist
```
Verify they're running:
```bash
launchctl list | grep nanoclaw
```
## 7. Test
Wait a few seconds for services to start, then tell the user:
> Send `@AssistantName hello` in your registered chat/group.
Check `logs/router.log` for activity:
```bash
tail -f logs/router.log
```
If there are issues, also check:
- `logs/router.error.log`
- `logs/bridge.log`
- `logs/bridge.error.log`
## Troubleshooting
**"Command failed with exit code 1"** - Usually means the Claude CLI isn't in PATH. Verify PATH in the router plist includes the directory containing `claude` (typically `~/.local/bin`).
**Messages received but no WhatsApp response** - Check that the bridge HTTP server is running:
```bash
curl -s http://127.0.0.1:3141/send -X POST -H "Content-Type: application/json" -d '{"jid":"test","message":"test"}'
```
Should return an error about invalid JID (not connection refused).
**Router not processing messages** - Check the trigger pattern matches. Messages must start with the trigger (e.g., `@Andy hello`).

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Dependencies
node_modules/
# Build output
dist/
# Local data & auth
store/
data/
logs/
# Secrets
*.keys.json
.env
# OS
.DS_Store
# IDE
.idea/
.vscode/

12
.mcp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"mcpServers": {
"gmail": {
"command": "npx",
"args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"]
},
"scheduler": {
"command": "npx",
"args": ["-y", "schedule-task-mcp"]
}
}
}

36
CLAUDE.md Normal file
View File

@@ -0,0 +1,36 @@
# NanoClaw
Personal Claude assistant via WhatsApp.
## Structure
- `src/index.ts` - Main application (WhatsApp + routing + agent)
- `package.json` - Dependencies and scripts
- `.mcp.json` - MCP server configuration (gmail, scheduler)
- `groups/CLAUDE.md` - Global memory
- `groups/{name}/CLAUDE.md` - Per-group memory
## Configuration
Set environment variable `ASSISTANT_NAME` to change the trigger (default: "Andy").
Or edit the CONFIG object in `src/index.ts`.
## Skills
- `/setup` - Install dependencies, authenticate, start services
- `/customize` - Modify behavior
## Architecture
```
WhatsApp (baileys) ─┬─> SQLite (messages.db)
│ ↓
│ Polling loop
│ ↓
│ Claude Agent SDK
│ ↓
└─< Send response
```
Single Node.js process handles everything.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gavriel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

71
README.md Normal file
View File

@@ -0,0 +1,71 @@
# NanoClaw
Personal Claude assistant via WhatsApp.
## Quick Start
```bash
git clone https://github.com/yourname/nanoclaw.git
cd nanoclaw
claude
# Run: /setup
```
Claude Code handles installation, authentication, and service setup.
## Features
- **WhatsApp I/O**: Message Claude from your phone
- **Persistent memory**: Per-group conversation context
- **Global memory**: Shared context across all groups
- **Email tools**: Read/send via Gmail (optional)
- **Scheduled tasks**: Recurring reminders and jobs
- **Web access**: Search and fetch content
## Usage
```
@Andy what's the weather in NYC?
@Andy summarize my unread emails
@Andy remind me every Monday at 9am to check metrics
/clear
```
From main channel:
```
@Andy add group "Family Chat"
@Andy list groups
```
## Requirements
- macOS (or Linux)
- Node.js 18+
- Claude Code CLI (authenticated)
## Manual Setup
```bash
npm install
npm run build
npm start
```
## Customization
Run Claude Code and ask to:
- "Change trigger to @Bot"
- "Make responses more concise"
Or use `/customize`.
## Architecture
Single Node.js process using:
- `@whiskeysockets/baileys` - WhatsApp Web API
- `@anthropic-ai/claude-agent-sdk` - Claude Agent SDK
- `better-sqlite3` - Message storage
## License
MIT

109
REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,109 @@
# NanoClaw Requirements
Original requirements and design decisions from the project creator.
---
## Vision
A personal Claude assistant accessible via WhatsApp, with minimal custom code.
**Core components:**
- **Claude Agent SDK** as the core agent
- **WhatsApp** as the primary I/O channel
- **Persistent memory** per conversation and globally
- **Scheduled tasks** that run Claude and can message back
- **Web access** for search and browsing
**Design philosophy:**
- Leverage existing tools (WhatsApp connector, Claude Agent SDK, MCP servers)
- Minimal glue code
- File-based systems where possible (CLAUDE.md for memory, folders for groups)
---
## Architecture Decisions
### Message Routing
- A router listens to WhatsApp and routes messages based on configuration
- Only messages from registered groups are processed
- Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var
- Unregistered groups are ignored completely
### Memory System
- **Per-group memory**: Each group has a folder with its own `CLAUDE.md`
- **Global memory**: Root `CLAUDE.md` is read by all groups, but only writable from "main" (self-chat)
- **Files**: Groups can create/read files in their folder and reference them
- Agent runs in the group's folder, automatically inherits both CLAUDE.md files
### Session Management
- Each group maintains a conversation session (via Claude Agent SDK)
- `/clear` command resets the session but keeps memory files
- Old session IDs are archived to a file
### Scheduled Tasks
- Users can ask Claude to schedule cron jobs from any group
- Tasks run in the context of the group that created them (with that group's memory)
- Task output is logged to the group's folder
### Group Management
- New groups are added explicitly via the main channel
- Groups are identified by human-readable name when possible
- Each group gets a dedicated folder
---
## Integration Points
### WhatsApp
- Using baileys library for WhatsApp Web connection
- Messages stored in SQLite, polled by router
- QR code authentication during setup
### Email (Gmail)
- Read-only MCP integration (can read and send, but not an I/O channel)
- Optional, enabled during setup
### Scheduler
- MCP server for creating/managing scheduled tasks
- Tasks execute Claude Agent SDK in group context
### Web Access
- Built-in WebSearch and WebFetch tools
- Standard Claude Agent SDK capabilities
---
## Setup & Customization
### Philosophy
- Minimal configuration files
- Setup and customization done via Claude Code
- Users clone the repo and run Claude Code to configure
- Each user gets a custom setup matching their exact needs
### Skills
- `/setup` - Install dependencies, authenticate WhatsApp/Gmail, configure scheduler, start services
- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes)
### Deployment
- Runs on local Mac via launchd
- Single Node.js process handles everything
---
## Personal Configuration (Reference)
These are the creator's settings, stored here for reference:
- **Trigger**: `@Andy` (case insensitive)
- **Response prefix**: `Andy:`
- **Persona**: Default Claude (no custom personality)
- **Gmail**: Enabled
- **Main channel**: Self-chat (messaging yourself in WhatsApp)
---
## Project Name
**NanoClaw** - A reference to Clawdbot (now OpenClaw).

484
SPEC.md Normal file
View File

@@ -0,0 +1,484 @@
# NanoClaw Specification
A personal Claude assistant accessible via WhatsApp, with persistent memory per conversation, scheduled tasks, and email integration.
---
## Table of Contents
1. [Architecture](#architecture)
2. [Folder Structure](#folder-structure)
3. [Configuration](#configuration)
4. [Memory System](#memory-system)
5. [Session Management](#session-management)
6. [Message Flow](#message-flow)
7. [Commands](#commands)
8. [Scheduled Tasks](#scheduled-tasks)
9. [MCP Servers](#mcp-servers)
10. [Deployment](#deployment)
11. [Security Considerations](#security-considerations)
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ NanoClaw │
│ (Single Node.js Process) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ WhatsApp │────────────────────▶│ SQLite Database │ │
│ │ (baileys) │◀────────────────────│ (messages.db) │ │
│ └──────────────┘ store/send └─────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ MESSAGE LOOP │ │
│ │ • Polls SQLite for new messages every 2 seconds │ │
│ │ • Filters: only registered groups, only trigger word │ │
│ │ • Loads session ID for conversation continuity │ │
│ │ • Invokes Claude Agent SDK in the group's directory │ │
│ │ • Sends response back to WhatsApp │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ CLAUDE AGENT SDK │ │
│ │ │ │
│ │ Working directory: groups/{group-name}/ │ │
│ │ Context loaded: │ │
│ │ • ../CLAUDE.md (global memory) │ │
│ │ • ./CLAUDE.md (group-specific memory) │ │
│ │ │ │
│ │ Available MCP Servers: │ │
│ │ • gmail-mcp (read/send email) │ │
│ │ • schedule-task-mcp (create cron jobs) │ │
│ │ │ │
│ │ Built-in Tools: │ │
│ │ • WebSearch, WebFetch (internet access) │ │
│ │ • Read, Write, Edit (file operations in group folder) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
```
### Technology Stack
| Component | Technology | Purpose |
|-----------|------------|---------|
| WhatsApp Connection | Node.js (@whiskeysockets/baileys) | Connect to WhatsApp, send/receive messages |
| Message Storage | SQLite (better-sqlite3) | Store messages for polling |
| Agent | @anthropic-ai/claude-agent-sdk | Run Claude with tools and MCP servers |
| Runtime | Node.js 18+ | Single unified process |
---
## Folder Structure
```
nanoclaw/
├── CLAUDE.md # Project context for Claude Code
├── SPEC.md # This specification document
├── README.md # User documentation
├── package.json # Node.js dependencies
├── tsconfig.json # TypeScript configuration
├── .mcp.json # MCP server configuration (reference)
├── .gitignore
├── src/
│ └── index.ts # Main application (WhatsApp + routing + agent)
├── dist/ # Compiled JavaScript (gitignored)
│ └── index.js
├── .claude/
│ └── skills/
│ ├── setup/
│ │ └── SKILL.md # /setup skill
│ └── customize/
│ └── SKILL.md # /customize skill
├── groups/
│ ├── CLAUDE.md # Global memory (all groups read this)
│ ├── main/ # Self-chat (main control channel)
│ │ ├── CLAUDE.md # Main channel memory
│ │ └── logs/ # Task execution logs
│ └── {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 message database
├── data/ # Application state (gitignored)
│ ├── sessions.json # Active session IDs per group
│ ├── archived_sessions.json # Old sessions after /clear
│ ├── registered_groups.json # Group JID → folder mapping
│ └── router_state.json # Last processed timestamp
├── logs/ # Runtime logs (gitignored)
│ ├── nanoclaw.log # stdout
│ └── nanoclaw.error.log # stderr
└── launchd/
└── com.nanoclaw.plist # macOS service configuration
```
---
## Configuration
Configuration is done via environment variables and the CONFIG object in `src/index.ts`:
```typescript
const CONFIG = {
assistantName: process.env.ASSISTANT_NAME || 'Andy',
pollInterval: 2000, // ms
storeDir: './store',
groupsDir: './groups',
dataDir: './data',
};
```
### Changing the Assistant Name
Set the `ASSISTANT_NAME` environment variable:
```bash
ASSISTANT_NAME=Bot npm start
```
Or edit the default in `src/index.ts`. This changes:
- The trigger pattern (messages must start with `@YourName`)
- The response prefix (`YourName:`)
### Placeholder Values in launchd
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 directory
---
## Memory System
NanoClaw uses a hierarchical memory system based on CLAUDE.md files.
### Memory Hierarchy
| 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 |
### How Memory Works
1. **Agent Context Loading**
- Agent runs with `cwd` set to `groups/{group-name}/`
- Claude Agent SDK with `settingSources: ['project']` automatically loads:
- `../CLAUDE.md` (parent directory = global memory)
- `./CLAUDE.md` (current directory = group memory)
2. **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.md` in the group folder
3. **Main Channel Privileges**
- Only the "main" group (self-chat) can write to global memory
- This prevents other groups from modifying shared context
---
## Session Management
Sessions enable conversation continuity - Claude remembers what you talked about.
### How Sessions Work
1. Each group has a session ID stored in `data/sessions.json`
2. Session ID is passed to Claude Agent SDK's `resume` option
3. Claude continues the conversation with full context
**data/sessions.json:**
```json
{
"main": "session-abc123",
"Family Chat": "session-def456"
}
```
### The /clear Command
When a user sends `/clear` in any group:
1. Current session ID is moved to `data/archived_sessions.json`
2. Session ID is removed from `data/sessions.json`
3. Next message starts a fresh session
4. **Memory files are NOT deleted** - only the session resets
---
## Message Flow
### Incoming Message Flow
```
1. User sends WhatsApp message
2. Baileys receives message via WhatsApp Web protocol
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.json? → No: ignore
├── Does message start with @Assistant? → No: ignore
└── Is message "/clear"? → Yes: handle specially
6. Router prepares invocation:
├── Load session ID for this group
├── Determine group folder path
└── Strip trigger word from message
7. Router invokes Claude Agent SDK:
├── cwd: groups/{group-name}/
├── prompt: user's message
├── resume: session_id (or undefined)
└── mcpServers: gmail, scheduler
8. Claude processes message:
├── Reads CLAUDE.md files for context
└── Uses tools as needed (search, email, etc.)
9. Router captures result and sends via WhatsApp
10. Router saves new session ID
```
### Trigger Word Matching
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)
- `/clear` → ✅ Special command (no trigger needed)
---
## Commands
### Commands Available in Any Group
| Command | Example | Effect |
|---------|---------|--------|
| `@Assistant [message]` | `@Andy what's the weather?` | Talk to Claude |
| `/clear` | `/clear` | Reset session, keep memory |
### Commands Available in Main Channel Only
| 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 |
---
## Scheduled Tasks
NanoClaw can schedule recurring tasks that run at specified times via the scheduler MCP.
### Creating a Task
```
User: @Andy remind me every Monday at 9am to review the weekly metrics
Claude: [calls mcp__scheduler__create_task]
{
"instruction": "Remind user to review weekly metrics",
"trigger_type": "cron",
"cron_expression": "0 9 * * 1"
}
Claude: Done! I'll remind you every Monday at 9am.
```
---
## MCP Servers
MCP servers are configured in the Claude Agent SDK options:
```typescript
mcpServers: {
gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] },
scheduler: { command: 'npx', args: ['-y', 'schedule-task-mcp'] }
}
```
### Gmail MCP (@gongrzhe/server-gmail-autoauth-mcp)
Provides email capabilities. Requires Google Cloud OAuth setup.
**Available Tools:**
| Tool | Purpose |
|------|---------|
| `search_messages` | Search inbox |
| `get_message` | Read full email |
| `send_message` | Send email |
| `reply_message` | Reply to thread |
### Scheduler MCP (schedule-task-mcp)
Provides cron-style task scheduling.
**Available Tools:**
| Tool | Purpose |
|------|---------|
| `create_task` | Schedule a new task |
| `list_tasks` | Show scheduled tasks |
| `delete_task` | Cancel a task |
| `update_task` | Modify schedule |
---
## Deployment
NanoClaw runs as a single macOS launchd service.
### Service: com.nanoclaw
**launchd/com.nanoclaw.plist:**
```xml
<?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>
```
### Managing the Service
```bash
# 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
```
---
## Security Considerations
### Prompt Injection Risk
WhatsApp messages could contain malicious instructions attempting to manipulate Claude's behavior.
**Mitigations:**
- Only registered groups are processed
- Trigger word required (reduces accidental processing)
- Main channel has elevated privileges (isolated from other groups)
- Claude's built-in safety training
**Recommendations:**
- Only register trusted groups
- Review scheduled tasks periodically
- Monitor logs for unusual activity
### Credential Storage
| Credential | Storage Location | Notes |
|------------|------------------|-------|
| Claude CLI Auth | ~/.claude/ | Managed by Claude Code CLI |
| WhatsApp Session | store/auth/ | Auto-created, persists ~20 days |
| Gmail OAuth Tokens | ~/.gmail-mcp/ | Created during setup (optional) |
### File Permissions
The groups/ folder contains personal memory and should be protected:
```bash
chmod 700 groups/
```
---
## Troubleshooting
### Common Issues
| Issue | Cause | Solution |
|-------|-------|----------|
| No response to messages | Service not running | Check `launchctl list | grep nanoclaw` |
| "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 |
| Session not continuing | Session ID not saved | Check `data/sessions.json` |
### Log Location
- `logs/nanoclaw.log` - stdout
- `logs/nanoclaw.error.log` - stderr
### Debug Mode
Run manually for verbose output:
```bash
npm run dev
# or
node dist/index.js
```

30
groups/CLAUDE.md Normal file
View File

@@ -0,0 +1,30 @@
# NanoClaw Global Memory
This file is read by all group conversations. Only the main channel can write here.
## About
Personal Claude assistant via WhatsApp.
Assistant name is configured in `src/config.py` (ASSISTANT_NAME).
## Commands
From any group:
- `@{name} [message]` - Talk to Claude
- `/clear` - Reset conversation (keeps memory)
- `@{name} list tasks` - Show scheduled tasks
From main channel only:
- `@{name} add group "Name"` - Register a new group
- `@{name} remove group "Name"` - Unregister a group
- `@{name} list groups` - Show registered groups
- `@{name} remember [fact]` - Add to global memory
## Preferences
<!-- Add global preferences here -->
## Notes
<!-- Add persistent notes here -->

18
groups/main/CLAUDE.md Normal file
View File

@@ -0,0 +1,18 @@
# Main Channel
Self-chat - the primary control channel for NanoClaw.
## Permissions
This channel can:
- Write to global memory (../CLAUDE.md)
- Add/remove groups
- Manage scheduled tasks across all groups
## Memory
<!-- Personal notes and context -->
## Files
<!-- Reference files created in this folder -->

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<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>

2740
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "nanoclaw",
"version": "1.0.0",
"description": "Personal Claude assistant via WhatsApp",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"lint": "eslint src/",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
"@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"qrcode-terminal": "^0.12.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
"@types/qrcode-terminal": "^0.12.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18"
}
}

457
src/index.ts Normal file
View File

@@ -0,0 +1,457 @@
/**
* NanoClaw - Unified Node.js Implementation
*
* Single process that handles:
* - WhatsApp connection (baileys)
* - Message routing
* - Claude Agent SDK queries
* - Response sending
*/
import makeWASocket, {
useMultiFileAuthState,
DisconnectReason,
makeCacheableSignalKeyStore,
WASocket,
proto
} from '@whiskeysockets/baileys';
import { query } from '@anthropic-ai/claude-agent-sdk';
import pino from 'pino';
import qrcode from 'qrcode-terminal';
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
// === CONFIGURATION ===
const CONFIG = {
assistantName: process.env.ASSISTANT_NAME || 'Andy',
pollInterval: 2000, // ms
storeDir: './store',
groupsDir: './groups',
dataDir: './data',
};
const TRIGGER_PATTERN = new RegExp(`^@${CONFIG.assistantName}\\b`, 'i');
const CLEAR_COMMAND = '/clear';
// === TYPES ===
interface RegisteredGroup {
name: string;
folder: string;
trigger: string;
added_at: string;
}
interface Session {
[folder: string]: string; // folder -> session_id
}
// === LOGGING ===
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: { colorize: true }
}
});
// === DATABASE ===
function initDatabase(dbPath: string): Database.Database {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS chats (
jid TEXT PRIMARY KEY,
name TEXT,
last_message_time TEXT
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT,
chat_jid TEXT,
sender TEXT,
content TEXT,
timestamp TEXT,
is_from_me INTEGER,
PRIMARY KEY (id, chat_jid),
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
`);
return db;
}
// === FILE HELPERS ===
function loadJson<T>(filePath: string, defaultValue: T): T {
try {
if (fs.existsSync(filePath)) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
} catch (e) {
logger.warn({ filePath, error: e }, 'Failed to load JSON file');
}
return defaultValue;
}
function saveJson(filePath: string, data: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
// === STATE ===
let db: Database.Database;
let sock: WASocket;
let lastTimestamp = '';
let sessions: Session = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
function loadState(): void {
const statePath = path.join(CONFIG.dataDir, 'router_state.json');
const state = loadJson<{ last_timestamp?: string }>(statePath, {});
lastTimestamp = state.last_timestamp || '';
sessions = loadJson(path.join(CONFIG.dataDir, 'sessions.json'), {});
registeredGroups = loadJson(path.join(CONFIG.dataDir, 'registered_groups.json'), {});
logger.info({
groupCount: Object.keys(registeredGroups).length,
lastTimestamp: lastTimestamp || '(start)'
}, 'State loaded');
}
function saveState(): void {
saveJson(path.join(CONFIG.dataDir, 'router_state.json'), { last_timestamp: lastTimestamp });
saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions);
}
// === MESSAGE STORAGE ===
function storeMessage(
msg: proto.IWebMessageInfo,
chatJid: string,
isFromMe: boolean
): void {
if (!msg.key) return;
const content =
msg.message?.conversation ||
msg.message?.extendedTextMessage?.text ||
msg.message?.imageMessage?.caption ||
msg.message?.videoMessage?.caption ||
'';
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
const sender = msg.key.participant || msg.key.remoteJid || '';
const msgId = msg.key.id || '';
try {
// Ensure chat exists first
db.prepare(`
INSERT OR REPLACE INTO chats (jid, name, last_message_time)
VALUES (?, ?, ?)
`).run(chatJid, chatJid, timestamp);
// Store message
db.prepare(`
INSERT OR REPLACE INTO messages (id, chat_jid, sender, content, timestamp, is_from_me)
VALUES (?, ?, ?, ?, ?, ?)
`).run(msgId, chatJid, sender, content, timestamp, isFromMe ? 1 : 0);
logger.debug({ chatJid, msgId }, 'Message stored');
} catch (err) {
logger.error({ err, msgId }, 'Failed to store message');
}
}
// === MESSAGE PROCESSING ===
interface NewMessage {
id: string;
chat_jid: string;
sender: string;
content: string;
timestamp: string;
}
function getNewMessages(): NewMessage[] {
const jids = Object.keys(registeredGroups);
if (jids.length === 0) {
logger.debug('No registered groups');
return [];
}
const placeholders = jids.map(() => '?').join(',');
const query = `
SELECT id, chat_jid, sender, content, timestamp
FROM messages
WHERE timestamp > ? AND chat_jid IN (${placeholders})
ORDER BY timestamp
`;
logger.debug({ lastTimestamp, jids }, 'Querying messages');
const rows = db.prepare(query).all(lastTimestamp, ...jids) as NewMessage[];
for (const row of rows) {
if (row.timestamp > lastTimestamp) {
lastTimestamp = row.timestamp;
}
}
return rows;
}
async function processMessage(msg: NewMessage): Promise<void> {
const group = registeredGroups[msg.chat_jid];
if (!group) return;
const content = msg.content.trim();
// Handle /clear command
if (content.toLowerCase() === CLEAR_COMMAND) {
if (sessions[group.folder]) {
// Archive old session
const archived = loadJson<Record<string, Array<{ session_id: string; cleared_at: string }>>>(
path.join(CONFIG.dataDir, 'archived_sessions.json'),
{}
);
if (!archived[group.folder]) archived[group.folder] = [];
archived[group.folder].push({
session_id: sessions[group.folder],
cleared_at: new Date().toISOString()
});
saveJson(path.join(CONFIG.dataDir, 'archived_sessions.json'), archived);
delete sessions[group.folder];
saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions);
}
logger.info({ group: group.name }, 'Session cleared');
await sendMessage(msg.chat_jid, `${CONFIG.assistantName}: Conversation cleared. Starting fresh!`);
return;
}
// Check trigger pattern
if (!TRIGGER_PATTERN.test(content)) return;
// Strip trigger from message
const prompt = content.replace(TRIGGER_PATTERN, '').trim();
if (!prompt) return;
logger.info({ group: group.name, prompt: prompt.slice(0, 50) }, 'Processing message');
// Run agent
const response = await runAgent(group, prompt, msg.chat_jid);
if (response) {
await sendMessage(msg.chat_jid, response);
}
}
async function runAgent(
group: RegisteredGroup,
prompt: string,
chatJid: string
): Promise<string | null> {
const isMain = group.folder === 'main';
const groupDir = path.join(CONFIG.groupsDir, group.folder);
// Ensure group directory exists
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
// Build context
const context = `[WhatsApp message from group: ${group.name}]
[Reply to chat_jid: ${chatJid}]
[Can write to global memory (../CLAUDE.md): ${isMain}]
[Prefix your responses with "${CONFIG.assistantName}:"]
User message: ${prompt}`;
const sessionId = sessions[group.folder];
let newSessionId: string | undefined;
let result: string | null = null;
try {
for await (const message of query({
prompt: context,
options: {
cwd: groupDir,
resume: sessionId,
allowedTools: [
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch'
],
permissionMode: 'bypassPermissions',
settingSources: ['project'],
mcpServers: {
gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] },
scheduler: { command: 'npx', args: ['-y', 'schedule-task-mcp'] }
}
}
})) {
// Capture session ID from init message
if (message.type === 'system' && message.subtype === 'init') {
newSessionId = message.session_id;
}
// Capture final result
if ('result' in message && message.result) {
result = message.result as string;
}
}
} catch (err) {
logger.error({ group: group.name, err }, 'Agent error');
return `${CONFIG.assistantName}: Sorry, I encountered an error. Please try again.`;
}
// Save session
if (newSessionId) {
sessions[group.folder] = newSessionId;
saveJson(path.join(CONFIG.dataDir, 'sessions.json'), sessions);
}
if (result) {
logger.info({ group: group.name, result: result.slice(0, 100) }, 'Agent response');
}
return result;
}
async function sendMessage(jid: string, text: string): Promise<void> {
try {
await sock.sendMessage(jid, { text });
logger.info({ jid, text: text.slice(0, 50) }, 'Message sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send message');
}
}
// === WHATSAPP CONNECTION ===
async function connectWhatsApp(): Promise<void> {
const authDir = path.join(CONFIG.storeDir, 'auth');
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger)
},
printQRInTerminal: false,
logger,
browser: ['NanoClaw', 'Chrome', '1.0.0']
});
// Handle connection updates
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('\nScan this QR code with WhatsApp:\n');
qrcode.generate(qr, { small: true });
console.log('\nWaiting for scan...\n');
}
if (connection === 'close') {
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info({ reason, shouldReconnect }, 'Connection closed');
if (shouldReconnect) {
logger.info('Reconnecting...');
connectWhatsApp();
} else {
logger.info('Logged out. Delete store/auth folder and restart to re-authenticate.');
process.exit(0);
}
} else if (connection === 'open') {
console.log('\n✓ Connected to WhatsApp!\n');
logger.info('WhatsApp connection established');
startMessageLoop();
}
});
// Save credentials on update
sock.ev.on('creds.update', saveCreds);
// Handle incoming messages (store them)
sock.ev.on('messages.upsert', ({ messages }) => {
for (const msg of messages) {
if (!msg.message) continue;
const chatJid = msg.key.remoteJid;
if (!chatJid || chatJid === 'status@broadcast') continue;
storeMessage(msg, chatJid, msg.key.fromMe || false);
}
});
}
// === MAIN LOOP ===
async function startMessageLoop(): Promise<void> {
logger.info(`NanoClaw running (trigger: @${CONFIG.assistantName})`);
while (true) {
try {
const messages = getNewMessages();
if (messages.length > 0) {
logger.info({ count: messages.length }, 'Found new messages');
}
for (const msg of messages) {
await processMessage(msg);
}
saveState();
} catch (err) {
logger.error({ err }, 'Error in message loop');
}
await new Promise(resolve => setTimeout(resolve, CONFIG.pollInterval));
}
}
// === ENTRY POINT ===
async function main(): Promise<void> {
// Initialize database
const dbPath = path.join(CONFIG.storeDir, 'messages.db');
db = initDatabase(dbPath);
logger.info('Database initialized');
// Load state
loadState();
// Connect to WhatsApp
await connectWhatsApp();
// Handle graceful shutdown
process.on('SIGINT', () => {
logger.info('Shutting down...');
db.close();
process.exit(0);
});
process.on('SIGTERM', () => {
logger.info('Shutting down...');
db.close();
process.exit(0);
});
}
main().catch(err => {
logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1);
});

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}