refactor: remove legacy container and non-discord remnants

This commit is contained in:
Eyejoker
2026-03-20 01:07:46 +09:00
parent ea09560128
commit bb0628e8f4
82 changed files with 2712 additions and 10523 deletions

View File

@@ -1,90 +0,0 @@
# Apple Container Networking Setup (macOS 26)
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
## Quick Setup
Run these two commands (requires `sudo`):
```bash
# 1. Enable IP forwarding so the host routes container traffic
sudo sysctl -w net.inet.ip.forwarding=1
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
```
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
## Making It Persistent
These settings reset on reboot. To make them permanent:
**IP Forwarding** — add to `/etc/sysctl.conf`:
```
net.inet.ip.forwarding=1
```
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
```
nat on en0 from 192.168.64.0/24 to any -> (en0)
```
Then reload: `sudo pfctl -f /etc/pf.conf`
## IPv6 DNS Issue
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
The container image and runner are configured to prefer IPv4 via:
```
NODE_OPTIONS=--dns-result-order=ipv4first
```
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
## Verification
```bash
# Check IP forwarding is enabled
sysctl net.inet.ip.forwarding
# Expected: net.inet.ip.forwarding: 1
# Test container internet access
container run --rm --entrypoint curl nanoclaw-agent:latest \
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
# Expected: 404
# Check bridge interface (only exists when a container is running)
ifconfig bridge100
```
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
## How It Works
```
Container VM (192.168.64.x)
├── eth0 → gateway 192.168.64.1
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
├── IP forwarding (sysctl) routes packets from bridge100 → en0
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
en0 (your WiFi/Ethernet) → Internet
```
## References
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building

View File

@@ -1,143 +0,0 @@
# NanoClaw Debug Checklist
## Known Issues (2026-02-08)
### 1. [FIXED] Resume branches from stale tree position
When agent teams spawns subagent CLI processes, they write to the same session JSONL. On subsequent `query()` resumes, the CLI reads the JSONL but may pick a stale branch tip (from before the subagent activity), causing the agent's response to land on a branch the host never receives a `result` for. **Fix**: pass `resumeSessionAt` with the last assistant message UUID to explicitly anchor each resume.
### 2. IDLE_TIMEOUT == CONTAINER_TIMEOUT (both 30 min)
Both timers fire at the same time, so containers always exit via hard SIGKILL (code 137) instead of graceful `_close` sentinel shutdown. The idle timeout should be shorter (e.g., 5 min) so containers wind down between messages, while container timeout stays at 30 min as a safety net for stuck agents.
### 3. Cursor advanced before agent succeeds
`processGroupMessages` advances `lastAgentTimestamp` before the agent runs. If the container times out, retries find no messages (cursor already past them). Messages are permanently lost on timeout.
## Quick Status Check
```bash
# 1. Is the service running?
launchctl list | grep nanoclaw
# Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed)
# 2. Any running containers?
container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
# 3. Any stopped/orphaned containers?
container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
# 4. Recent errors in service log?
grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20
# 5. Is WhatsApp connected? (look for last connection event)
grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5
# 6. Are groups loaded?
grep 'groupCount' logs/nanoclaw.log | tail -3
```
## Session Transcript Branching
```bash
# Check for concurrent CLI processes in session debug logs
ls -la data/sessions/<group>/.claude/debug/
# Count unique SDK processes that handled messages
# Each .txt file = one CLI subprocess. Multiple = concurrent queries.
# Check parentUuid branching in transcript
python3 -c "
import json, sys
lines = open('data/sessions/<group>/.claude/projects/-workspace-group/<session>.jsonl').read().strip().split('\n')
for i, line in enumerate(lines):
try:
d = json.loads(line)
if d.get('type') == 'user' and d.get('message'):
parent = d.get('parentUuid', 'ROOT')[:8]
content = str(d['message'].get('content', ''))[:60]
print(f'L{i+1} parent={parent} {content}')
except: pass
"
```
## Container Timeout Investigation
```bash
# Check for recent timeouts
grep -E 'Container timeout|timed out' logs/nanoclaw.log | tail -10
# Check container log files for the timed-out container
ls -lt groups/*/logs/container-*.log | head -10
# Read the most recent container log (replace path)
cat groups/<group>/logs/container-<timestamp>.log
# Check if retries were scheduled and what happened
grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10
```
## Agent Not Responding
```bash
# Check if messages are being received from WhatsApp
grep 'New messages' logs/nanoclaw.log | tail -10
# Check if messages are being processed (container spawned)
grep -E 'Processing messages|Spawning container' logs/nanoclaw.log | tail -10
# Check if messages are being piped to active container
grep -E 'Piped messages|sendMessage' logs/nanoclaw.log | tail -10
# Check the queue state — any active containers?
grep -E 'Starting container|Container active|concurrency limit' logs/nanoclaw.log | tail -10
# Check lastAgentTimestamp vs latest message timestamp
sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messages GROUP BY chat_jid ORDER BY latest DESC LIMIT 5;"
```
## Container Mount Issues
```bash
# Check mount validation logs (shows on container spawn)
grep -E 'Mount validated|Mount.*REJECTED|mount' logs/nanoclaw.log | tail -10
# Verify the mount allowlist is readable
cat ~/.config/nanoclaw/mount-allowlist.json
# Check group's container_config in DB
sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;"
# Test-run a container to check mounts (dry run)
# Replace <group-folder> with the group's folder name
container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/
```
## WhatsApp Auth Issues
```bash
# Check if QR code was requested (means auth expired)
grep 'QR\|authentication required\|qr' logs/nanoclaw.log | tail -5
# Check auth files exist
ls -la store/auth/
# Re-authenticate if needed
npm run auth
```
## Service Management
```bash
# Restart the service
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# View live logs
tail -f logs/nanoclaw.log
# Stop the service (careful — running containers are detached, not killed)
launchctl bootout gui/$(id -u)/com.nanoclaw
# Start the service
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist
# Rebuild after code changes
npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```

View File

@@ -1,196 +0,0 @@
# NanoClaw Requirements
Original requirements and design decisions from the project creator.
---
## Why This Exists
This is a lightweight, secure alternative to OpenClaw (formerly ClawBot). That project became a monstrosity - 4-5 different processes running different gateways, endless configuration files, endless integrations. It's a security nightmare where agents don't run in isolated processes; there's all kinds of leaky workarounds trying to prevent them from accessing parts of the system they shouldn't. It's impossible for anyone to realistically understand the whole codebase. When you run it you're kind of just yoloing it.
NanoClaw gives you the core functionality without that mess.
---
## Philosophy
### Small Enough to Understand
The entire codebase should be something you can read and understand. One Node.js process. A handful of source files. No microservices, no message queues, no abstraction layers.
### Security Through True Isolation
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
### Built for One User
This isn't a framework or a platform. It's working software for my specific needs. I use WhatsApp and Email, so it supports WhatsApp and Email. I don't use Telegram, so it doesn't support Telegram. I add the integrations I actually want, not every possible integration.
### Customization = Code Changes
No configuration sprawl. If you want different behavior, modify the code. The codebase is small enough that this is safe and practical. Very minimal things like the trigger word are in config. Everything else - just change the code to do what you want.
### AI-Native Development
I don't need an installation wizard - Claude Code guides the setup. I don't need a monitoring dashboard - I ask Claude Code what's happening. I don't need elaborate logging UIs - I ask Claude to read the logs. I don't need debugging tools - I describe the problem and Claude fixes it.
The codebase assumes you have an AI collaborator. It doesn't need to be excessively self-documenting or self-debugging because Claude is always there.
### Skills Over Features
When people contribute, they shouldn't add "Telegram support alongside WhatsApp." They should contribute a skill like `/add-telegram` that transforms the codebase. Users fork the repo, run skills to customize, and end up with clean code that does exactly what they need - not a bloated system trying to support everyone's use case simultaneously.
---
## RFS (Request for Skills)
Skills we'd love contributors to build:
### Communication Channels
Skills to add or switch to different messaging platforms:
- `/add-telegram` - Add Telegram as an input channel
- `/add-slack` - Add Slack as an input channel
- `/add-discord` - Add Discord as an input channel
- `/add-sms` - Add SMS via Twilio or similar
- `/convert-to-telegram` - Replace WhatsApp with Telegram entirely
### Container Runtime
The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container:
- `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only)
### Platform Support
- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion)
- `/setup-windows` - Windows support via WSL2 + Docker
---
## Vision
A personal Claude assistant accessible via WhatsApp, with minimal custom code.
**Core components:**
- **Claude Agent SDK** as the core agent
- **Containers** for isolated agent execution (Linux VMs)
- **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
- **Browser automation** via agent-browser
**Implementation approach:**
- Use 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)
- Sessions auto-compact when context gets too long, preserving critical information
### Container Isolation
- All agents run inside containers (lightweight Linux VMs)
- Each agent invocation spawns a container with mounted directories
- Containers provide filesystem isolation - agents can only see mounted paths
- Bash access is safe because commands run inside the container, not on the host
- Browser automation via agent-browser with Chromium in the container
### Scheduled Tasks
- Users can ask Claude to schedule recurring or one-time tasks from any group
- Tasks run as full agents in the context of the group that created them
- Tasks have access to all tools including Bash (safe in container)
- Tasks can optionally send messages to their group via `send_message` tool, or complete silently
- Task runs are logged to the database with duration and result
- Schedule types: cron expressions, intervals (ms), or one-time (ISO timestamp)
- From main: can schedule tasks for any group, view/manage all tasks
- From other groups: can only manage that group's tasks
### Group Management
- New groups are added explicitly via the main channel
- Groups are registered in SQLite (via the main channel or IPC `register_group` command)
- Each group gets a dedicated folder under `groups/`
- Groups can have additional directories mounted via `containerConfig`
### Main Channel Privileges
- Main channel is the admin/control group (typically self-chat)
- Can write to global memory (`groups/CLAUDE.md`)
- Can schedule tasks for any group
- Can view and manage tasks from all groups
- Can configure additional directory mounts for any group
---
## Integration Points
### WhatsApp
- Using baileys library for WhatsApp Web connection
- Messages stored in SQLite, polled by router
- QR code authentication during setup
### Scheduler
- Built-in scheduler runs on the host, spawns containers for task execution
- Custom `nanoclaw` MCP server (inside container) provides scheduling tools
- Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message`
- Tasks stored in SQLite with run history
- Scheduler loop checks for due tasks every minute
- Tasks execute Claude Agent SDK in containerized group context
### Web Access
- Built-in WebSearch and WebFetch tools
- Standard Claude Agent SDK capabilities
### Browser Automation
- agent-browser CLI with Chromium in container
- Snapshot-based interaction with element references (@e1, @e2, etc.)
- Screenshots, PDFs, video recording
- Authentication state persistence
---
## 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, configure scheduler, start services
- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes)
- `/update` - Pull upstream changes, merge with customizations, run migrations
### 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)
- **Main channel**: Self-chat (messaging yourself in WhatsApp)
---
## Project Name
**NanoClaw** - A reference to Clawdbot (now OpenClaw).

View File

@@ -1,643 +0,0 @@
# Claude Agent SDK Deep Dive
Findings from reverse-engineering `@anthropic-ai/claude-agent-sdk` v0.2.290.2.34 to understand how `query()` works, why agent teams subagents were being killed, and how to fix it. Supplemented with official SDK reference docs.
## Architecture
```
Agent Runner (our code)
└── query() → SDK (sdk.mjs)
└── spawns CLI subprocess (cli.js)
└── Claude API calls, tool execution
└── Task tool → spawns subagent subprocesses
```
The SDK spawns `cli.js` as a child process with `--output-format stream-json --input-format stream-json --print --verbose` flags. Communication happens via JSON-lines on stdin/stdout.
`query()` returns a `Query` object extending `AsyncGenerator<SDKMessage, void>`. Internally:
- SDK spawns CLI as a child process, communicates via stdin/stdout JSON lines
- SDK's `readMessages()` reads from CLI stdout, enqueues into internal stream
- `readSdkMessages()` async generator yields from that stream
- `[Symbol.asyncIterator]` returns `readSdkMessages()`
- Iterator returns `done: true` only when CLI closes stdout
Both V1 (`query()`) and V2 (`createSession`/`send`/`stream`) use the exact same three-layer architecture:
```
SDK (sdk.mjs) CLI Process (cli.js)
-------------- --------------------
XX Transport ------> stdin reader (bd1)
(spawn cli.js) |
$X Query <------ stdout writer
(JSON-lines) |
EZ() recursive generator
|
Anthropic Messages API
```
## The Core Agent Loop (EZ)
Inside the CLI, the agentic loop is a **recursive async generator called `EZ()`**, not an iterative while loop:
```
EZ({ messages, systemPrompt, canUseTool, maxTurns, turnCount=1, ... })
```
Each invocation = one API call to Claude (one "turn").
### Flow per turn:
1. **Prepare messages** — trim context, run compaction if needed
2. **Call the Anthropic API** (via `mW1` streaming function)
3. **Extract tool_use blocks** from the response
4. **Branch:**
- If **no tool_use blocks** → stop (run stop hooks, return)
- If **tool_use blocks present** → execute tools, increment turnCount, recurse
All complex logic — the agent loop, tool execution, background tasks, teammate orchestration — runs inside the CLI subprocess. `query()` is a thin transport wrapper.
## query() Options
Full `Options` type from the official docs:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `abortController` | `AbortController` | `new AbortController()` | Controller for cancelling operations |
| `additionalDirectories` | `string[]` | `[]` | Additional directories Claude can access |
| `agents` | `Record<string, AgentDefinition>` | `undefined` | Programmatically define subagents (not agent teams — no orchestration) |
| `allowDangerouslySkipPermissions` | `boolean` | `false` | Required when using `permissionMode: 'bypassPermissions'` |
| `allowedTools` | `string[]` | All tools | List of allowed tool names |
| `betas` | `SdkBeta[]` | `[]` | Beta features (e.g., `['context-1m-2025-08-07']` for 1M context) |
| `canUseTool` | `CanUseTool` | `undefined` | Custom permission function for tool usage |
| `continue` | `boolean` | `false` | Continue the most recent conversation |
| `cwd` | `string` | `process.cwd()` | Current working directory |
| `disallowedTools` | `string[]` | `[]` | List of disallowed tool names |
| `enableFileCheckpointing` | `boolean` | `false` | Enable file change tracking for rewinding |
| `env` | `Dict<string>` | `process.env` | Environment variables |
| `executable` | `'bun' \| 'deno' \| 'node'` | Auto-detected | JavaScript runtime |
| `fallbackModel` | `string` | `undefined` | Model to use if primary fails |
| `forkSession` | `boolean` | `false` | When resuming, fork to a new session ID instead of continuing original |
| `hooks` | `Partial<Record<HookEvent, HookCallbackMatcher[]>>` | `{}` | Hook callbacks for events |
| `includePartialMessages` | `boolean` | `false` | Include partial message events (streaming) |
| `maxBudgetUsd` | `number` | `undefined` | Maximum budget in USD for the query |
| `maxThinkingTokens` | `number` | `undefined` | Maximum tokens for thinking process |
| `maxTurns` | `number` | `undefined` | Maximum conversation turns |
| `mcpServers` | `Record<string, McpServerConfig>` | `{}` | MCP server configurations |
| `model` | `string` | Default from CLI | Claude model to use |
| `outputFormat` | `{ type: 'json_schema', schema: JSONSchema }` | `undefined` | Structured output format |
| `pathToClaudeCodeExecutable` | `string` | Uses built-in | Path to Claude Code executable |
| `permissionMode` | `PermissionMode` | `'default'` | Permission mode |
| `plugins` | `SdkPluginConfig[]` | `[]` | Load custom plugins from local paths |
| `resume` | `string` | `undefined` | Session ID to resume |
| `resumeSessionAt` | `string` | `undefined` | Resume session at a specific message UUID |
| `sandbox` | `SandboxSettings` | `undefined` | Sandbox behavior configuration |
| `settingSources` | `SettingSource[]` | `[]` (none) | Which filesystem settings to load. Must include `'project'` to load CLAUDE.md |
| `stderr` | `(data: string) => void` | `undefined` | Callback for stderr output |
| `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | System prompt. Use preset to get Claude Code's prompt, with optional `append` |
| `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | Tool configuration |
### PermissionMode
```typescript
type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
```
### SettingSource
```typescript
type SettingSource = 'user' | 'project' | 'local';
// 'user' → ~/.claude/settings.json
// 'project' → .claude/settings.json (version controlled)
// 'local' → .claude/settings.local.json (gitignored)
```
When omitted, SDK loads NO filesystem settings (isolation by default). Precedence: local > project > user. Programmatic options always override filesystem settings.
### AgentDefinition
Programmatic subagents (NOT agent teams — these are simpler, no inter-agent coordination):
```typescript
type AgentDefinition = {
description: string; // When to use this agent
tools?: string[]; // Allowed tools (inherits all if omitted)
prompt: string; // Agent's system prompt
model?: 'sonnet' | 'opus' | 'haiku' | 'inherit';
}
```
### McpServerConfig
```typescript
type McpServerConfig =
| { type?: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
| { type: 'sse'; url: string; headers?: Record<string, string> }
| { type: 'http'; url: string; headers?: Record<string, string> }
| { type: 'sdk'; name: string; instance: McpServer } // in-process
```
### SdkBeta
```typescript
type SdkBeta = 'context-1m-2025-08-07';
// Enables 1M token context window for Opus 4.6, Sonnet 4.5, Sonnet 4
```
### CanUseTool
```typescript
type CanUseTool = (
toolName: string,
input: ToolInput,
options: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
) => Promise<PermissionResult>;
type PermissionResult =
| { behavior: 'allow'; updatedInput: ToolInput; updatedPermissions?: PermissionUpdate[] }
| { behavior: 'deny'; message: string; interrupt?: boolean };
```
## SDKMessage Types
`query()` can yield 16 message types. The official docs show a simplified union of 7, but `sdk.d.ts` has the full set:
| Type | Subtype | Purpose |
|------|---------|---------|
| `system` | `init` | Session initialized, contains session_id, tools, model |
| `system` | `task_notification` | Background agent completed/failed/stopped |
| `system` | `compact_boundary` | Conversation was compacted |
| `system` | `status` | Status change (e.g. compacting) |
| `system` | `hook_started` | Hook execution started |
| `system` | `hook_progress` | Hook progress output |
| `system` | `hook_response` | Hook completed |
| `system` | `files_persisted` | Files saved |
| `assistant` | — | Claude's response (text + tool calls) |
| `user` | — | User message (internal) |
| `user` (replay) | — | Replayed user message on resume |
| `result` | `success` / `error_*` | Final result of a prompt processing round |
| `stream_event` | — | Partial streaming (when includePartialMessages) |
| `tool_progress` | — | Long-running tool progress |
| `auth_status` | — | Authentication state changes |
| `tool_use_summary` | — | Summary of preceding tool uses |
### SDKTaskNotificationMessage (sdk.d.ts:1507)
```typescript
type SDKTaskNotificationMessage = {
type: 'system';
subtype: 'task_notification';
task_id: string;
status: 'completed' | 'failed' | 'stopped';
output_file: string;
summary: string;
uuid: UUID;
session_id: string;
};
```
### SDKResultMessage (sdk.d.ts:1375)
Two variants with shared fields:
```typescript
// Shared fields on both variants:
// uuid, session_id, duration_ms, duration_api_ms, is_error, num_turns,
// total_cost_usd, usage: NonNullableUsage, modelUsage, permission_denials
// Success:
type SDKResultSuccess = {
type: 'result';
subtype: 'success';
result: string;
structured_output?: unknown;
// ...shared fields
};
// Error:
type SDKResultError = {
type: 'result';
subtype: 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | 'error_max_structured_output_retries';
errors: string[];
// ...shared fields
};
```
Useful fields on result: `total_cost_usd`, `duration_ms`, `num_turns`, `modelUsage` (per-model breakdown with `costUSD`, `inputTokens`, `outputTokens`, `contextWindow`).
### SDKAssistantMessage
```typescript
type SDKAssistantMessage = {
type: 'assistant';
uuid: UUID;
session_id: string;
message: APIAssistantMessage; // From Anthropic SDK
parent_tool_use_id: string | null; // Non-null when from subagent
};
```
### SDKSystemMessage (init)
```typescript
type SDKSystemMessage = {
type: 'system';
subtype: 'init';
uuid: UUID;
session_id: string;
apiKeySource: ApiKeySource;
cwd: string;
tools: string[];
mcp_servers: { name: string; status: string }[];
model: string;
permissionMode: PermissionMode;
slash_commands: string[];
output_style: string;
};
```
## Turn Behavior: When the Agent Stops vs Continues
### When the Agent STOPS (no more API calls)
**1. No tool_use blocks in response (THE PRIMARY CASE)**
Claude responded with text only — it decided it has completed the task. The API's `stop_reason` will be `"end_turn"`. The SDK does NOT make this decision — it's entirely driven by Claude's model output.
**2. Max turns exceeded** — Results in `SDKResultError` with `subtype: "error_max_turns"`.
**3. Abort signal** — User interruption via `abortController`.
**4. Budget exceeded**`totalCost >= maxBudgetUsd``"error_max_budget_usd"`.
**5. Stop hook prevents continuation** — Hook returns `{preventContinuation: true}`.
### When the Agent CONTINUES (makes another API call)
**1. Response contains tool_use blocks (THE PRIMARY CASE)** — Execute tools, increment turnCount, recurse into EZ.
**2. max_output_tokens recovery** — Up to 3 retries with a "break your work into smaller pieces" context message.
**3. Stop hook blocking errors** — Errors fed back as context messages, loop continues.
**4. Model fallback** — Retry with fallback model (one-time).
### Decision Table
| Condition | Action | Result Type |
|-----------|--------|-------------|
| Response has `tool_use` blocks | Execute tools, recurse into `EZ` | continues |
| Response has NO `tool_use` blocks | Run stop hooks, return | `success` |
| `turnCount > maxTurns` | Yield max_turns_reached | `error_max_turns` |
| `totalCost >= maxBudgetUsd` | Yield budget error | `error_max_budget_usd` |
| `abortController.signal.aborted` | Yield interrupted msg | depends on context |
| `stop_reason === "max_tokens"` (output) | Retry up to 3x with recovery prompt | continues |
| Stop hook `preventContinuation` | Return immediately | `success` |
| Stop hook blocking error | Feed error back, recurse | continues |
| Model fallback error | Retry with fallback model (one-time) | continues |
## Subagent Execution Modes
### Case 1: Synchronous Subagents (`run_in_background: false`) — BLOCKS
Parent agent calls Task tool → `VR()` runs `EZ()` for subagent → parent waits for full result → tool result returned to parent → parent continues.
The subagent runs the full recursive EZ loop. The parent's tool execution is suspended via `await`. There is a mid-execution "promotion" mechanism: a synchronous subagent can be promoted to background via `Promise.race()` against a `backgroundSignal` promise.
### Case 2: Background Tasks (`run_in_background: true`) — DOES NOT WAIT
- **Bash tool:** Command spawned, tool returns immediately with empty result + `backgroundTaskId`
- **Task/Agent tool:** Subagent launched in fire-and-forget wrapper (`g01()`), tool returns immediately with `status: "async_launched"` + `outputFile` path
Zero "wait for background tasks" logic before emitting the `type: "result"` message. When a background task completes, an `SDKTaskNotificationMessage` is emitted separately.
### Case 3: Agent Teams (TeammateTool / SendMessage) — RESULT FIRST, THEN POLLING
The team leader runs its normal EZ loop, which includes spawning teammates. When the leader's EZ loop finishes, `type: "result"` is emitted. Then the leader enters a post-result polling loop:
```javascript
while (true) {
// Check if no active teammates AND no running tasks → break
// Check for unread messages from teammates → re-inject as new prompt, restart EZ loop
// If stdin closed with active teammates → inject shutdown prompt
// Poll every 500ms
}
```
From the SDK consumer's perspective: you receive the initial `type: "result"`, but the AsyncGenerator may continue yielding more messages as the team leader processes teammate responses and re-enters the agent loop. The generator only truly finishes when all teammates have shut down.
## The isSingleUserTurn Problem
From sdk.mjs:
```javascript
QK = typeof X === "string" // isSingleUserTurn = true when prompt is a string
```
When `isSingleUserTurn` is true and the first `result` message arrives:
```javascript
if (this.isSingleUserTurn) {
this.transport.endInput(); // closes stdin to CLI
}
```
This triggers a chain reaction:
1. SDK closes CLI stdin
2. CLI detects stdin close
3. Polling loop sees `D = true` (stdin closed) with active teammates
4. Injects shutdown prompt → leader sends `shutdown_request` to all teammates
5. **Teammates get killed mid-research**
The shutdown prompt (found via `BGq` variable in minified cli.js):
```
You are running in non-interactive mode and cannot return a response
to the user until your team is shut down.
You MUST shut down your team before preparing your final response:
1. Use requestShutdown to ask each team member to shut down gracefully
2. Wait for shutdown approvals
3. Use the cleanup operation to clean up the team
4. Only then provide your final response to the user
```
### The practical problem
With V1 `query()` + string prompt + agent teams:
1. Leader spawns teammates, they start researching
2. Leader's EZ loop ends ("I've dispatched the team, they're working on it")
3. `type: "result"` emitted
4. SDK sees `isSingleUserTurn = true` → closes stdin immediately
5. Polling loop detects stdin closed + active teammates → injects shutdown prompt
6. Leader sends `shutdown_request` to all teammates
7. **Teammates could be 10 seconds into a 5-minute research task and they get told to stop**
## The Fix: Streaming Input Mode
Instead of passing a string prompt (which sets `isSingleUserTurn = true`), pass an `AsyncIterable<SDKUserMessage>`:
```typescript
// Before (broken for agent teams):
query({ prompt: "do something" })
// After (keeps CLI alive):
query({ prompt: asyncIterableOfMessages })
```
When prompt is an `AsyncIterable`:
- `isSingleUserTurn = false`
- SDK does NOT close stdin after first result
- CLI stays alive, continues processing
- Background agents keep running
- `task_notification` messages flow through the iterator
- We control when to end the iterable
### Additional Benefit: Streaming New Messages
With the async iterable approach, we can push new incoming WhatsApp messages into the iterable while the agent is still working. Instead of queuing messages until the container exits and spawning a new container, we stream them directly into the running session.
### Intended Lifecycle with Agent Teams
With the async iterable fix (`isSingleUserTurn = false`), stdin stays open so the CLI never hits the teammate check or shutdown prompt injection:
```
1. system/init → session initialized
2. assistant/user → Claude reasoning, tool calls, tool results
3. ... → more assistant/user turns (spawning subagents, etc.)
4. result #1 → lead agent's first response (capture)
5. task_notification(s) → background agents complete/fail/stop
6. assistant/user → lead agent continues (processing subagent results)
7. result #2 → lead agent's follow-up response (capture)
8. [iterator done] → CLI closed stdout, all done
```
All results are meaningful — capture every one, not just the first.
## V1 vs V2 API
### V1: `query()` — One-shot async generator
```typescript
const q = query({ prompt: "...", options: {...} });
for await (const msg of q) { /* process events */ }
```
- When `prompt` is a string: `isSingleUserTurn = true` → stdin auto-closes after first result
- For multi-turn: must pass an `AsyncIterable<SDKUserMessage>` and manage coordination yourself
### V2: `createSession()` + `send()` / `stream()` — Persistent session
```typescript
await using session = unstable_v2_createSession({ model: "..." });
await session.send("first message");
for await (const msg of session.stream()) { /* events */ }
await session.send("follow-up");
for await (const msg of session.stream()) { /* events */ }
```
- `isSingleUserTurn = false` always → stdin stays open
- `send()` enqueues into an async queue (`QX`)
- `stream()` yields from the same message generator, stopping on `result` type
- Multi-turn is natural — just alternate `send()` / `stream()`
- V2 does NOT call V1 `query()` internally — both independently create Transport + Query
### Comparison Table
| Aspect | V1 | V2 |
|--------|----|----|
| `isSingleUserTurn` | `true` for string prompt | always `false` |
| Multi-turn | Requires managing `AsyncIterable` | Just call `send()`/`stream()` |
| stdin lifecycle | Auto-closes after first result | Stays open until `close()` |
| Agentic loop | Identical `EZ()` | Identical `EZ()` |
| Stop conditions | Same | Same |
| Session persistence | Must pass `resume` to new `query()` | Built-in via session object |
| API stability | Stable | Unstable preview (`unstable_v2_*` prefix) |
**Key finding: Zero difference in turn behavior.** Both use the same CLI process, the same `EZ()` recursive generator, and the same decision logic.
## Hook Events
```typescript
type HookEvent =
| 'PreToolUse' // Before tool execution
| 'PostToolUse' // After successful tool execution
| 'PostToolUseFailure' // After failed tool execution
| 'Notification' // Notification messages
| 'UserPromptSubmit' // User prompt submitted
| 'SessionStart' // Session started (startup/resume/clear/compact)
| 'SessionEnd' // Session ended
| 'Stop' // Agent stopping
| 'SubagentStart' // Subagent spawned
| 'SubagentStop' // Subagent stopped
| 'PreCompact' // Before conversation compaction
| 'PermissionRequest'; // Permission being requested
```
### Hook Configuration
```typescript
interface HookCallbackMatcher {
matcher?: string; // Optional tool name matcher
hooks: HookCallback[];
}
type HookCallback = (
input: HookInput,
toolUseID: string | undefined,
options: { signal: AbortSignal }
) => Promise<HookJSONOutput>;
```
### Hook Return Values
```typescript
type HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput;
type AsyncHookJSONOutput = { async: true; asyncTimeout?: number };
type SyncHookJSONOutput = {
continue?: boolean;
suppressOutput?: boolean;
stopReason?: string;
decision?: 'approve' | 'block';
systemMessage?: string;
reason?: string;
hookSpecificOutput?:
| { hookEventName: 'PreToolUse'; permissionDecision?: 'allow' | 'deny' | 'ask'; updatedInput?: Record<string, unknown> }
| { hookEventName: 'UserPromptSubmit'; additionalContext?: string }
| { hookEventName: 'SessionStart'; additionalContext?: string }
| { hookEventName: 'PostToolUse'; additionalContext?: string };
};
```
### Subagent Hooks (from sdk.d.ts)
```typescript
type SubagentStartHookInput = BaseHookInput & {
hook_event_name: 'SubagentStart';
agent_id: string;
agent_type: string;
};
type SubagentStopHookInput = BaseHookInput & {
hook_event_name: 'SubagentStop';
stop_hook_active: boolean;
agent_id: string;
agent_transcript_path: string;
agent_type: string;
};
// BaseHookInput = { session_id, transcript_path, cwd, permission_mode? }
```
## Query Interface Methods
The `Query` object (sdk.d.ts:931). Official docs list these public methods:
```typescript
interface Query extends AsyncGenerator<SDKMessage, void> {
interrupt(): Promise<void>; // Stop current execution (streaming input mode only)
rewindFiles(userMessageUuid: string): Promise<void>; // Restore files to state at message (needs enableFileCheckpointing)
setPermissionMode(mode: PermissionMode): Promise<void>; // Change permissions (streaming input mode only)
setModel(model?: string): Promise<void>; // Change model (streaming input mode only)
setMaxThinkingTokens(max: number | null): Promise<void>; // Change thinking tokens (streaming input mode only)
supportedCommands(): Promise<SlashCommand[]>; // Available slash commands
supportedModels(): Promise<ModelInfo[]>; // Available models
mcpServerStatus(): Promise<McpServerStatus[]>; // MCP server connection status
accountInfo(): Promise<AccountInfo>; // Authenticated user info
}
```
Found in sdk.d.ts but NOT in official docs (may be internal):
- `streamInput(stream)` — stream additional user messages
- `close()` — forcefully end the query
- `setMcpServers(servers)` — dynamically add/remove MCP servers
## Sandbox Configuration
```typescript
type SandboxSettings = {
enabled?: boolean;
autoAllowBashIfSandboxed?: boolean;
excludedCommands?: string[];
allowUnsandboxedCommands?: boolean;
network?: {
allowLocalBinding?: boolean;
allowUnixSockets?: string[];
allowAllUnixSockets?: boolean;
httpProxyPort?: number;
socksProxyPort?: number;
};
ignoreViolations?: {
file?: string[];
network?: string[];
};
};
```
When `allowUnsandboxedCommands` is true, the model can set `dangerouslyDisableSandbox: true` in Bash tool input, which falls back to the `canUseTool` permission handler.
## MCP Server Helpers
### tool()
Creates type-safe MCP tool definitions with Zod schemas:
```typescript
function tool<Schema extends ZodRawShape>(
name: string,
description: string,
inputSchema: Schema,
handler: (args: z.infer<ZodObject<Schema>>, extra: unknown) => Promise<CallToolResult>
): SdkMcpToolDefinition<Schema>
```
### createSdkMcpServer()
Creates an in-process MCP server (we use stdio instead for subagent inheritance):
```typescript
function createSdkMcpServer(options: {
name: string;
version?: string;
tools?: Array<SdkMcpToolDefinition<any>>;
}): McpSdkServerConfigWithInstance
```
## Internals Reference
### Key minified identifiers (sdk.mjs)
| Minified | Purpose |
|----------|---------|
| `s_` | V1 `query()` export |
| `e_` | `unstable_v2_createSession` |
| `Xx` | `unstable_v2_resumeSession` |
| `Qx` | `unstable_v2_prompt` |
| `U9` | V2 Session class (`send`/`stream`/`close`) |
| `XX` | ProcessTransport (spawns cli.js) |
| `$X` | Query class (JSON-line routing, async iterable) |
| `QX` | AsyncQueue (input stream buffer) |
### Key minified identifiers (cli.js)
| Minified | Purpose |
|----------|---------|
| `EZ` | Core recursive agentic loop (async generator) |
| `_t4` | Stop hook handler (runs when no tool_use blocks) |
| `PU1` | Streaming tool executor (parallel during API response) |
| `TP6` | Standard tool executor (after API response) |
| `GU1` | Individual tool executor |
| `lTq` | SDK session runner (calls EZ directly) |
| `bd1` | stdin reader (JSON-lines from transport) |
| `mW1` | Anthropic API streaming caller |
## Key Files
- `sdk.d.ts` — All type definitions (1777 lines)
- `sdk-tools.d.ts` — Tool input schemas
- `sdk.mjs` — SDK runtime (minified, 376KB)
- `cli.js` — CLI executable (minified, runs as subprocess)

View File

@@ -1,122 +0,0 @@
# NanoClaw Security Model
## Trust Model
| Entity | Trust Level | Rationale |
|--------|-------------|-----------|
| Main group | Trusted | Private self-chat, admin control |
| Non-main groups | Untrusted | Other users may be malicious |
| Container agents | Sandboxed | Isolated execution environment |
| WhatsApp messages | User input | Potential prompt injection |
## Security Boundaries
### 1. Container Isolation (Primary Boundary)
Agents execute in containers (lightweight Linux VMs), providing:
- **Process isolation** - Container processes cannot affect the host
- **Filesystem isolation** - Only explicitly mounted directories are visible
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
### 2. Mount Security
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
- Outside project root
- Never mounted into containers
- Cannot be modified by agents
**Default Blocked Patterns:**
```
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
private_key, .secret
```
**Protections:**
- Symlink resolution before validation (prevents traversal attacks)
- Container path validation (rejects `..` and absolute paths)
- `nonMainReadOnly` option forces read-only for non-main groups
**Read-Only Project Root:**
The main group's project root is mounted read-only. Writable paths the agent needs (group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart.
### 3. Session Isolation
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
- Groups cannot see other groups' conversation history
- Session data includes full message history and file contents read
- Prevents cross-group information disclosure
### 4. IPC Authorization
Messages and task operations are verified against group identity:
| Operation | Main Group | Non-Main Group |
|-----------|------------|----------------|
| Send message to own chat | ✓ | ✓ |
| Send message to other chats | ✓ | ✗ |
| Schedule task for self | ✓ | ✓ |
| Schedule task for others | ✓ | ✗ |
| View all tasks | ✓ | Own only |
| Manage other groups | ✓ | ✗ |
### 5. Credential Isolation (Credential Proxy)
Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently.
**How it works:**
1. Host starts a credential proxy on `CREDENTIAL_PROXY_PORT` (default: 3001)
2. Containers receive `ANTHROPIC_BASE_URL=http://host.docker.internal:<port>` and `ANTHROPIC_API_KEY=placeholder`
3. The SDK sends API requests to the proxy with the placeholder key
4. The proxy strips placeholder auth, injects real credentials (`x-api-key` or `Authorization: Bearer`), and forwards to `api.anthropic.com`
5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc`
**NOT Mounted:**
- WhatsApp session (`store/auth/`) - host only
- Mount allowlist - external, never mounted
- Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount
## Privilege Comparison
| Capability | Main Group | Non-Main Group |
|------------|------------|----------------|
| Project root access | `/workspace/project` (ro) | None |
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
| Global memory | Implicit via project | `/workspace/global` (ro) |
| Additional mounts | Configurable | Read-only unless allowed |
| Network access | Unrestricted | Unrestricted |
| MCP tools | All | All |
## Security Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────┐
│ UNTRUSTED ZONE │
│ WhatsApp Messages (potentially malicious) │
└────────────────────────────────┬─────────────────────────────────┘
▼ Trigger check, input escaping
┌──────────────────────────────────────────────────────────────────┐
│ HOST PROCESS (TRUSTED) │
│ • Message routing │
│ • IPC authorization │
│ • Mount validation (external allowlist) │
│ • Container lifecycle │
│ • Credential proxy (injects auth headers) │
└────────────────────────────────┬─────────────────────────────────┘
▼ Explicit mounts only, no secrets
┌──────────────────────────────────────────────────────────────────┐
│ CONTAINER (ISOLATED/SANDBOXED) │
│ • Agent execution │
│ • Bash commands (sandboxed) │
│ • File operations (limited to mounts) │
│ • API calls routed through credential proxy │
│ • No real credentials in environment or filesystem │
└──────────────────────────────────────────────────────────────────┘
```

View File

@@ -1,785 +0,0 @@
# NanoClaw Specification
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
---
## Table of Contents
1. [Architecture](#architecture)
2. [Architecture: Channel System](#architecture-channel-system)
3. [Folder Structure](#folder-structure)
4. [Configuration](#configuration)
5. [Memory System](#memory-system)
6. [Session Management](#session-management)
7. [Message Flow](#message-flow)
8. [Commands](#commands)
9. [Scheduled Tasks](#scheduled-tasks)
10. [MCP Servers](#mcp-servers)
11. [Deployment](#deployment)
12. [Security Considerations](#security-considerations)
---
## Architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ 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) │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
```
### Technology Stack
| 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 |
---
## Architecture: Channel System
The core ships with no channels built in — each channel (WhatsApp, Telegram, Slack, Discord, Gmail) is installed as a [Claude Code skill](https://code.claude.com/docs/en/skills) 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.
### System Diagram
```mermaid
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
```
### Channel Registry
The channel system is built on a factory registry in `src/channels/registry.ts`:
```typescript
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.
### Channel Interface
Every channel implements this interface (defined in `src/types.ts`):
```typescript
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>;
}
```
### Self-Registration Pattern
Channels self-register using a barrel-import pattern:
1. Each channel skill adds a file to `src/channels/` (e.g. `whatsapp.ts`, `telegram.ts`) that calls `registerChannel()` at module load time:
```typescript
// 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);
});
```
2. The barrel file `src/channels/index.ts` imports all channel modules, triggering registration:
```typescript
import './whatsapp.js';
import './telegram.js';
// ... each skill adds its import here
```
3. At startup, the orchestrator (`src/index.ts`) loops through registered channels and connects whichever ones return a valid instance:
```typescript
for (const name of getRegisteredChannelNames()) {
const factory = getChannelFactory(name);
const channel = factory?.(channelOpts);
if (channel) {
await channel.connect();
channels.push(channel);
}
}
```
### Key Files
| 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 |
### Adding a New Channel
To add a new channel, contribute a skill to `.claude/skills/add-<name>/` that:
1. Adds a `src/channels/<name>.ts` file implementing the `Channel` interface
2. Calls `registerChannel(name, factory)` at module load
3. Returns `null` from the factory if credentials are missing
4. 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.
---
## Folder Structure
```
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
Configuration constants are in `src/config.ts`:
```typescript
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.
### Container Configuration
Groups can have additional directories mounted via `containerConfig` in the SQLite `registered_groups` table (stored as JSON in the `container_config` column). Example registration:
```typescript
registerGroup("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).
### Claude Authentication
Configure authentication in a `.env` file in the project root. Two options:
**Option 1: Claude Subscription (OAuth token)**
```bash
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**
```bash
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).
### Changing the Assistant Name
Set the `ASSISTANT_NAME` environment variable:
```bash
ASSISTANT_NAME=Bot npm start
```
Or edit the default in `src/config.ts`. This changes:
- The trigger pattern (messages must start with `@YourName`)
- The response prefix (`YourName:` added automatically)
### 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
- 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)
---
## Session Management
Sessions enable conversation continuity - Claude remembers what you talked about.
### How Sessions Work
1. Each group has a session ID stored in SQLite (`sessions` table, keyed by `group_folder`)
2. Session ID is passed to Claude Agent SDK's `resume` option
3. Claude continues the conversation with full context
4. Session transcripts are stored as JSONL files in `data/sessions/{group}/.claude/`
---
## Message Flow
### Incoming Message Flow
```
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
```
### 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)
### Conversation Catch-Up
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.
---
## Commands
### Commands Available in Any Group
| Command | Example | Effect |
|---------|---------|--------|
| `@Assistant [message]` | `@Andy what's the weather?` | Talk to Claude |
### 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 has a built-in scheduler that runs tasks as full agents in their group's context.
### How Scheduling Works
1. **Group Context**: Tasks created in a group run with that group's working directory and memory
2. **Full Agent Capabilities**: Scheduled tasks have access to all tools (WebSearch, file operations, etc.)
3. **Optional Messaging**: Tasks can send messages to their group using the `send_message` tool, or complete silently
4. **Main Channel Privileges**: The main channel can schedule tasks for any group and view all tasks
### Schedule Types
| 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` |
### Creating a Task
```
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.
```
### One-Time Tasks
```
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"
}
```
### Managing Tasks
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
---
## MCP Servers
### NanoClaw MCP (built-in)
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 |
---
## Deployment
NanoClaw runs as a single macOS launchd service.
### Startup Sequence
When NanoClaw starts, it:
1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
2. Initializes the SQLite database (migrates from JSON files if they exist)
3. Loads state from SQLite (registered groups, sessions, router state)
4. **Connects channels** — loops through registered channels, instantiates those with credentials, calls `connect()` on each
5. 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
### 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
### Container Isolation
All 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 `node` user (uid 1000)
### Prompt Injection Risk
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
| 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 |
### 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` |
| "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 |
### 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
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +0,0 @@
# NanoClaw Skills Architecture
## What Skills Are For
NanoClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks.
## Why This Architecture
The problem: users need to combine multiple modifications to a shared codebase, keep those modifications working across core updates, and do all of this without becoming git experts or losing their custom changes. A plugin system would be simpler but constrains what skills can do. Giving skills full codebase access means they can change anything, but that creates merge conflicts, update breakage, and state tracking challenges.
This architecture solves that by making skill application fully programmatic using standard git mechanics, with AI as a fallback for conflicts git can't resolve, and a shared resolution cache so most users never hit those conflicts at all. The result: users compose exactly the features they want, customizations survive core updates automatically, and the system is always recoverable.
## Core Principle
Skills are self-contained, auditable packages applied via standard git merge mechanics. Claude Code orchestrates the process — running git commands, reading skill manifests, and stepping in only when git can't resolve a conflict. The system uses existing git features (`merge-file`, `rerere`, `apply`) rather than custom merge infrastructure.
## Three-Level Resolution Model
Every operation follows this escalation:
1. **Git** — deterministic. `git merge-file` merges, `git rerere` replays cached resolutions, structured operations apply without merging. No AI. Handles the vast majority of cases.
2. **Claude Code** — reads `SKILL.md`, `.intent.md`, and `state.yaml` to resolve conflicts git can't handle. Caches resolutions via `git rerere` so the same conflict never needs resolving twice.
3. **Claude Code + user input** — when Claude Code lacks sufficient context to determine intent (e.g., two features genuinely conflict at an application level), it asks the user for a decision, then uses that input to perform the resolution. Claude Code still does the work — the user provides direction, not code.
**Important**: A clean merge doesn't guarantee working code. Semantic conflicts can produce clean text merges that break at runtime. **Tests run after every operation.**
## Backup/Restore Safety
Before any operation, all affected files are copied to `.nanoclaw/backup/`. On success, backup is deleted. On failure, backup is restored. Works safely for users who don't use git.
## The Shared Base
`.nanoclaw/base/` holds a clean copy of the core codebase. This is the single common ancestor for all three-way merges, only updated during core updates.
## Two Types of Changes
### Code Files (Three-Way Merge)
Source code where skills weave in logic. Merged via `git merge-file` against the shared base. Skills carry full modified files.
### Structured Data (Deterministic Operations)
Files like `package.json`, `docker-compose.yml`, `.env.example`. Skills declare requirements in the manifest; the system applies them programmatically. Multiple skills' declarations are batched — dependencies merged, `package.json` written once, `npm install` run once.
```yaml
structured:
npm_dependencies:
whatsapp-web.js: "^2.1.0"
env_additions:
- WHATSAPP_TOKEN
docker_compose_services:
whatsapp-redis:
image: redis:alpine
ports: ["6380:6379"]
```
Structured conflicts (version incompatibilities, port collisions) follow the same three-level resolution model.
## Skill Package Structure
A skill contains only the files it adds or modifies. Modified code files carry the **full file** (clean core + skill's changes), making `git merge-file` straightforward and auditable.
```
skills/add-whatsapp/
SKILL.md # What this skill does and why
manifest.yaml # Metadata, dependencies, structured ops
tests/whatsapp.test.ts # Integration tests
add/src/channels/whatsapp.ts # New files
modify/src/server.ts # Full modified file for merge
modify/src/server.ts.intent.md # Structured intent for conflict resolution
```
### Intent Files
Each modified file has a `.intent.md` with structured headings: **What this skill adds**, **Key sections**, **Invariants**, and **Must-keep sections**. These give Claude Code specific guidance during conflict resolution.
### Manifest
Declares: skill metadata, core version compatibility, files added/modified, file operations, structured operations, skill relationships (conflicts, depends, tested_with), post-apply commands, and test command.
## Customization and Layering
**One skill, one happy path** — a skill implements the reasonable default for 80% of users.
**Customization is more patching.** Apply the skill, then modify via tracked patches, direct editing, or additional layered skills. Custom modifications are recorded in `state.yaml` and replayable.
**Skills layer via `depends`.** Extension skills build on base skills (e.g., `telegram-reactions` depends on `add-telegram`).
## File Operations
Renames, deletes, and moves are declared in the manifest and run **before** code merges. When core renames a file, a **path remap** resolves skill references at apply time — skill packages are never mutated.
## The Apply Flow
1. Pre-flight checks (compatibility, dependencies, untracked changes)
2. Backup
3. File operations + path remapping
4. Copy new files
5. Merge modified code files (`git merge-file`)
6. Conflict resolution (shared cache → `git rerere` → Claude Code → Claude Code + user input)
7. Apply structured operations (batched)
8. Post-apply commands, update `state.yaml`
9. **Run tests** (mandatory, even if all merges were clean)
10. Clean up (delete backup on success, restore on failure)
## Shared Resolution Cache
`.nanoclaw/resolutions/` ships pre-computed, verified conflict resolutions with **hash enforcement** — a cached resolution only applies if base, current, and skill input hashes match exactly. This means most users never encounter unresolved conflicts for common skill combinations.
### rerere Adapter
`git rerere` requires unmerged index entries that `git merge-file` doesn't create. An adapter sets up the required index state after `merge-file` produces a conflict, enabling rerere caching. This requires the project to be a git repository; users without `.git/` lose caching but not functionality.
## State Tracking
`.nanoclaw/state.yaml` records: core version, all applied skills (with per-file hashes for base/skill/merged), structured operation outcomes, custom patches, and path remaps. This makes drift detection instant and replay deterministic.
## Untracked Changes
Direct edits are detected via hash comparison before any operation. Users can record them as tracked patches, continue untracked, or abort. The three-level model can always recover coherent state from any starting point.
## Core Updates
Most changes propagate automatically through three-way merge. **Breaking changes** require a **migration skill** — a regular skill that preserves the old behavior, authored against the new core. Migrations are declared in `migrations.yaml` and applied automatically during updates.
### Update Flow
1. Preview changes (git-only, no files modified)
2. Backup → file operations → three-way merge → conflict resolution
3. Re-apply custom patches (`git apply --3way`)
4. **Update base** to new core
5. Apply migration skills (preserves user's setup automatically)
6. Re-apply updated skills (version-changed skills only)
7. Re-run structured operations → run all tests → clean up
The user sees no prompts during updates. To accept a new default later, they remove the migration skill.
## Skill Removal
Uninstall is **replay without the skill**: read `state.yaml`, remove the target skill, replay all remaining skills from clean base using the resolution cache. Backup for safety.
## Rebase
Flatten accumulated layers into a clean starting point. Updates base, regenerates diffs, clears old patches and stale cache entries. Trades individual skill history for simpler future merges.
## Replay
Given `state.yaml`, reproduce the exact installation on a fresh machine with no AI (assuming cached resolutions). Apply skills in order, merge, apply custom patches, batch structured operations, run tests.
## Skill Tests
Each skill includes integration tests. Tests run **always** — after apply, after update, after uninstall, during replay, in CI. CI tests all official skills individually and pairwise combinations for skills sharing modified files or structured operations.
## Design Principles
1. **Use git, don't reinvent it.**
2. **Three-level resolution: git → Claude Code → Claude Code + user input.**
3. **Clean merges aren't enough.** Tests run after every operation.
4. **All operations are safe.** Backup/restore, no half-applied state.
5. **One shared base**, only updated on core updates.
6. **Code merges vs. structured operations.** Source code is merged; configs are aggregated.
7. **Resolutions are learned and shared** with hash enforcement.
8. **One skill, one happy path.** Customization is more patching.
9. **Skills layer and compose.**
10. **Intent is first-class and structured.**
11. **State is explicit and complete.** Replay is deterministic.
12. **Always recoverable.**
13. **Uninstall is replay.**
14. **Core updates are the maintainers' responsibility.** Breaking changes require migration skills.
15. **File operations and path remapping are first-class.**
16. **Skills are tested.** CI tests pairwise by overlap.
17. **Deterministic serialization.** No noisy diffs.
18. **Rebase when needed.**
19. **Progressive core slimming** via migration skills.

View File

@@ -1,662 +0,0 @@
# Skills as Branches
## Overview
NanoClaw skills are distributed as git branches on the upstream repository. Applying a skill is a `git merge`. Updating core is a `git merge`. Everything is standard git.
This replaces the previous `skills-engine/` system (three-way file merging, `.nanoclaw/` state, manifest files, replay, backup/restore) with plain git operations and Claude for conflict resolution.
## How It Works
### Repository structure
The upstream repo (`qwibitai/nanoclaw`) maintains:
- `main` — core NanoClaw (no skill code)
- `skill/discord` — main + Discord integration
- `skill/telegram` — main + Telegram integration
- `skill/slack` — main + Slack integration
- `skill/gmail` — main + Gmail integration
- etc.
Each skill branch contains all the code changes for that skill: new files, modified source files, updated `package.json` dependencies, `.env.example` additions — everything. No manifest, no structured operations, no separate `add/` and `modify/` directories.
### Skill discovery and installation
Skills are split into two categories:
**Operational skills** (on `main`, always available):
- `/setup`, `/debug`, `/update-nanoclaw`, `/customize`, `/update-skills`
- These are instruction-only SKILL.md files — no code changes, just workflows
- Live in `.claude/skills/` on `main`, immediately available to every user
**Feature skills** (in marketplace, installed on demand):
- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc.
- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code
- Live in the marketplace repo (`qwibitai/nanoclaw-skills`)
Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently:
```bash
# Claude runs this behind the scenes — users don't see it
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
```
Skills are hot-loaded after `claude plugin install` — no restart needed. This means `/setup` can install the marketplace plugin, then immediately run any feature skill, all in one session.
### Selective skill installation
`/setup` asks users what channels they want, then only offers relevant skills:
1. "Which messaging channels do you want to use?" → Discord, Telegram, Slack, WhatsApp
2. User picks Telegram → Claude installs the plugin and runs `/add-telegram`
3. After Telegram is set up: "Want to add Agent Swarm support for Telegram?" → offers `/add-telegram-swarm`
4. "Want to enable community skills?" → installs community marketplace plugins
Dependent skills (e.g., `telegram-swarm` depends on `telegram`) are only offered after their parent is installed. `/customize` follows the same pattern for post-setup additions.
### Marketplace configuration
NanoClaw's `.claude/settings.json` registers the official marketplace:
```json
{
"extraKnownMarketplaces": {
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
}
}
}
}
```
The marketplace repo uses Claude Code's plugin structure:
```
qwibitai/nanoclaw-skills/
.claude-plugin/
marketplace.json # Plugin catalog
plugins/
nanoclaw-skills/ # Single plugin bundling all official skills
.claude-plugin/
plugin.json # Plugin manifest
skills/
add-discord/
SKILL.md # Setup instructions; step 1 is "merge the branch"
add-telegram/
SKILL.md
add-slack/
SKILL.md
...
```
Multiple skills are bundled in one plugin — installing `nanoclaw-skills` makes all feature skills available at once. Individual skills don't need separate installation.
Each SKILL.md tells Claude to merge the corresponding skill branch as step 1, then walks through interactive setup (env vars, bot creation, etc.).
### Applying a skill
User runs `/add-discord` (discovered via marketplace). Claude follows the SKILL.md:
1. `git fetch upstream skill/discord`
2. `git merge upstream/skill/discord`
3. Interactive setup (create bot, get token, configure env vars, etc.)
Or manually:
```bash
git fetch upstream skill/discord
git merge upstream/skill/discord
```
### Applying multiple skills
```bash
git merge upstream/skill/discord
git merge upstream/skill/telegram
```
Git handles the composition. If both skills modify the same lines, it's a real conflict and Claude resolves it.
### Updating core
```bash
git fetch upstream main
git merge upstream/main
```
Since skill branches are kept merged-forward with main (see CI section), the user's merged-in skill changes and upstream changes have proper common ancestors.
### Checking for skill updates
Users who previously merged a skill branch can check for updates. For each `upstream/skill/*` branch, check whether the branch has commits that aren't in the user's HEAD:
```bash
git fetch upstream
for branch in $(git branch -r | grep 'upstream/skill/'); do
# Check if user has merged this skill at some point
merge_base=$(git merge-base HEAD "$branch" 2>/dev/null) || continue
# Check if the skill branch has new commits beyond what the user has
if ! git merge-base --is-ancestor "$branch" HEAD 2>/dev/null; then
echo "$branch has updates available"
fi
done
```
This requires no state — it uses git history to determine which skills were previously merged and whether they have new commits.
This logic is available in two ways:
- Built into `/update-nanoclaw` — after merging main, optionally check for skill updates
- Standalone `/update-skills` — check and merge skill updates independently
### Conflict resolution
At any merge step, conflicts may arise. Claude resolves them — reading the conflicted files, understanding the intent of both sides, and producing the correct result. This is what makes the branch approach viable at scale: conflict resolution that previously required human judgment is now automated.
### Skill dependencies
Some skills depend on other skills. E.g., `skill/telegram-swarm` requires `skill/telegram`. Dependent skill branches are branched from their parent skill branch, not from `main`.
This means `skill/telegram-swarm` includes all of telegram's changes plus its own additions. When a user merges `skill/telegram-swarm`, they get both — no need to merge telegram separately.
Dependencies are implicit in git history — `git merge-base --is-ancestor` determines whether one skill branch is an ancestor of another. No separate dependency file is needed.
### Uninstalling a skill
```bash
# Find the merge commit
git log --merges --oneline | grep discord
# Revert it
git revert -m 1 <merge-commit>
```
This creates a new commit that undoes the skill's changes. Claude can handle the whole flow.
If the user has modified the skill's code since merging (custom changes on top), the revert might conflict — Claude resolves it.
If the user later wants to re-apply the skill, they need to revert the revert first (git treats reverted changes as "already applied and undone"). Claude handles this too.
## CI: Keeping Skill Branches Current
A GitHub Action runs on every push to `main`:
1. List all `skill/*` branches
2. For each skill branch, merge `main` into it (merge-forward, not rebase)
3. Run build and tests on the merged result
4. If tests pass, push the updated skill branch
5. If a skill fails (conflict, build error, test failure), open a GitHub issue for manual resolution
**Why merge-forward instead of rebase:**
- No force-push — preserves history for users who already merged the skill
- Users can re-merge a skill branch to pick up skill updates (bug fixes, improvements)
- Git has proper common ancestors throughout the merge graph
**Why this scales:** With a few hundred skills and a few commits to main per day, the CI cost is trivial. Haiku is fast and cheap. The approach that wouldn't have been feasible a year or two ago is now practical because Claude can resolve conflicts at scale.
## Installation Flow
### New users (recommended)
1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button)
2. Clone your fork:
```bash
git clone https://github.com/<you>/nanoclaw.git
cd nanoclaw
```
3. Run Claude Code:
```bash
claude
```
4. Run `/setup` — Claude handles dependencies, authentication, container setup, service configuration, and adds `upstream` remote if not present
Forking is recommended because it gives users a remote to push their customizations to. Clone-only works for trying things out but provides no remote backup.
### Existing users migrating from clone
Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations:
1. Fork `qwibitai/nanoclaw` on GitHub
2. Reroute remotes:
```bash
git remote rename origin upstream
git remote add origin https://github.com/<you>/nanoclaw.git
git push --force origin main
```
The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose.
3. From this point, `origin` = their fork, `upstream` = qwibitai/nanoclaw
### Existing users migrating from the old skills engine
Users who previously applied skills via the `skills-engine/` system have skill code in their tree but no merge commits linking to skill branches. Git doesn't know these changes came from a skill, so merging a skill branch on top would conflict or duplicate.
**For new skills going forward:** just merge skill branches as normal. No issue.
**For existing old-engine skills**, two migration paths:
**Option A: Per-skill reapply (keep your fork)**
1. For each old-engine skill: identify and revert the old changes, then merge the skill branch fresh
2. Claude assists with identifying what to revert and resolving any conflicts
3. Custom modifications (non-skill changes) are preserved
**Option B: Fresh start (cleanest)**
1. Create a new fork from upstream
2. Merge the skill branches you want
3. Manually re-apply your custom (non-skill) changes
4. Claude assists by diffing your old fork against the new one to identify custom changes
In both cases:
- Delete the `.nanoclaw/` directory (no longer needed)
- The `skills-engine/` code will be removed from upstream once all skills are migrated
- `/update-skills` only tracks skills applied via branch merge — old-engine skills won't appear in update checks
## User Workflows
### Custom changes
Users make custom changes directly on their main branch. This is the standard fork workflow — their `main` IS their customized version.
```bash
# Make changes
vim src/config.ts
git commit -am "change trigger word to @Bob"
git push origin main
```
Custom changes, skills, and core updates all coexist on their main branch. Git handles the three-way merging at each merge step because it can trace common ancestors through the merge history.
### Applying a skill
Run `/add-discord` in Claude Code (discovered via the marketplace plugin), or manually:
```bash
git fetch upstream skill/discord
git merge upstream/skill/discord
# Follow setup instructions for configuration
git push origin main
```
If the user is behind upstream's main when they merge a skill branch, the merge might bring in some core changes too (since skill branches are merged-forward with main). This is generally fine — they get a compatible version of everything.
### Updating core
```bash
git fetch upstream main
git merge upstream/main
git push origin main
```
This is the same as the existing `/update-nanoclaw` skill's merge path.
### Updating skills
Run `/update-skills` or let `/update-nanoclaw` check after a core update. For each previously-merged skill branch that has new commits, Claude offers to merge the updates.
### Contributing back to upstream
Users who want to submit a PR to upstream:
```bash
git fetch upstream main
git checkout -b my-fix upstream/main
# Make changes
git push origin my-fix
# Create PR from my-fix to qwibitai/nanoclaw:main
```
Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR.
## Contributing a Skill
### Contributor flow
1. Fork `qwibitai/nanoclaw`
2. Branch from `main`
3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.)
4. Open a PR to `main`
The contributor opens a normal PR — they don't need to know about skill branches or marketplace repos. They just make code changes and submit.
### Maintainer flow
When a skill PR is reviewed and approved:
1. Create a `skill/<name>` branch from the PR's commits:
```bash
git fetch origin pull/<PR_NUMBER>/head:skill/<name>
git push origin skill/<name>
```
2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes)
3. Merge the slimmed PR into `main` (just the contributor addition)
4. Add the skill's SKILL.md to the marketplace repo (`qwibitai/nanoclaw-skills`)
This way:
- The contributor gets merge credit (their PR is merged)
- They're added to CONTRIBUTORS.md automatically by the maintainer
- The skill branch is created from their work
- `main` stays clean (no skill code)
- The contributor only had to do one thing: open a PR with code changes
**Note:** GitHub PRs from forks have "Allow edits from maintainers" checked by default, so the maintainer can push to the contributor's PR branch.
### Skill SKILL.md
The contributor can optionally provide a SKILL.md (either in the PR or separately). This goes into the marketplace repo and contains:
1. Frontmatter (name, description, triggers)
2. Step 1: Merge the skill branch
3. Steps 2-N: Interactive setup (create bot, get token, configure env vars, verify)
If the contributor doesn't provide a SKILL.md, the maintainer writes one based on the PR.
## Community Marketplaces
Anyone can maintain their own fork with skill branches and their own marketplace repo. This enables a community-driven skill ecosystem without requiring write access to the upstream repo.
### How it works
A community contributor:
1. Maintains a fork of NanoClaw (e.g., `alice/nanoclaw`)
2. Creates `skill/*` branches on their fork with their custom skills
3. Creates a marketplace repo (e.g., `alice/nanoclaw-skills`) with a `.claude-plugin/marketplace.json` and plugin structure
### Adding a community marketplace
If the community contributor is trusted, they can open a PR to add their marketplace to NanoClaw's `.claude/settings.json`:
```json
{
"extraKnownMarketplaces": {
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
}
},
"alice-nanoclaw-skills": {
"source": {
"source": "github",
"repo": "alice/nanoclaw-skills"
}
}
}
}
```
Once merged, all NanoClaw users automatically discover the community marketplace alongside the official one.
### Installing community skills
`/setup` and `/customize` ask users whether they want to enable community skills. If yes, Claude installs community marketplace plugins via `claude plugin install`:
```bash
claude plugin install alice-skills@alice-nanoclaw-skills --scope project
```
Community skills are hot-loaded and immediately available — no restart needed. Dependent skills are only offered after their prerequisites are met (e.g., community Telegram add-ons only after Telegram is installed).
Users can also browse and install community plugins manually via `/plugin`.
### Properties of this system
- **No gatekeeping required.** Anyone can create skills on their fork without permission. They only need approval to be listed in the auto-discovered marketplaces.
- **Multiple marketplaces coexist.** Users see skills from all trusted marketplaces in `/plugin`.
- **Community skills use the same merge pattern.** The SKILL.md just points to a different remote:
```bash
git remote add alice https://github.com/alice/nanoclaw.git
git fetch alice skill/my-cool-feature
git merge alice/skill/my-cool-feature
```
- **Users can also add marketplaces manually.** Even without being listed in settings.json, users can run `/plugin marketplace add alice/nanoclaw-skills` to discover skills from any source.
- **CI is per-fork.** Each community maintainer runs their own CI to keep their skill branches merged-forward. They can use the same GitHub Action as the upstream repo.
## Flavors
A flavor is a curated fork of NanoClaw — a combination of skills, custom changes, and configuration tailored for a specific use case (e.g., "NanoClaw for Sales," "NanoClaw Minimal," "NanoClaw for Developers").
### Creating a flavor
1. Fork `qwibitai/nanoclaw`
2. Merge in the skills you want
3. Make custom changes (trigger word, prompts, integrations, etc.)
4. Your fork's `main` IS the flavor
### Installing a flavor
During `/setup`, users are offered a choice of flavors before any configuration happens. The setup skill reads `flavors.yaml` from the repo (shipped with upstream, always up to date) and presents options:
AskUserQuestion: "Start with a flavor or default NanoClaw?"
- Default NanoClaw
- NanoClaw for Sales — Gmail + Slack + CRM (maintained by alice)
- NanoClaw Minimal — Telegram-only, lightweight (maintained by bob)
If a flavor is chosen:
```bash
git remote add <flavor-name> https://github.com/alice/nanoclaw.git
git fetch <flavor-name> main
git merge <flavor-name>/main
```
Then setup continues normally (dependencies, auth, container, service).
**This choice is only offered on a fresh fork** — when the user's main matches or is close to upstream's main with no local commits. If `/setup` detects significant local changes (re-running setup on an existing install), it skips the flavor selection and goes straight to configuration.
After installation, the user's fork has three remotes:
- `origin` — their fork (push customizations here)
- `upstream` — `qwibitai/nanoclaw` (core updates)
- `<flavor-name>` — the flavor fork (flavor updates)
### Updating a flavor
```bash
git fetch <flavor-name> main
git merge <flavor-name>/main
```
The flavor maintainer keeps their fork updated (merging upstream, updating skills). Users pull flavor updates the same way they pull core updates.
### Flavors registry
`flavors.yaml` lives in the upstream repo:
```yaml
flavors:
- name: NanoClaw for Sales
repo: alice/nanoclaw
description: Gmail + Slack + CRM integration, daily pipeline summaries
maintainer: alice
- name: NanoClaw Minimal
repo: bob/nanoclaw
description: Telegram-only, no container overhead
maintainer: bob
```
Anyone can PR to add their flavor. The file is available locally when `/setup` runs since it's part of the cloned repo.
### Discoverability
- **During setup** — flavor selection is offered as part of the initial setup flow
- **`/browse-flavors` skill** — reads `flavors.yaml` and presents options at any time
- **GitHub topics** — flavor forks can tag themselves with `nanoclaw-flavor` for searchability
- **Discord / website** — community-curated lists
## Migration
Migration from the old skills engine to branches is complete. All feature skills now live on `skill/*` branches, and the skills engine has been removed.
### Skill branches
| Branch | Base | Description |
|--------|------|-------------|
| `skill/whatsapp` | `main` | WhatsApp channel |
| `skill/telegram` | `main` | Telegram channel |
| `skill/slack` | `main` | Slack channel |
| `skill/discord` | `main` | Discord channel |
| `skill/gmail` | `main` | Gmail channel |
| `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper voice transcription |
| `skill/image-vision` | `skill/whatsapp` | Image attachment processing |
| `skill/pdf-reader` | `skill/whatsapp` | PDF attachment reading |
| `skill/local-whisper` | `skill/voice-transcription` | Local whisper.cpp transcription |
| `skill/ollama-tool` | `main` | Ollama MCP server for local models |
| `skill/apple-container` | `main` | Apple Container runtime |
| `skill/reactions` | `main` | WhatsApp emoji reactions |
### What was removed
- `skills-engine/` directory (entire engine)
- `scripts/apply-skill.ts`, `scripts/uninstall-skill.ts`, `scripts/rebase.ts`
- `scripts/fix-skill-drift.ts`, `scripts/validate-all-skills.ts`
- `.github/workflows/skill-drift.yml`, `.github/workflows/skill-pr.yml`
- All `add/`, `modify/`, `tests/`, and `manifest.yaml` from skill directories
- `.nanoclaw/` state directory
Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`.
## What Changes
### README Quick Start
Before:
```bash
git clone https://github.com/qwibitai/NanoClaw.git
cd NanoClaw
claude
```
After:
```
1. Fork qwibitai/nanoclaw on GitHub
2. git clone https://github.com/<you>/nanoclaw.git
3. cd nanoclaw
4. claude
5. /setup
```
### Setup skill (`/setup`)
Updates to the setup flow:
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git`
- Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration.
- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart)
- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels
- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp)
- **Optionally enable community marketplaces:** ask if the user wants community skills, install those marketplace plugins too
### `.claude/settings.json`
Marketplace configuration so the official marketplace is auto-registered:
```json
{
"extraKnownMarketplaces": {
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "qwibitai/nanoclaw-skills"
}
}
}
}
```
### Skills directory on main
The `.claude/skills/` directory on `main` retains only operational skills (setup, debug, update-nanoclaw, customize, update-skills). Feature skills (add-discord, add-telegram, etc.) live in the marketplace repo, installed via `claude plugin install` during `/setup` or `/customize`.
### Skills engine removal
The following can be removed:
- `skills-engine/` — entire directory (apply, merge, replay, state, backup, etc.)
- `scripts/apply-skill.ts`
- `scripts/uninstall-skill.ts`
- `scripts/fix-skill-drift.ts`
- `scripts/validate-all-skills.ts`
- `.nanoclaw/` — state directory
- `add/` and `modify/` subdirectories from all skill directories
- Feature skill SKILL.md files from `.claude/skills/` on main (they now live in the marketplace)
Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`.
### New infrastructure
- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution
- **`/update-skills` skill** — checks for and applies skill branch updates using git history
- **`CONTRIBUTORS.md`** — tracks skill contributors
### Update skill (`/update-nanoclaw`)
The update skill gets simpler with the branch-based approach. The old skills engine required replaying all applied skills after merging core updates — that entire step disappears. Skill changes are already in the user's git history, so `git merge upstream/main` just works.
**What stays the same:**
- Preflight (clean working tree, upstream remote)
- Backup branch + tag
- Preview (git log, git diff, file buckets)
- Merge/cherry-pick/rebase options
- Conflict preview (dry-run merge)
- Conflict resolution
- Build + test validation
- Rollback instructions
**What's removed:**
- Skill replay step (was needed by the old skills engine to re-apply skills after core update)
- Re-running structured operations (npm deps, env vars — these are part of git history now)
**What's added:**
- Optional step at the end: "Check for skill updates?" which runs the `/update-skills` logic
- This checks whether any previously-merged skill branches have new commits (bug fixes, improvements to the skill itself — not just merge-forwards from main)
**Why users don't need to re-merge skills after a core update:**
When the user merged a skill branch, those changes became part of their git history. When they later merge `upstream/main`, git performs a normal three-way merge — the skill changes in their tree are untouched, and only core changes are brought in. The merge-forward CI ensures skill branches stay compatible with latest main, but that's for new users applying the skill fresh. Existing users who already merged the skill don't need to do anything.
Users only need to re-merge a skill branch if the skill itself was updated (not just merged-forward with main). The `/update-skills` check detects this.
## Discord Announcement
### For existing users
> **Skills are now git branches**
>
> We've simplified how skills work in NanoClaw. Instead of a custom skills engine, skills are now git branches that you merge in.
>
> **What this means for you:**
> - Applying a skill: `git fetch upstream skill/discord && git merge upstream/skill/discord`
> - Updating core: `git fetch upstream main && git merge upstream/main`
> - Checking for skill updates: `/update-skills`
> - No more `.nanoclaw/` state directory or skills engine
>
> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to.
>
> **If you currently have a clone with local changes**, migrate to a fork:
> 1. Fork `qwibitai/nanoclaw` on GitHub
> 2. Run:
> ```
> git remote rename origin upstream
> git remote add origin https://github.com/<you>/nanoclaw.git
> git push --force origin main
> ```
> This works even if you're way behind — just push your current state.
>
> **If you previously applied skills via the old system**, your code changes are already in your working tree — nothing to redo. You can delete the `.nanoclaw/` directory. Future skills and updates use the branch-based approach.
>
> **Discovering skills:** Skills are now available through Claude Code's plugin marketplace. Run `/plugin` in Claude Code to browse and install available skills.
### For skill contributors
> **Contributing skills**
>
> To contribute a skill:
> 1. Fork `qwibitai/nanoclaw`
> 2. Branch from `main` and make your code changes
> 3. Open a regular PR
>
> That's it. We'll create a `skill/<name>` branch from your PR, add you to CONTRIBUTORS.md, and add the SKILL.md to the marketplace. CI automatically keeps skill branches merged-forward with `main` using Claude to resolve any conflicts.
>
> **Want to run your own skill marketplace?** Maintain skill branches on your fork and create a marketplace repo. Open a PR to add it to NanoClaw's auto-discovered marketplaces — or users can add it manually via `/plugin marketplace add`.