Compare commits

..

1 Commits

Author SHA1 Message Date
gavrielc 420fe5ecff setup skill: inject top-of-flow state via dynamic context
Replace the four auto-run commands that execute before the first user
decision (git upstream check, bash setup.sh, environment probe, timezone
probe) with `!`cmd`` dynamic-context placeholders so their output is
pre-rendered into the skill instead of Claude running them interactively.

Adds matching allowed-tools frontmatter so preprocessing runs without
per-command approval prompts. Later auto-run steps (container build,
mounts, service, verify) stay as body instructions — they depend on
mid-flow user choices and earlier state changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:30:16 +03:00
341 changed files with 5126 additions and 33810 deletions
+36
View File
@@ -1,5 +1,41 @@
{
"sandbox": {
"enabled": false
},
"permissions": {
"allow": [
"Bash(bash setup.sh*)",
"Bash(git remote *)",
"Bash(pnpm exec tsx setup/index.ts*)",
"Bash(pnpm exec tsx scripts/init-first-agent.ts*)",
"Bash(pnpm install @chat-adapter/*)",
"Bash(pnpm install chat-adapter-imessage*)",
"Bash(pnpm install @bitbasti/chat-adapter-webex*)",
"Bash(pnpm install @resend/chat-sdk-adapter*)",
"Bash(pnpm install @whiskeysockets/baileys*)",
"Bash(pnpm install @beeper/chat-adapter-matrix*)",
"Bash(pnpm install @nanoco/nanoclaw-dashboard*)",
"Bash(pnpm install --frozen-lockfile*)",
"Bash(pnpm run build*)",
"Bash(curl -fsSL onecli.sh*)",
"Bash(onecli *)",
"Bash(grep -q *)",
"Bash(echo *>> .env)",
"Bash(ls *)",
"Bash(cat ~/.config/nanoclaw/*)",
"Bash(tail *logs/*)",
"Bash(launchctl *nanoclaw*)",
"Bash(sqlite3 data/*)",
"Bash(docker info*)",
"Bash(docker logs *)",
"Bash(mkdir -p *)",
"Bash(cp .env *)",
"Bash(rsync -a .claude/skills/*)",
"Bash(head *)",
"Bash(xattr *)",
"Bash(find ~/.npm *)",
"Bash(which onecli*)",
"Bash(./container/build.sh*)"
]
}
}
@@ -1,246 +0,0 @@
---
name: add-atomic-chat-tool
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
---
# Add Atomic Chat Integration
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
Tools exposed:
- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
## Phase 1: Pre-flight
### Check if already applied
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
### Check prerequisites
Verify Atomic Chat is installed and its local API server is running. On the host:
```bash
curl -s http://127.0.0.1:1337/v1/models | head
```
If the request fails:
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
2. Open the app.
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
## Phase 2: Apply Code Changes
### Copy the MCP server source
```bash
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
```
### Register the MCP server in the agent-runner
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
};
```
Add an `atomic_chat` entry alongside `nanoclaw`:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
atomic_chat: {
command: 'bun',
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
env: {
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
},
},
};
```
### Add the tool glob to the allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
```ts
'mcp__nanoclaw__*',
'mcp__atomic_chat__*',
];
```
### Forward host env vars into the container
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
```
Add ATOMIC_CHAT forwarding right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
```
### Surface `[ATOMIC]` log lines at info level
In the same file, find the stderr logger:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
```
Replace it with:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (!line) continue;
if (line.includes('[ATOMIC]')) {
log.info(line, { container: agentGroup.folder });
} else {
log.debug(line, { container: agentGroup.folder });
}
}
});
```
### Add env-var stubs to `.env.example`
Append to `.env.example`:
```bash
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
# Default: http://host.docker.internal:1337 (with fallback to localhost)
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
# ATOMIC_CHAT_API_KEY=
```
### Validate code changes
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
All three must be clean before proceeding.
## Phase 3: Configure
### Set Atomic Chat host (optional)
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
```bash
ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337
```
### Set API key (optional)
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
```bash
ATOMIC_CHAT_API_KEY=sk-...
```
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
## Phase 4: Verify
### Test inference
Tell the user:
> Send a message like: "use atomic chat to tell me the capital of France"
>
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i atomic
```
Look for:
- `[ATOMIC] Listing models...` — list request started
- `[ATOMIC] Found N models` — models discovered
- `[ATOMIC] >>> Generating with <model>` — generation started
- `[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
## Troubleshooting
### Agent says "Atomic Chat is not installed" or tries to run a CLI
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
4. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Atomic Chat"
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
2. Confirm the Local API Server is enabled in Atomic Chat's settings
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
### `model not found` / 404 on generate
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
### Slow first response
Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
### Agent doesn't use Atomic Chat tools
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
### Context window or output size issues
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
@@ -1,229 +0,0 @@
/**
* Atomic Chat MCP Server for NanoClaw
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
const ATOMIC_CHAT_HOST =
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
function log(msg: string): void {
console.error(`[ATOMIC] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
} catch {
/* best-effort */
}
}
async function atomicFetch(
apiPath: string,
options?: RequestInit,
): Promise<Response> {
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
const headers: Record<string, string> = {
...((options?.headers as Record<string, string>) || {}),
};
if (ATOMIC_CHAT_API_KEY) {
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
}
const finalOptions: RequestInit = { ...options, headers };
try {
return await fetch(url, finalOptions);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, finalOptions);
}
throw err;
}
}
const server = new McpServer({
name: 'atomic_chat',
version: '1.0.0',
});
server.tool(
'atomic_chat_list_models',
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing available models');
try {
const res = await atomicFetch('/v1/models');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
data?: Array<{ id: string; owned_by?: string }>;
};
const models = data.data || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
},
],
};
}
const list = models
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
.join('\n');
log(`Found ${models.length} models`);
return {
content: [
{ type: 'text' as const, text: `Available models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'atomic_chat_generate',
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
),
prompt: z.string().describe('The prompt to send to the model'),
system: z
.string()
.optional()
.describe('Optional system prompt to set model behavior'),
temperature: z
.number()
.optional()
.describe('Sampling temperature (0.02.0). Defaults to model default.'),
max_tokens: z
.number()
.optional()
.describe('Maximum number of tokens to generate in the response.'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const messages: Array<{ role: string; content: string }> = [];
if (args.system) {
messages.push({ role: 'system', content: args.system });
}
messages.push({ role: 'user', content: args.prompt });
const body: Record<string, unknown> = {
model: args.model,
messages,
stream: false,
};
if (args.temperature !== undefined) body.temperature = args.temperature;
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
const startedAt = Date.now();
const res = await atomicFetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Atomic Chat error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
const response = data.choices?.[0]?.message?.content ?? '';
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
const completionTokens = data.usage?.completion_tokens;
const meta = `\n\n[${args.model} | ${elapsedSec}s${
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
}]`;
log(
`<<< Done: ${args.model} | ${elapsedSec}s | ${
completionTokens ?? '?'
} tokens | ${response.length} chars`,
);
writeStatus(
'done',
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
);
return { content: [{ type: 'text' as const, text: response + meta }] };
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
-161
View File
@@ -1,161 +0,0 @@
---
name: add-codex
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
---
# Codex agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
## Install
### Pre-flight
If all of the following are already present, skip to **Configuration**:
- `src/providers/codex.ts`
- `container/agent-runner/src/providers/codex.ts`
- `container/agent-runner/src/providers/codex-app-server.ts`
- `container/agent-runner/src/providers/codex.factory.test.ts`
- `import './codex.js';` line in `src/providers/index.ts`
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch the providers branch
```bash
git fetch origin providers
```
### 2. Copy the Codex source files
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line — alphabetical placement keeps diffs small.
`src/providers/index.ts`:
```typescript
import './codex.js';
```
`container/agent-runner/src/providers/index.ts`:
```typescript
import './codex.js';
```
### 4. Add the Codex CLI to the container Dockerfile
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
```dockerfile
ARG CODEX_VERSION=0.124.0
```
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
### 5. Build
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
```
## Configuration
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
### Option A — ChatGPT subscription (recommended for individuals)
On the host (not inside the container), run Codex's OAuth login:
```bash
codex login
```
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
### Per group / per session
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
## Operational notes
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
## Verify
```bash
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
- `init` event with a stable thread ID as continuation
- One or more `activity` / `progress` events during the turn
- `result` event with the model's reply
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
+135
View File
@@ -0,0 +1,135 @@
---
name: add-compact
description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only.
---
# Add /compact Command
Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts.
**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context.
## Phase 1: Pre-flight
Check if `src/session-commands.ts` exists:
```bash
test -f src/session-commands.ts && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
## Phase 2: Apply Code Changes
Merge the skill branch:
```bash
git fetch upstream skill/compact
git merge upstream/skill/compact
```
> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly.
This adds:
- `src/session-commands.ts` (extract and authorize session commands)
- `src/session-commands.test.ts` (unit tests for command parsing and auth)
- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`)
- Slash command handling in `container/agent-runner/src/index.ts`
### Validate
```bash
pnpm test
pnpm run build
```
### Rebuild container
```bash
./container/build.sh
```
### Restart service
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 3: Verify
### Integration Test
1. Start NanoClaw in dev mode: `pnpm run dev`
2. From the **main group** (self-chat), send exactly: `/compact`
3. Verify:
- The agent acknowledges compaction (e.g., "Conversation compacted.")
- The session continues — send a follow-up message and verify the agent responds coherently
- A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook)
- Container logs show `Compact boundary observed` (confirms SDK actually compacted)
- If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed"
4. From a **non-main group** as a non-admin user, send: `@<assistant> /compact`
5. Verify:
- The bot responds with "Session commands require admin access."
- No compaction occurs, no container is spawned for the command
6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@<assistant> /compact`
7. Verify:
- Compaction proceeds normally (same behavior as main group)
8. While an **active container** is running for the main group, send `/compact`
9. Verify:
- The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work)
- Compaction proceeds via a new container once the active one exits
- The command is not dropped (no cursor race)
10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch):
11. Verify:
- Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls)
- Compaction proceeds after pre-compact messages are processed
- Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle
12. From a **non-main group** as a non-admin user, send `@<assistant> /compact`:
13. Verify:
- Denial message is sent ("Session commands require admin access.")
- The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls
- Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval)
- No container is killed or interrupted
14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix):
15. Verify:
- No denial message is sent (trigger policy prevents untrusted bot responses)
- The `/compact` is consumed silently
- Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable
16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it
### Validation on Fresh Clone
```bash
git clone <your-fork> /tmp/nanoclaw-test
cd /tmp/nanoclaw-test
claude # then run /add-compact
pnpm run build
pnpm test
./container/build.sh
# Manual: send /compact from main group, verify compaction + continuation
# Manual: send @<assistant> /compact from non-main as non-admin, verify denial
# Manual: send @<assistant> /compact from non-main as admin, verify allowed
# Manual: verify no auto-compaction behavior
```
## Security Constraints
- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group.
- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill.
- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl.
- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it.
- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context.
## What This Does NOT Do
- No automatic compaction threshold (add separately if desired)
- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset)
- No cross-group compaction (each group's session is isolated)
- No changes to the container image, Dockerfile, or build script
## Troubleshooting
- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied.
- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded.
- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt.
+2 -5
View File
@@ -93,13 +93,10 @@ Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes
### 6. Build and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart nanoclaw # Linux
# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
### 7. Verify
-65
View File
@@ -1,65 +0,0 @@
# Remove DeltaChat
## 1. Disable the adapter
Comment out the import in `src/channels/index.ts`:
```typescript
// import './deltachat.js';
```
## 2. Remove credentials
Remove the `DC_*` lines from `.env`:
```bash
DC_EMAIL
DC_PASSWORD
DC_IMAP_HOST
DC_IMAP_PORT
DC_SMTP_HOST
DC_SMTP_PORT
```
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
## 4. Remove account data (optional)
To fully remove all account data including DeltaChat encryption keys:
```bash
rm -rf dc-account/
```
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
To keep the account for later reinstall, leave `dc-account/` intact.
## 5. Remove the package (optional)
```bash
pnpm remove @deltachat/stdio-rpc-server
```
## Verification
After removal, confirm the adapter is no longer starting:
```bash
grep "deltachat" logs/nanoclaw.log | tail -5
```
Expected: no `Channel adapter started` entry after the last restart.
-258
View File
@@ -1,258 +0,0 @@
---
name: add-deltachat
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
---
# Add DeltaChat Channel
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/deltachat.ts` exists
- `src/channels/index.ts` contains `import './deltachat.js';`
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
import './deltachat.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 5. Build
```bash
pnpm run build
```
## Account Setup
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
To find the correct hostnames for a domain:
```bash
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
```
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
## Credentials
Add to `.env`:
```bash
DC_EMAIL=bot@example.com
DC_PASSWORD=your-app-password
DC_IMAP_HOST=imap.example.com
DC_IMAP_PORT=993
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
DC_SMTP_HOST=smtp.example.com
DC_SMTP_PORT=587
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
```
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
| Variable | Default | Description |
|----------|---------|-------------|
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
## Wiring
### DMs
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
#### Step 1 — Get the invite link
After the service starts, the adapter logs the invite URL and writes a QR SVG:
```bash
grep "invite link" logs/nanoclaw.log | tail -1
# url field contains the https://i.delta.chat/... invite link
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
```
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
#### Step 2 — Add the bot in DeltaChat
Two options for the user to connect:
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
After accepting, DeltaChat exchanges keys and creates the chat automatically.
#### Step 3 — Wire the chat to an agent
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
```bash
pnpm exec tsx scripts/init-first-agent.ts \
--channel deltachat \
--user-id deltachat:user@example.com \
--platform-id <platform_id from above> \
--display-name "Your Name"
```
### Groups
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `deltachat`
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
- **supports-threads**: no — DeltaChat has no thread model
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
- **user-id-format**: `deltachat:{email}` — the contact's email address
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
### Connectivity model
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
| Range | Meaning |
|-------|---------|
| 10001999 | Not connected |
| 20002999 | Connecting |
| 30003999 | Working (IMAP fetching) |
| ≥ 4000 | Fully connected (IMAP IDLE) |
## Troubleshooting
### Adapter not starting — credentials missing
```bash
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
```
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
### Account configure fails
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
```
Common causes:
- Wrong IMAP/SMTP hostnames — double-check provider docs
- App password not generated — Gmail and some others require this when 2FA is enabled
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
### Messages not arriving
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
### Stale lock file after crash
```bash
rm -f dc-account/accounts.lock
systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"
```
### Bot not responding after restart
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
```bash
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
```
### Messages received but agent not responding
The messaging group exists but may not be wired to an agent group. Run:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
```
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
-54
View File
@@ -1,54 +0,0 @@
# Verify DeltaChat
## 1. Check the adapter started
```bash
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
```
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
## 2. Check IMAP/SMTP connectivity
Replace with your provider's hostnames from `.env`:
```bash
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
```
## 3. End-to-end message test
1. Open DeltaChat on your device
2. Add the bot email address as a contact
3. Send a message
4. The bot should respond within a few seconds
If nothing arrives, check:
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
```
## 4. Check messaging group was created
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
## 5. Verify user access
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.
+1 -1
View File
@@ -44,7 +44,7 @@ import './discord.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/discord@4.27.0
pnpm install @chat-adapter/discord@4.26.0
```
### 5. Build
+127 -140
View File
@@ -1,11 +1,12 @@
---
name: add-emacs
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Local HTTP bridge — no bot token or external service needed.
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed.
---
# Add Emacs Channel
Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
This skill adds Emacs support to NanoClaw, then walks through interactive setup.
Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
## What you can do with this
@@ -14,99 +15,95 @@ Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, an
- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node
- **Draft writing** — send org prose; receive revisions or continuations in place
- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it
- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR")
## Install
## Phase 1: Pre-flight
NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and the Lisp client in from the `channels` branch. Native HTTP bridge — no Chat SDK, no adapter package.
### Check if already applied
### Pre-flight (idempotent)
Skip to **Enable** if all of these are already in place:
- `src/channels/emacs.ts` exists
- `emacs/nanoclaw.el` exists
- `src/channels/index.ts` contains `import './emacs.js';`
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
Check if `src/channels/emacs.ts` exists:
```bash
git fetch origin channels
test -f src/channels/emacs.ts && echo "already applied" || echo "not applied"
```
### 2. Copy the adapter and Lisp client
If it exists, skip to Phase 3 (Setup). The code changes are already in place.
## Phase 2: Apply Code Changes
### Ensure the upstream remote
```bash
mkdir -p emacs
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
git remote -v
```
### 3. Append the self-registration import
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
add it:
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './emacs.js';
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### 4. Build
### Merge the skill branch
```bash
git fetch upstream skill/emacs
git merge upstream/skill/emacs
```
If there are merge conflicts on `pnpm-lock.yaml`, resolve them by accepting the incoming
version and continuing:
```bash
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
```
For any other conflict, read the conflicted file and reconcile both sides manually.
This adds:
- `src/channels/emacs.ts``EmacsBridgeChannel` HTTP server (port 8766)
- `src/channels/emacs.test.ts` — unit tests
- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`)
- `import './emacs.js'` appended to `src/channels/index.ts`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
pnpm run build
pnpm exec vitest run src/channels/emacs.test.ts
```
No npm package to install — the adapter uses only Node builtins (`http`).
Build must be clean and tests must pass before proceeding.
## Enable
## Phase 3: Setup
The adapter is gated by `EMACS_ENABLED` so the HTTP port isn't opened on hosts that aren't running Emacs. Add to `.env`:
### Configure environment (optional)
The channel works out of the box with defaults. Add to `.env` only if you need non-defaults:
```bash
EMACS_ENABLED=true
EMACS_CHANNEL_PORT=8766 # optional — change only if 8766 is taken
EMACS_AUTH_TOKEN= # optional — set to a random string to lock the endpoint
EMACS_PLATFORM_ID=default # optional — only change if you want a non-default chat id
EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use
EMACS_AUTH_TOKEN=<random> # optional — locks the endpoint to Emacs only
```
Generate an auth token (recommended even on single-user machines — prevents other local processes from poking the endpoint):
If you change or add values, sync to the container environment:
```bash
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
mkdir -p data/env && cp .env data/env/env
```
## Wire the channel
### Configure Emacs
Emacs is a single-user, single-chat channel. One host = one messaging group with `platform_id = "default"`.
### If this is your first agent group
Run `/init-first-agent` — pick **Emacs** as the channel, use any short handle as the "user id" (e.g. your OS username), and the skill will create the agent group, wire the channel, and write a welcome message that the agent delivers back to your Emacs buffer.
### Otherwise — wire to an existing agent group
Run the `register` step directly. The `EMACS_PLATFORM_ID` (default `default`) becomes the messaging group's platform id:
```bash
pnpm exec tsx setup/index.ts --step register -- \
--platform-id "default" --name "Emacs" \
--folder "<existing-folder>" --channel "emacs" \
--session-mode "agent-shared" \
--assistant-name "<existing-assistant-name>"
```
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
## Configure Emacs
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed.
AskUserQuestion: Which Emacs distribution are you using?
- **Doom Emacs** `config.el` with `map!` keybindings
- **Spacemacs** `dotspacemacs/user-config` in `~/.spacemacs`
- **Vanilla Emacs / other** `init.el` with `global-set-key`
- **Doom Emacs** - config.el with map! keybindings
- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs
- **Vanilla Emacs / other** - init.el with global-set-key
**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`):
@@ -120,7 +117,7 @@ AskUserQuestion: Which Emacs distribution are you using?
:desc "Send org" "o" #'nanoclaw-org-send)
```
Reload: `M-x doom/reload`
Then reload: `M-x doom/reload`
**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`:
@@ -132,9 +129,9 @@ Reload: `M-x doom/reload`
(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
```
Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
**Vanilla Emacs** — add to `~/.emacs.d/init.el`:
**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`):
```elisp
;; NanoClaw — personal AI assistant channel
@@ -144,78 +141,61 @@ Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
(global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
```
Reload: `M-x eval-buffer` or restart Emacs.
Then reload: `M-x eval-buffer` or restart Emacs.
Replace `~/src/nanoclaw/emacs/nanoclaw.el` with your actual NanoClaw checkout path.
If `EMACS_AUTH_TOKEN` is set, also add (any distribution):
If `EMACS_AUTH_TOKEN` was set, also add (any distribution):
```elisp
(setq nanoclaw-auth-token "<your-token>")
```
If you changed `EMACS_CHANNEL_PORT` from the default:
If `EMACS_CHANNEL_PORT` was changed from the default, also add:
```elisp
(setq nanoclaw-port <your-port>)
```
## Restart NanoClaw
Run from your NanoClaw project root:
### Restart NanoClaw
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Verify
## Phase 4: Verify
### HTTP endpoint
### Test the HTTP endpoint
```bash
curl -s http://localhost:8766/api/messages?since=0
curl -s "http://localhost:8766/api/messages?since=0"
```
Expected: `{"messages":[]}`. With an auth token:
Expected: `{"messages":[]}`
If you set `EMACS_AUTH_TOKEN`:
```bash
curl -s -H "Authorization: Bearer <token>" http://localhost:8766/api/messages?since=0
curl -s -H "Authorization: Bearer <token>" "http://localhost:8766/api/messages?since=0"
```
### From Emacs
### Test from Emacs
Tell the user:
> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`)
> 2. Type a message and press `C-c C-c` to send (RET inserts newlines)
> 3. A response should appear within a few seconds
> 2. Type a message and press `RET`
> 3. A response from Andy should appear within a few seconds
>
> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o`
### Log line
### Check logs if needed
`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup.
```bash
tail -f logs/nanoclaw.log
```
## Channel Info
- **type**: `emacs`
- **terminology**: Single local buffer. There are no "groups" or separate chats — one host = one chat, addressed by a `platform_id` string (default `default`).
- **how-to-find-id**: The platform id is whatever you set in `EMACS_PLATFORM_ID` (default `default`). User handles are arbitrary; your OS username or first name is fine (e.g. `emacs:<username>`).
- **supports-threads**: no
- **typical-use**: Single developer talking to the assistant from within Emacs, alongside whatever other channel they use (Slack, Telegram, Discord).
- **default-isolation**: Same agent group as the primary DM, with `session-mode = agent-shared` so a conversation started elsewhere continues in Emacs. Pick a separate folder only if you specifically want an Emacs-only persona.
### Features
- Interactive chat buffer (`nanoclaw-chat`) with markdown → org-mode rendering
- Org integration (`nanoclaw-org-send`) — sends the current subtree or region; reply lands as a child heading
- Optional bearer-token auth for the local endpoint
- Single-user: the adapter exposes exactly one messaging group per host
Not applicable (design): multi-user channels, threads, cold DM initiation, typing indicators, attachments.
Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent.
## Troubleshooting
@@ -225,53 +205,66 @@ Not applicable (design): multi-user channels, threads, cold DM initiation, typin
Error: listen EADDRINUSE: address already in use :::8766
```
Either a stale NanoClaw is running or another app has the port. Kill stale process or change port:
Either a stale NanoClaw process is running, or 8766 is taken by another app.
Find and kill the stale process:
```bash
lsof -ti :8766 | xargs kill -9
# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port)
```
### Adapter not starting
If `grep "Emacs channel listening" logs/nanoclaw.log` returns nothing, check that `EMACS_ENABLED=true` is in `.env` and that the adapter import is present:
```bash
grep -q '^EMACS_ENABLED=true' .env && echo "enabled" || echo "not enabled"
grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "not imported"
```
Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config.
### No response from agent
1. NanoClaw running: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
Check:
1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"`
3. Logs show activity: `tail -50 logs/nanoclaw.log`
If no messaging group row exists, run the `register` command above.
If the group is not registered, it will be created automatically on the next NanoClaw restart.
### Auth token mismatch (401 Unauthorized)
```elisp
M-x describe-variable RET nanoclaw-auth-token RET
```
Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too:
Verify the token in Emacs matches `.env`:
```elisp
(setq nanoclaw-auth-token nil)
;; M-x describe-variable RET nanoclaw-auth-token RET
```
Must exactly match `EMACS_AUTH_TOKEN` in `.env`.
### nanoclaw.el not loading
Check the path is correct:
```bash
ls ~/src/nanoclaw/emacs/nanoclaw.el
```
If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config.
## After Setup
If running `pnpm run dev` while the service is active:
```bash
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
pnpm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user stop nanoclaw
# pnpm run dev
# systemctl --user start nanoclaw
```
## Agent Formatting
The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax:
The Emacs bridge converts markdown → org-mode automatically. Agents should
output standard markdown — **not** org-mode syntax. The conversion handles:
| Markdown | Org-mode |
|----------|----------|
@@ -281,22 +274,16 @@ The Emacs bridge converts markdown → org-mode automatically. Agents should out
| `` `code` `` | `~code~` |
| ` ```lang ` | `#+begin_src lang` |
If an agent outputs org-mode directly, markers get double-converted and render incorrectly.
If an agent outputs org-mode directly, bold/italic/etc. will be double-converted
and render incorrectly.
## Removal
Run from your NanoClaw project root:
To remove the Emacs channel:
```bash
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
# Remove the `import './emacs.js';` line from src/channels/index.ts
# Remove EMACS_* lines from .env
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el`
2. Remove `import './emacs.js'` from `src/channels/index.ts`
3. Remove the NanoClaw block from your Emacs config file
4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"`
5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set
6. Rebuild: `pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `pnpm run build && systemctl --user restart nanoclaw` (Linux)
-238
View File
@@ -1,238 +0,0 @@
---
name: add-gcal-tool
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
---
# Add Google Calendar Tool (OneCLI-native)
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
## Phase 1: Pre-flight
### Verify OneCLI has Google Calendar connected
```bash
onecli apps get --provider google-calendar
```
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
### Verify stub credentials exist
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
```bash
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
```
If both exist with `onecli-managed`:
```bash
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
```
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
If absent, write them:
```bash
mkdir -p ~/.calendar-mcp
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.calendar-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
}
EOF
chmod 600 ~/.calendar-mcp/*.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject the Google Calendar token:
```bash
onecli agents list
```
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
## Phase 2: Apply Code Changes
### Check if already applied
```bash
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
```dockerfile
ARG CALENDAR_MCP_VERSION=2.6.1
```
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `calendar` in Phase 3 automatically allows `mcp__calendar__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
### Rebuild the container image
```bash
./container/build.sh
```
## Phase 3: Wire Per-Agent-Group
For each agent group, persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.calendar` entry and an `additionalMounts` entry for `.calendar-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
### Register the MCP server
For each chosen `<group-id>` (use `ncl groups list` to enumerate):
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name calendar \
--command google-calendar-mcp \
--args '[]' \
--env '{"GOOGLE_OAUTH_CREDENTIALS":"/workspace/extra/.calendar-mcp/gcp-oauth.keys.json","GOOGLE_CALENDAR_MCP_TOKEN_PATH":"/workspace/extra/.calendar-mcp/credentials.json"}'
```
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
### Add the `.calendar-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
## Phase 4: Build and Restart
```bash
pnpm run build
```
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Kill any existing agent containers so they respawn with the new mcpServers config:
```bash
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
```
## Phase 5: Verify
### Test from a wired agent
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
>
> First call takes 23s while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
```
Common signals:
- `command not found: google-calendar-mcp` → image not rebuilt.
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
- Agent says "I don't have calendar tools" → the `calendar` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (`./container/build.sh`, `--no-cache` if suspicious).
## Removal
1. For each group that had Calendar wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name calendar
```
2. Remove the `.calendar-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
## Credits & references
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
+1 -1
View File
@@ -44,7 +44,7 @@ import './gchat.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/gchat@4.27.0
pnpm install @chat-adapter/gchat@4.26.0
```
### 5. Build
+17 -79
View File
@@ -7,10 +7,6 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
## Prerequisites
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
@@ -48,7 +44,7 @@ import './github.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/github@4.27.0
pnpm install @chat-adapter/github@4.26.0
```
### 5. Build
@@ -59,98 +55,40 @@ pnpm run build
## Credentials
### 1. Create a Personal Access Token for the bot account
> 1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
> 2. Create a **Fine-grained token** with:
> - Repository access: select the repos you want the bot to monitor
> - Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
> 3. Copy the token
> 4. Set up a webhook on your repo(s):
> - Go to **Settings** > **Webhooks** > **Add webhook**
> - Payload URL: `https://your-domain/webhook/github`
> - Content type: `application/json`
> - Secret: generate a random string
> - Events: select **Issue comments**, **Pull request review comments**
Log in as your **bot account**, then:
1. Go to [Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
2. Create a **Fine-grained token** with:
- Repository access: select the repos you want the bot to monitor
- Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
3. Copy the token
### 2. Set up a webhook on each repo
On each repo (logged in as the repo owner/admin):
1. Go to **Settings** > **Webhooks** > **Add webhook**
2. Payload URL: `https://your-domain/webhook/github` (the shared webhook server, default port 3000)
3. Content type: `application/json`
4. Secret: generate a random string (e.g. `openssl rand -hex 20`)
5. Events: select **Issue comments** and **Pull request review comments**
### 3. Configure environment
### Configure environment
Add to `.env`:
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public repo?**
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
```sql
-- Create messaging group (one per repo)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
```
Replace `<policy>` with `public` or `strict` based on the user's choice above.
### Adding members (for strict mode)
When using `strict`, add each GitHub user who should be able to trigger the agent:
```sql
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
VALUES ('github:<user-id>', 'github', '<username>', datetime('now'));
-- Grant membership to the agent group
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
VALUES ('github:<user-id>', '<agent-group-id>');
```
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
Use `per-thread` session mode so each PR/issue gets its own agent session.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `github`
- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation.
- **how-to-find-id**: The platform ID is `github:owner/repo` (e.g. `github:acme/backend`). Each PR/issue becomes its own thread automatically.
- **how-to-find-id**: The platform ID is `owner/repo` (e.g. `acme/backend`). Each PR/issue becomes its own thread automatically.
- **supports-threads**: yes (PR and issue comment threads are native conversations)
- **typical-use**: Webhook-driven — the agent receives PR and issue comment events and responds in comment threads when @-mentioned. After the first mention, the thread is subscribed and the agent responds to all follow-up comments.
- **default-isolation**: Use `per-thread` session mode. Each PR or issue gets its own isolated agent session. Typically wire to a dedicated agent group if the repo contains sensitive code.
- **typical-use**: Webhook/notification — the agent receives PR and issue events and responds in comment threads
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can summarize PRs and respond to reviews in the same context. Use a separate agent group if the repo contains sensitive code that other channels shouldn't access.
-262
View File
@@ -1,262 +0,0 @@
---
name: add-gmail-tool
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
---
# Add Gmail Tool (OneCLI-native)
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
## Phase 1: Pre-flight
### Verify OneCLI has Gmail connected
```bash
onecli apps get --provider gmail
```
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
### Verify stub credentials exist
```bash
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
```
If both exist and contain `"onecli-managed"`:
```bash
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
...skip to Phase 2.
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
If both files are absent, write them now:
```bash
mkdir -p ~/.gmail-mcp
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.gmail-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
}
EOF
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
```bash
onecli agents list
```
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first):
```bash
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
onecli agents secrets --id <agent-id>
```
## Phase 2: Apply Code Changes
### Check if already applied
```bash
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block:
```dockerfile
ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG BUN_VERSION=1.3.12
```
Add a new line:
```dockerfile
ARG GMAIL_MCP_VERSION=1.1.11
```
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 automatically allows `mcp__gmail__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
### Rebuild the container image
```bash
./container/build.sh
```
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
## Phase 3: Wire Per-Agent-Group
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.gmail` entry and an `additionalMounts` entry for `.gmail-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
### List groups, pick which ones get Gmail
```bash
ncl groups list
```
### Register the MCP server
For each chosen `<group-id>`:
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name gmail \
--command gmail-mcp \
--args '[]' \
--env '{"GMAIL_OAUTH_PATH":"/workspace/extra/.gmail-mcp/gcp-oauth.keys.json","GMAIL_CREDENTIALS_PATH":"/workspace/extra/.gmail-mcp/credentials.json"}'
```
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
### Add the `.gmail-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
## Phase 4: Build and Restart
```bash
pnpm run build
```
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
## Phase 5: Verify
### Test from the wired agent
Tell the user:
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
>
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
# Per-container logs — session-scoped:
ls data/v2-sessions/*/stderr.log | head
```
Common signals:
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
- Agent says "I don't have Gmail tools" → the `gmail` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (rebuild with `./container/build.sh`, with `--no-cache` if suspicious).
## Removal
1. For each group that had Gmail wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
2. Remove the `.gmail-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
## Notes
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
## Credits & references
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/nanocoai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/nanocoai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
+236
View File
@@ -0,0 +1,236 @@
---
name: add-gmail
description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
---
# Add Gmail Integration
This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox.
## Phase 1: Pre-flight
### Check if already applied
Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
### Ask the user
Use `AskUserQuestion`:
AskUserQuestion: Should incoming emails be able to trigger the agent?
- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically
- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added.
## Phase 2: Apply Code Changes
### Ensure channel remote
```bash
git remote -v
```
If `gmail` is missing, add it:
```bash
git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git
```
### Merge the skill branch
```bash
git fetch gmail main
git merge gmail/main || {
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
}
```
This merges in:
- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
- `src/channels/gmail.test.ts` (unit tests)
- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts`
- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts`
- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts`
- `googleapis` npm dependency in `package.json`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Add email handling instructions (Channel mode only)
If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section):
```markdown
## Email Notifications
When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
```
### Validate code changes
```bash
pnpm install
pnpm run build
pnpm exec vitest run src/channels/gmail.test.ts
```
All tests must pass (including the new Gmail tests) and build must be clean before proceeding.
## Phase 3: Setup
### Check existing Gmail credentials
```bash
ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found"
```
If `credentials.json` already exists with real tokens (not `onecli-managed` values), skip to "Build and restart" below.
### GCP Project Setup
Check if OneCLI is configured:
```bash
grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual"
```
**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done.
Once the user confirms, run:
```bash
onecli apps get --provider gmail
```
Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values.
**If manual:** Tell the user:
> I need you to set up Google Cloud OAuth credentials:
>
> 1. Open https://console.cloud.google.com — create a new project or select existing
> 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable**
> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID**
> - If prompted for consent screen: choose "External", fill in app name and email, save
> - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail")
> 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json`
>
> Where did you save the file? (Give me the full path, or paste the file contents here)
If user provides a path, copy it:
```bash
mkdir -p ~/.gmail-mcp
cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json
```
If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`.
### OAuth Authorization
Tell the user:
> I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps.
Run the authorization:
```bash
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp auth
```
If that fails (some versions don't have an auth subcommand), try `timeout 60 pnpm dlx @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`.
### Build and restart
Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server):
```bash
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
```
Rebuild the container (agent-runner changed):
```bash
cd container && ./build.sh
```
Then compile and restart:
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test tool access (both modes)
Tell the user:
> Gmail is connected! Send this in your main channel:
>
> `@Andy check my recent emails` or `@Andy list my Gmail labels`
### Test channel mode (Channel mode only)
Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`.
Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
## Troubleshooting
### Gmail connection not responding
Test directly:
```bash
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp
```
### OAuth token expired
Re-authorize:
```bash
rm ~/.gmail-mcp/credentials.json
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp
```
### Container can't access Gmail
- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
### Emails not being detected (Channel mode only)
- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`)
- Check logs for Gmail polling errors
## Removal
### Tool-only mode
1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
3. Rebuild and restart
4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
5. Rebuild: `cd container && ./build.sh && cd .. && pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
### Channel mode
1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts`
2. Remove `import './gmail.js'` from `src/channels/index.ts`
3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
5. Uninstall: `pnpm uninstall googleapis`
6. Rebuild and restart
7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
8. Rebuild: `cd container && ./build.sh && cd .. && pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
+94
View File
@@ -0,0 +1,94 @@
---
name: add-image-vision
description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks.
---
# Image Vision Skill
Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks.
## Phase 1: Pre-flight
1. Check if `src/image.ts` exists — skip to Phase 3 if already applied
2. Confirm `sharp` is installable (native bindings require build tools)
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/image-vision
git merge whatsapp/skill/image-vision || {
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
}
```
This merges in:
- `src/image.ts` (image download, resize via sharp, base64 encoding)
- `src/image.test.ts` (8 unit tests)
- Image attachment handling in `src/channels/whatsapp.ts`
- Image passing to agent in `src/index.ts` and `src/container-runner.ts`
- Image content block support in `container/agent-runner/src/index.ts`
- `sharp` npm dependency in `package.json`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
pnpm install
pnpm run build
pnpm exec vitest run src/image.test.ts
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Configure
1. Rebuild the container (agent-runner changes need a rebuild):
```bash
./container/build.sh
```
2. Sync agent-runner source to group caches:
```bash
for dir in data/sessions/*/agent-runner-src/; do
cp container/agent-runner/src/*.ts "$dir"
done
```
3. Restart the service:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
## Phase 4: Verify
1. Send an image in a registered WhatsApp group
2. Check the agent responds with understanding of the image content
3. Check logs for "Processed image attachment":
```bash
tail -50 groups/*/logs/container-*.log
```
## Troubleshooting
- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections.
- **"Image - processing failed"**: Sharp may not be installed correctly. Run `pnpm ls sharp` to verify.
- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches.
+32 -8
View File
@@ -71,16 +71,40 @@ AskUserQuestion: "Want periodic wiki health checks?"
2. **Monthly**
3. **Skip** — lint manually
If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation.
## Step 6: Restart
Run from your NanoClaw project root:
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
pnpm exec tsx -e "
const Database = require('better-sqlite3');
const { CronExpressionParser } = require('cron-parser');
const db = new Database('store/messages.db');
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
const nextRun = interval.next().toISOString();
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
'wiki-lint',
'<group_folder>',
'<chat_jid>',
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
'cron',
'<cron-expr>',
'group',
nextRun,
'active',
new Date().toISOString()
);
db.close();
"
```
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
## Step 6: Build and restart
```bash
pnpm run build
./container/build.sh
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Tell the user to test by sending a source to the wiki group.
+20 -105
View File
@@ -5,24 +5,11 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
# Add Linear Channel
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
## Prerequisites
**Recommended:** Create a Linear **OAuth application** so the agent posts as an app identity, not as you. This prevents the adapter from filtering your own comments as self-messages.
1. Go to [Linear Settings > API > OAuth Applications](https://linear.app/settings/api/applications/new)
2. Create an app (e.g. "NanoClaw Bot")
- Developer URL: your repo URL (e.g. `https://github.com/your-org/nanoclaw`)
- Callback URL: `http://localhost`
3. After creating, click the app and enable **Client credentials** under grant types
4. Copy the **Client ID** and **Client Secret**
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads.
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch.
### Pre-flight (idempotent)
@@ -31,7 +18,6 @@ Skip to **Credentials** if all of these are already in place:
- `src/channels/linear.ts` exists
- `src/channels/index.ts` contains `import './linear.js';`
- `@chat-adapter/linear` is listed in `package.json` dependencies
- `src/channels/chat-sdk-bridge.ts` contains `catchAll`
Otherwise continue. Every step below is safe to re-run.
@@ -55,42 +41,13 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './linear.js';
```
### 4. Patch the Chat SDK bridge for catch-all message forwarding
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
```typescript
/**
* Forward ALL messages in unsubscribed threads, not just @-mentions.
* Use for platforms where the bot identity can't be @-mentioned (e.g.
* Linear OAuth apps). The thread is auto-subscribed on first message.
*/
catchAll?: boolean;
```
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
```typescript
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
if (config.catchAll) {
chat.onNewMessage(/.*/, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
await thread.subscribe();
});
}
```
### 5. Install the adapter package (pinned)
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.27.0
pnpm install @chat-adapter/linear@4.26.0
```
### 6. Build
### 5. Build
```bash
pnpm run build
@@ -98,79 +55,37 @@ pnpm run build
## Credentials
### 1. Set up a webhook
> 1. Go to [Linear Settings > API Keys](https://linear.app/settings/account/security/api-keys/new)
> 2. Create a **Personal API Key** (or use an OAuth application for team-wide access)
> 3. Copy the API key
> 4. Set up a webhook:
> - Go to **Settings** > **API** > **Webhooks** > **New webhook**
> - URL: `https://your-domain/webhook/linear`
> - Select events: **Comment** (created, updated)
> - Copy the signing secret
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
2. Label: `NanoClaw`
3. URL: `https://your-domain/webhook/linear` (the shared webhook server, default port 3000)
4. Team: select the team you want to monitor
5. Events: check **Comment**
6. Save — copy the **signing secret**
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Configure environment
### Configure environment
Add to `.env`:
```bash
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
# OR Personal API key (simpler, but agent posts as you)
# LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
LINEAR_BOT_USERNAME=NanoClaw Bot
LINEAR_TEAM_KEY=ENG
LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-secret
```
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public Linear workspace?**
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
```
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `linear`
- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation.
- **how-to-find-id**: The platform ID is `linear:<TEAM_KEY>` (e.g. `linear:ENG`). Find your team key in Linear under Settings > Teams. Each issue becomes its own thread automatically.
- **how-to-find-id**: The platform ID is your team key (e.g. `ENG`). Find it in Linear under Settings > Teams. Each issue becomes its own thread automatically.
- **supports-threads**: yes (issue comment threads are native conversations)
- **typical-use**: Webhook-driven — the agent receives all issue comment events and responds automatically. No @-mention needed (Linear OAuth apps can't be @-mentioned).
- **default-isolation**: Use `per-thread` session mode. Each issue comment thread gets its own isolated agent session.
- **typical-use**: Webhook/notification — the agent receives issue comment events and responds in threads
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can discuss issues in the same context as team chat. Use a separate agent group if the Linear team tracks sensitive work.
+14 -71
View File
@@ -47,29 +47,7 @@ import './matrix.js';
pnpm install @beeper/chat-adapter-matrix@0.2.0
```
### 5. Patch matrix-js-sdk ESM imports
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
extensions, which fails under Node 22 strict ESM resolution. Add the missing
extensions (idempotent — safe to re-run):
```bash
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
));
console.log("Patched", f);
'
```
Re-run this after every `pnpm install` that touches the adapter.
### 6. Build
### 5. Build
```bash
pnpm run build
@@ -77,60 +55,25 @@ pnpm run build
## Credentials
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
1. Register a bot account on your Matrix homeserver (e.g., via Element)
2. Get the homeserver URL (e.g., `https://matrix.org` or your self-hosted URL)
3. Get an access token:
- In Element: **Settings** > **Help & About** > **Access Token** (advanced)
- Or via API: `curl -XPOST 'https://matrix.org/_matrix/client/r0/login' -d '{"type":"m.login.password","user":"botuser","password":"..."}'`
4. Note the bot's user ID (e.g., `@botuser:matrix.org`)
### Create a bot account
### Configure environment
1. Open [app.element.io](https://app.element.io) in a private/incognito window (or sign out first)
2. Register a new account for the bot (e.g. `andybot` on matrix.org)
3. Note the bot's user ID (e.g. `@andybot:matrix.org`)
### Choose an auth method
**Option A: Username + Password (simpler)**
No extra steps — just use the bot account's credentials directly. The adapter logs in automatically.
```bash
MATRIX_BASE_URL=https://matrix.org
MATRIX_USERNAME=andybot
MATRIX_PASSWORD=your-bot-password
MATRIX_USER_ID=@andybot:matrix.org
MATRIX_BOT_USERNAME=Andy
```
**Option B: Access Token (recommended for production)**
Get an access token from Element: sign into the bot account → **Settings** > **Help & About** > **Access Token** (under Advanced). Or via API:
```bash
curl -XPOST 'https://matrix.org/_matrix/client/r0/login' \
-d '{"type":"m.login.password","user":"andybot","password":"..."}'
```
Add to `.env`:
```bash
MATRIX_BASE_URL=https://matrix.org
MATRIX_ACCESS_TOKEN=your-access-token
MATRIX_USER_ID=@andybot:matrix.org
MATRIX_BOT_USERNAME=Andy
MATRIX_USER_ID=@botuser:matrix.org
MATRIX_BOT_USERNAME=botuser
```
### Optional settings
```bash
MATRIX_INVITE_AUTOJOIN=true # Auto-accept room invites (default: true)
MATRIX_INVITE_AUTOJOIN_ALLOWLIST=@you:matrix.org # Only accept invites from these users
MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
```
### Configure environment
Add the chosen env vars to `.env`, then sync:
```bash
mkdir -p data/env && cp .env data/env/env
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
@@ -142,7 +85,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **type**: `matrix`
- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`).
- **how-to-find-id**: For DMs, use the bot's `openDM` to resolve the room automatically. For group rooms, in Element click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`.
- **how-to-find-id**: In Element, click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`.
- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability)
- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account).
- **typical-use**: Interactive chat — rooms or direct messages
- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts.
-211
View File
@@ -1,211 +0,0 @@
---
name: add-mnemon
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
---
# Add Mnemon — Persistent Memory
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
## Provider Compatibility
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
Check your provider:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
```
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
## Phase 1: Pre-flight
### Check if already applied
```bash
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
### Check latest mnemon version
```bash
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
```
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
## Phase 2: Apply Changes (Claude Code path)
### 1. Dockerfile — install mnemon binary
Add after the AWS CLI block, before the Bun runtime section:
```dockerfile
# ---- mnemon — persistent agent memory ----------------------------------------
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
### 2. Entrypoint — run mnemon setup on each container start
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
```bash
#!/bin/bash
# NanoClaw agent container entrypoint.
#
# ...existing header comment...
set -e
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
cat > /tmp/input.json
exec bun run /app/src/index.ts < /tmp/input.json
```
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
### 3. Rebuild and smoke-test the image
```bash
./container/build.sh
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Phase 3: Restart and Verify
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### Confirm mnemon hooks are registered
After the next container starts, check that setup ran:
```bash
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
```
Then inspect the hooks inside the running container:
```bash
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
cat /home/node/.claude/settings.json | grep -A5 mnemon
```
### Test memory recall
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
## Phase 2 (OpenCode path) — context injection
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
Then rebuild: `./container/build.sh`
### Verify (OpenCode)
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
```bash
# Also confirm the binary is present in the image:
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Memory Storage
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
```bash
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
```
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
## Migration Guide Update
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
**Dockerfile — after AWS CLI, before Bun runtime:**
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
**`container/entrypoint.sh` — add after `set -e`:**
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
## Troubleshooting
### `mnemon: command not found` in container
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
### Memory not persisting across restarts
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
```bash
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
```
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
### Agent not using past memory
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
```bash
docker exec <container> cat /home/node/.claude/settings.json
```
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
### Setup fails at container start
Run setup manually inside a running container to see the full error:
```bash
docker exec -it <container> mnemon setup --target claude-code --yes --global
```
-182
View File
@@ -1,182 +0,0 @@
---
name: add-ollama-provider
description: Route a NanoClaw agent group to a local Ollama model instead of the Anthropic API. Ollama speaks the Anthropic API natively (v1/messages), so no provider code changes are needed — just env var overrides and a model setting. Use when the user wants to run their agent locally, cut API costs, or experiment with open-weight models. See docs/ollama.md for background.
---
# Add Ollama Provider
Routes an agent group to a local Ollama instance instead of the Anthropic API.
See `docs/ollama.md` for how this works and the tradeoffs involved.
## Prerequisites
1. **Ollama is installed and running** on the host — verify: `curl -s http://localhost:11434/api/tags`
2. **A model is pulled** — e.g. `ollama pull gemma4` or `ollama pull qwen3-coder`
3. **The agent group already exists** — run `/init-first-agent` first if needed
## 1. Check source support
The feature requires two fields in `ContainerConfig` (`env` and `blockedHosts`) and their
corresponding wiring in `container-runner.ts`. Check if already present:
```bash
grep -c 'blockedHosts' src/container-config.ts src/container-runner.ts
```
If either count is 0, apply the changes in steps 1a and 1b. Otherwise skip to step 2.
### 1a. Extend ContainerConfig
In `src/container-config.ts`, add to the `ContainerConfig` interface:
```typescript
env?: Record<string, string>;
blockedHosts?: string[];
```
And in `readContainerConfig`, add inside the returned object:
```typescript
env: raw.env,
blockedHosts: raw.blockedHosts,
```
### 1b. Wire into container-runner
In `src/container-runner.ts`, after the `NANOCLAW_MCP_SERVERS` block, add:
```typescript
// Per-agent-group env overrides — applied last to win over OneCLI values.
if (containerConfig.env) {
for (const [key, value] of Object.entries(containerConfig.env)) {
args.push('-e', `${key}=${value}`);
}
}
// Blocked hosts: resolve to 0.0.0.0 so they are unreachable inside the container.
if (containerConfig.blockedHosts) {
for (const host of containerConfig.blockedHosts) {
args.push('--add-host', `${host}:0.0.0.0`);
}
}
```
### 1c. Fix home directory permissions (if not already done)
The container may run as your host uid (not uid 1000). Check the Dockerfile:
```bash
grep 'chmod.*home/node' container/Dockerfile
```
If it shows `chmod 755`, change it to `chmod 777` so any uid can write there.
Then rebuild the container image: `./container/build.sh`
## 2. Identify the setup
Ask the user (plain text, not AskUserQuestion):
1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"`
2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'`
3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts.
Record as `FOLDER`, `MODEL`, and `BLOCK_ANTHROPIC`.
## 3. Configure container.json
Read `groups/<FOLDER>/container.json`. Add (or merge into) an `env` block and optionally `blockedHosts`:
```json
{
"env": {
"ANTHROPIC_BASE_URL": "http://host.docker.internal:11434",
"ANTHROPIC_API_KEY": "ollama",
"NO_PROXY": "host.docker.internal",
"no_proxy": "host.docker.internal"
},
"blockedHosts": ["api.anthropic.com"]
}
```
Omit `blockedHosts` if the user declined step 2.
**Why these vars:** `ANTHROPIC_BASE_URL` redirects the Anthropic SDK to Ollama.
`ANTHROPIC_API_KEY=ollama` satisfies the SDK's key requirement (Ollama ignores it).
`NO_PROXY` bypasses the OneCLI HTTPS proxy for requests to `host.docker.internal`
so they reach Ollama directly instead of going through the credential gateway.
## 4. Set the model
Read the agent group's shared Claude settings:
```bash
# Find the agent group ID
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
```
Add `"model": "<MODEL>"` to that settings file. Create the file if it doesn't exist:
```json
{
"model": "gemma4:latest"
}
```
If the file already has content, merge the `model` key in — don't overwrite existing keys.
**Why here and not container.json:** Claude Code reads its model from its own settings
file, not from env vars. This file is bind-mounted into the container as `~/.claude/settings.json`.
## 5. Build and restart
Run from your NanoClaw project root:
```bash
export PATH="/opt/homebrew/bin:$PATH"
pnpm run build
source setup/lib/install-slug.sh
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux: systemctl --user restart $(systemd_unit)
```
## 6. Verify
Send a message to the agent. Then confirm:
```bash
# Ollama shows the model as active
curl -s http://localhost:11434/api/ps | grep '"name"'
# Container has the right env vars
CTR=$(docker ps --filter "name=nanoclaw-v2-<FOLDER>" --format "{{.Names}}" | head -1)
docker inspect "$CTR" --format '{{json .HostConfig.ExtraHosts}}'
docker exec "$CTR" env | grep ANTHROPIC
```
Expected: `api.anthropic.com:0.0.0.0` in ExtraHosts, `ANTHROPIC_BASE_URL=http://host.docker.internal:11434`.
## Reverting to Claude
To switch back to the Anthropic API:
1. Remove the `env` and `blockedHosts` keys from `groups/<FOLDER>/container.json`
2. Remove `"model"` from the shared settings file
3. Restart the service
No rebuild needed — both files are read at container spawn time.
## Troubleshooting
**Agent hangs, no response:** Ollama may be loading the model cold (large models take 1030s).
Watch `curl -s http://localhost:11434/api/ps` — the model appears once loaded.
**"model not found" error in container logs:** The model name in settings.json doesn't match
what Ollama has. Run `ollama list` on the host and use the exact name shown.
**Responses claim to be Claude:** The model was trained on data that includes Claude conversations.
Add a line to `groups/<FOLDER>/CLAUDE.md` telling it what model it runs on.
**Agent responds but Ollama shows no activity:** `NO_PROXY` may not have taken effect for
`http_proxy` (lowercase). Add both `NO_PROXY` and `no_proxy` to the env block.
+3 -6
View File
@@ -54,7 +54,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
@@ -122,12 +122,9 @@ OLLAMA_HOST=http://your-ollama-host:11434
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
+25 -70
View File
@@ -60,10 +60,10 @@ import './opencode.js';
### 4. Add the agent-runner dependency
Pinned. Bump deliberately, not with `bun update`. Use `1.4.17` — must match the `opencode-ai` CLI version pinned in step 5. The 1.14.x SDK has a completely different API and is **incompatible** with the current provider code.
Pinned. Bump deliberately, not with `bun update`.
```bash
cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd -
cd container/agent-runner && bun add @opencode-ai/sdk@1.4.3 && cd -
```
### 5. Add `opencode-ai` to the container Dockerfile
@@ -73,11 +73,9 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`:
```dockerfile
ARG OPENCODE_VERSION=1.4.17
ARG OPENCODE_VERSION=latest
```
> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x.
**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list:
```dockerfile
@@ -96,25 +94,6 @@ pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typ
./container/build.sh # agent image
```
> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild:
> ```bash
> docker builder prune -f && ./container/build.sh
> ```
### 7. Propagate to existing per-group overlays
Each agent group has a live source overlay at `data/v2-sessions/<group-id>/agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually.
```bash
for overlay in data/v2-sessions/*/agent-runner-src/providers/; do
[ -d "$overlay" ] || continue
cp container/agent-runner/src/providers/opencode.ts "$overlay"
cp container/agent-runner/src/providers/mcp-to-opencode.ts "$overlay"
cp container/agent-runner/src/providers/index.ts "$overlay"
echo "Updated: $overlay"
done
```
## Configuration
### Host `.env` (typical)
@@ -123,65 +102,35 @@ Set model/provider strings in the form OpenCode expects (often `provider/model-i
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
- `OPENCODE_SMALL_MODEL` — optional second model for lighter tasks; defaults to `OPENCODE_MODEL` if unset.
- `ANTHROPIC_BASE_URL`**required for non-`anthropic` providers.** The opencode container provider passes this as the `baseURL` for the upstream provider config so requests route through OneCLI's credential proxy or directly to the provider's API. Set it to the provider's API base URL (e.g. `https://api.deepseek.com/v1`, `https://openrouter.ai/api/v1`).
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic` (if unset, the runner defaults to `anthropic`).
- `OPENCODE_MODEL` — full model id, e.g. `openrouter/anthropic/claude-sonnet-4`.
- `OPENCODE_SMALL_MODEL` — optional second model for "small" tasks.
Credentials: register provider API keys in OneCLI with the matching `--host-pattern` (e.g. `api.deepseek.com`, `openrouter.ai`). OneCLI injects them via `HTTPS_PROXY` in the container — the key never lives in `.env` or the container environment.
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
```bash
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
onecli agents secrets --id "$AGENT_ID"
```
#### Example: DeepSeek
```env
OPENCODE_PROVIDER=deepseek
OPENCODE_MODEL=deepseek/deepseek-chat
OPENCODE_SMALL_MODEL=deepseek/deepseek-chat
ANTHROPIC_BASE_URL=https://api.deepseek.com/v1
```
Register the key:
```bash
onecli secrets create --name "DeepSeek" --type generic \
--value YOUR_KEY --host-pattern "api.deepseek.com" \
--header-name "Authorization" --value-format "Bearer {value}"
```
Credentials: OneCLI / credential proxy patterns are unchanged. For non-`anthropic` OpenCode providers, the runner registers a placeholder API key and **`ANTHROPIC_BASE_URL`** (the credential proxy) as `baseURL` so the real key never lives in the container.
#### Example: OpenRouter
```env
# OpenCode — host passes these into the container when agent_provider is opencode
OPENCODE_PROVIDER=openrouter
OPENCODE_MODEL=openrouter/anthropic/claude-sonnet-4
OPENCODE_SMALL_MODEL=openrouter/anthropic/claude-haiku-4.5
ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
```
Register the key:
```bash
onecli secrets create --name "OpenRouter" --type generic \
--value YOUR_KEY --host-pattern "openrouter.ai" \
--header-name "Authorization" --value-format "Bearer {value}"
```
#### Example: Anthropic via existing proxy env
#### Example: Anthropic (no ANTHROPIC_BASE_URL needed)
When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container — the proxy + placeholder key pattern is unchanged and `ANTHROPIC_BASE_URL` is not required.
When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container (proxy + placeholder key pattern unchanged).
```env
OPENCODE_PROVIDER=anthropic
OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514
OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
```
#### Example: only a main model
```env
OPENCODE_PROVIDER=openrouter
OPENCODE_MODEL=openrouter/google/gemini-2.5-pro-preview
```
#### OpenCode Zen (`x-api-key`, not Bearer)
@@ -193,9 +142,13 @@ Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api
**Host `.env` (typical Zen shape):**
```env
# NanoClaw still resolves AGENT_PROVIDER from agent_groups / sessions; set agent_provider to opencode there.
# OpenCode SDK: Zen as the upstream provider + models under opencode/…
OPENCODE_PROVIDER=opencode
OPENCODE_MODEL=opencode/big-pickle
OPENCODE_SMALL_MODEL=opencode/big-pickle
# Point the credential proxy at Zen's Anthropic-compatible base URL (host + OneCLI must forward this host).
ANTHROPIC_BASE_URL=https://opencode.ai/zen/v1
```
@@ -209,16 +162,18 @@ onecli secrets create --name "OpenCode Zen" --type generic \
--header-name "x-api-key" --value-format "{value}"
```
For comparison, OpenRouter uses `Authorization` + `Bearer {value}`. Zen is different by design.
### Per group / per session
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
## Operational notes
- OpenCode keeps a local **`opencode serve`** process and SSE subscription; the provider tears down with **`stream.return`** and **SIGKILL** on the server process on **`abort()`** / shared runtime reset to avoid MCP/zombie hangs.
- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder.
- Session continuation is opaque (`ses_*` ids); stale sessions are cleared using **`isSessionInvalid`** on OpenCode-specific errors (timeouts, connection resets, not-found patterns) in addition to the poll-loop's existing recovery.
- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI).
## Verify
+7 -10
View File
@@ -229,22 +229,19 @@ echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Containe
### 7. Restart Service
Rebuild the main app and restart.
Run from your NanoClaw project root:
Rebuild the main app and restart:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Wait 3 seconds for service to start, then verify:
```bash
sleep 3
launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)" # macOS
# Linux: systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"
launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
```
### 8. Test Integration
@@ -278,7 +275,7 @@ Look for: `Parallel AI MCP servers configured`
- Check agent-runner logs for "Parallel AI MCP servers configured" message
**Task polling not working:**
- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"`
- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"`
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
- Ensure task prompt includes proper Parallel MCP tool names
@@ -290,4 +287,4 @@ To remove Parallel AI integration:
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && pnpm run build`
5. Restart: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)` (macOS) or `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (Linux)
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
+104
View File
@@ -0,0 +1,104 @@
---
name: add-pdf-reader
description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files.
---
# Add PDF Reader
Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace.
## Phase 1: Pre-flight
1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied
2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/pdf-reader
git merge whatsapp/skill/pdf-reader || {
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
}
```
This merges in:
- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation)
- `container/skills/pdf-reader/pdf-reader` (CLI script)
- `poppler-utils` in `container/Dockerfile`
- PDF attachment download in `src/channels/whatsapp.ts`
- PDF tests in `src/channels/whatsapp.test.ts`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate
```bash
pnpm run build
pnpm exec vitest run src/channels/whatsapp.test.ts
```
### Rebuild container
```bash
./container/build.sh
```
### Restart service
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 3: Verify
### Test PDF extraction
Send a PDF file in any registered WhatsApp chat. The agent should:
1. Download the PDF to `attachments/`
2. Respond acknowledging the PDF
3. Be able to extract text when asked
### Test URL fetching
Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch <url>`.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i pdf
```
Look for:
- `Downloaded PDF attachment` — successful download
- `Failed to download PDF attachment` — media download issue
## Troubleshooting
### Agent says pdf-reader command not found
Container needs rebuilding. Run `./container/build.sh` and restart the service.
### PDF text extraction is empty
The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead.
### WhatsApp PDF not detected
Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype.
+117
View File
@@ -0,0 +1,117 @@
---
name: add-reactions
description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions.
---
# Add Reactions
This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite.
## Phase 1: Pre-flight
### Check if already applied
Check if `src/status-tracker.ts` exists:
```bash
test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/reactions
git merge whatsapp/skill/reactions || {
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
}
```
This adds:
- `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes)
- `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry)
- `src/status-tracker.test.ts` (unit tests for StatusTracker)
- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool)
- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts`
### Run database migration
```bash
pnpm exec tsx scripts/migrate-reactions.ts
```
### Validate code changes
```bash
pnpm test
pnpm run build
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Verify
### Build and restart
```bash
pnpm run build
```
Linux:
```bash
systemctl --user restart nanoclaw
```
macOS:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
### Test receiving reactions
1. Send a message from your phone
2. React to it with an emoji on WhatsApp
3. Check the database:
```bash
sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;"
```
### Test sending reactions
Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message.
## Troubleshooting
### Reactions not appearing in database
- Check NanoClaw logs for `Failed to process reaction` errors
- Verify the chat is registered
- Confirm the service is running
### Migration fails
- Ensure `store/messages.db` exists and is accessible
- If "table reactions already exists", the migration already ran — skip it
### Agent can't send reactions
- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat
- Verify WhatsApp is connected: check logs for connection status
-13
View File
@@ -1,13 +0,0 @@
# Remove Signal
1. Comment out `import './signal.js'` in `src/channels/index.ts`
2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env`
3. Rebuild and restart
If you also want to unlink the Signal account from `signal-cli`:
```bash
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
```
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
-331
View File
@@ -1,331 +0,0 @@
---
name: add-signal
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
## Prerequisites
### Java
signal-cli requires Java 17+:
```bash
java -version
```
If missing:
- **macOS:** `brew install --cask temurin@17`
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
Java 1725 all work.
### signal-cli
- **macOS:** `brew install signal-cli`
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
```bash
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar -xz -C ~/.local
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
signal-cli --version
```
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
## Registration
Two paths. The new-number path is recommended and battle-tested.
### Path A: Register a new number (recommended)
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
**Step 1: Solve the CAPTCHA**
Signal requires a CAPTCHA on first registration:
1. Open `https://signalcaptchas.org/registration/generate.html` in a browser
2. Solve the captcha
3. Right-click the **"Open Signal"** button → **Copy Link**
4. The link starts with `signalcaptcha://` — the token is everything after that prefix
**Step 2: Request SMS verification**
```bash
signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE"
```
**Step 3: Voice call fallback (if your number can't receive SMS)**
Wait ~60 seconds after the SMS request, then:
```bash
signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN"
```
Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one.
> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…`
**Step 4: Verify**
```bash
signal-cli -a +1YOURNUMBER verify CODE
```
No output = success.
**Step 5: Set profile name (optional)**
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
# optionally: --avatar /path/to/avatar.jpg
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux
systemctl --user stop $(systemd_unit)
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start $(systemd_unit)
```
### Path B: Link as secondary device
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
```bash
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
```
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist
- `src/channels/index.ts` contains `import './signal.js';`
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter and tests
```bash
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './signal.js';
```
### 4. Build
```bash
pnpm run build
```
No npm packages to install — the adapter uses only Node.js builtins.
## Credentials
Add to `.env`:
```bash
SIGNAL_ACCOUNT=+1YOURNUMBER
```
### Optional settings
```bash
# TCP daemon host and port (default: 127.0.0.1:7583)
SIGNAL_TCP_HOST=127.0.0.1
SIGNAL_TCP_PORT=7583
# Path to the signal-cli binary (default: resolved on PATH)
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# Whether NanoClaw manages the daemon lifecycle (default: true).
# Set to false if you run signal-cli daemon externally.
SIGNAL_MANAGE_DAEMON=true
# signal-cli data directory (default: ~/.local/share/signal-cli)
SIGNAL_DATA_DIR=~/.local/share/signal-cli
```
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Wiring
### DMs
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
### Groups
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
"
```
### Grant user access
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
"
```
Find the UUID from `messaging_groups.platform_id` or the `users` table.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
- **supports-threads**: no
- **platform-id-format**:
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
- Quoted replies — `replyTo*` fields populated from Signal quotes
- Typing indicators — DMs only (Signal doesn't support group typing)
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Troubleshooting
### Daemon not reachable
```bash
grep "Signal" logs/nanoclaw.log | tail
```
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
### Bot not responding
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
3. Service running: `launchctl print gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
### Messages delivered but never arrive (null platformMsgId)
Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend.
### Lost connection mid-session
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish.
### Messages dropped with `not_member`
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
### Captcha required
Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`.
### `Invalid verification method: Before requesting voice verification…`
You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token.
### Config file in use / daemon lock
signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward.
### Group replies going to DM instead of group
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
### Java not found
Install Java 17+ — see the Prerequisites section above.
### QR code expired (Path B)
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
-5
View File
@@ -1,5 +0,0 @@
# Verify Signal
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
+3 -9
View File
@@ -44,7 +44,7 @@ import './slack.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/slack@4.27.0
pnpm install @chat-adapter/slack@4.26.0
```
### 5. Build
@@ -60,7 +60,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
@@ -76,13 +76,7 @@ pnpm run build
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
### Interactivity
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Configure environment
+1 -1
View File
@@ -44,7 +44,7 @@ import './teams.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/teams@4.27.0
pnpm install @chat-adapter/teams@4.26.0
```
### 5. Build
+384
View File
@@ -0,0 +1,384 @@
---
name: add-telegram-swarm
description: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot identity in the group. Requires Telegram channel to be set up first (use /add-telegram). Triggers on "agent swarm", "agent teams telegram", "telegram swarm", "bot pool".
---
# Add Agent Swarm to Telegram
This skill adds Agent Teams (Swarm) support to an existing Telegram channel. Each subagent in a team gets its own bot identity in the Telegram group, so users can visually distinguish which agent is speaking.
**Prerequisite**: Telegram must already be set up via the `/add-telegram` skill. If `src/telegram.ts` does not exist or `TELEGRAM_BOT_TOKEN` is not configured, tell the user to run `/add-telegram` first.
## How It Works
- The **main bot** receives messages and sends lead agent responses (already set up by `/add-telegram`)
- **Pool bots** are send-only — each gets a Grammy `Api` instance (no polling)
- When a subagent calls `send_message` with a `sender` parameter, the host assigns a pool bot and renames it to match the sender's role
- Messages appear in Telegram from different bot identities
```
Subagent calls send_message(text: "Found 3 results", sender: "Researcher")
→ MCP writes IPC file with sender field
→ Host IPC watcher picks it up
→ Assigns pool bot #2 to "Researcher" (round-robin, stable per-group)
→ Renames pool bot #2 to "Researcher" via setMyName
→ Sends message via pool bot #2's Api instance
→ Appears in Telegram from "Researcher" bot
```
## Prerequisites
### 1. Create Pool Bots
Tell the user:
> I need you to create 3-5 Telegram bots to use as the agent pool. These will be renamed dynamically to match agent roles.
>
> 1. Open Telegram and search for `@BotFather`
> 2. Send `/newbot` for each bot:
> - Give them any placeholder name (e.g., "Bot 1", "Bot 2")
> - Usernames like `myproject_swarm_1_bot`, `myproject_swarm_2_bot`, etc.
> 3. Copy all the tokens
> 4. Add all bots to your Telegram group(s) where you want agent teams
Wait for user to provide the tokens.
### 2. Disable Group Privacy for Pool Bots
Tell the user:
> **Important**: Each pool bot needs Group Privacy disabled so it can send messages in groups.
>
> For each pool bot in `@BotFather`:
> 1. Send `/mybots` and select the bot
> 2. Go to **Bot Settings** > **Group Privacy** > **Turn off**
>
> Then add all pool bots to your Telegram group(s).
## Implementation
### Step 1: Update Configuration
Read `src/config.ts` and add the bot pool config near the other Telegram exports:
```typescript
export const TELEGRAM_BOT_POOL = (process.env.TELEGRAM_BOT_POOL || '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
```
### Step 2: Add Bot Pool to Telegram Module
Read `src/telegram.ts` and add the following:
1. **Update imports** — add `Api` to the Grammy import:
```typescript
import { Api, Bot } from 'grammy';
```
2. **Add pool state** after the existing `let bot` declaration:
```typescript
// Bot pool for agent teams: send-only Api instances (no polling)
const poolApis: Api[] = [];
// Maps "{groupFolder}:{senderName}" → pool Api index for stable assignment
const senderBotMap = new Map<string, number>();
let nextPoolIndex = 0;
```
3. **Add pool functions** — place these before the `isTelegramConnected` function:
```typescript
/**
* Initialize send-only Api instances for the bot pool.
* Each pool bot can send messages but doesn't poll for updates.
*/
export async function initBotPool(tokens: string[]): Promise<void> {
for (const token of tokens) {
try {
const api = new Api(token);
const me = await api.getMe();
poolApis.push(api);
logger.info(
{ username: me.username, id: me.id, poolSize: poolApis.length },
'Pool bot initialized',
);
} catch (err) {
logger.error({ err }, 'Failed to initialize pool bot');
}
}
if (poolApis.length > 0) {
logger.info({ count: poolApis.length }, 'Telegram bot pool ready');
}
}
/**
* Send a message via a pool bot assigned to the given sender name.
* Assigns bots round-robin on first use; subsequent messages from the
* same sender in the same group always use the same bot.
* On first assignment, renames the bot to match the sender's role.
*/
export async function sendPoolMessage(
chatId: string,
text: string,
sender: string,
groupFolder: string,
): Promise<void> {
if (poolApis.length === 0) {
// No pool bots — fall back to main bot
await sendTelegramMessage(chatId, text);
return;
}
const key = `${groupFolder}:${sender}`;
let idx = senderBotMap.get(key);
if (idx === undefined) {
idx = nextPoolIndex % poolApis.length;
nextPoolIndex++;
senderBotMap.set(key, idx);
// Rename the bot to match the sender's role, then wait for Telegram to propagate
try {
await poolApis[idx].setMyName(sender);
await new Promise((r) => setTimeout(r, 2000));
logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot');
} catch (err) {
logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)');
}
}
const api = poolApis[idx];
try {
const numericId = chatId.replace(/^tg:/, '');
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent');
} catch (err) {
logger.error({ chatId, sender, err }, 'Failed to send pool message');
}
}
```
### Step 3: Add sender Parameter to MCP Tool
Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter:
Change the tool's schema from:
```typescript
{ text: z.string().describe('The message text to send') },
```
To:
```typescript
{
text: z.string().describe('The message text to send'),
sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'),
},
```
And update the handler to include `sender` in the IPC data:
```typescript
async (args) => {
const data: Record<string, string | undefined> = {
type: 'message',
chatJid,
text: args.text,
sender: args.sender || undefined,
groupFolder,
timestamp: new Date().toISOString(),
};
writeIpcFile(MESSAGES_DIR, data);
return { content: [{ type: 'text' as const, text: 'Message sent.' }] };
},
```
### Step 4: Update Host IPC Routing
Read `src/ipc.ts` and make these changes:
1. **Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config.
2. **Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool:
```typescript
if (data.sender && data.chatJid.startsWith('tg:')) {
await sendPoolMessage(
data.chatJid,
data.text,
data.sender,
sourceGroup,
);
} else {
await deps.sendMessage(data.chatJid, data.text);
}
```
Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs.
3. **Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add:
```typescript
if (TELEGRAM_BOT_POOL.length > 0) {
await initBotPool(TELEGRAM_BOT_POOL);
}
```
### Step 5: Update CLAUDE.md Files
#### 5a. Add global message formatting rules
Read `groups/global/CLAUDE.md` and add a Message Formatting section:
```markdown
## Message Formatting
NEVER use markdown. Only use WhatsApp/Telegram formatting:
- *single asterisks* for bold (NEVER **double asterisks**)
- _underscores_ for italic
- • bullet points
- ```triple backticks``` for code
No ## headings. No [links](url). No **double stars**.
```
#### 5b. Update existing group CLAUDE.md headings
In any group CLAUDE.md that has a "WhatsApp Formatting" section (e.g. `groups/main/CLAUDE.md`), rename the heading to reflect multi-channel support:
```
## WhatsApp Formatting (and other messaging apps)
```
#### 5c. Add Agent Teams instructions to Telegram groups
For each Telegram group that will use agent teams, create or update its `groups/{folder}/CLAUDE.md` with these instructions. Read the existing CLAUDE.md first (or `groups/global/CLAUDE.md` as a base) and add the Agent Teams section:
```markdown
## Agent Teams
When creating a team to tackle a complex task, follow these rules:
### CRITICAL: Follow the user's prompt exactly
Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names.
### Team member instructions
Each team member MUST be instructed to:
1. *Share progress in the group* via `mcp__nanoclaw__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group.
2. *Also communicate with teammates* via `SendMessage` as normal for coordination.
3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text.
4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable.
5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**.
### Example team creation prompt
When creating a teammate, include instructions like:
\```
You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__nanoclaw__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage.
\```
### Lead agent behavior
As the lead agent who created the team:
- You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots.
- Send your own messages only to comment, share thoughts, synthesize, or direct the team.
- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `<internal>` tags.
- Focus on high-level coordination and the final synthesis.
```
### Step 6: Update Environment
Add pool tokens to `.env`:
```bash
TELEGRAM_BOT_POOL=TOKEN1,TOKEN2,TOKEN3,...
```
**Important**: Sync to all required locations:
```bash
cp .env data/env/env
```
Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.nanoclaw.plist`) in the `EnvironmentVariables` dict if using launchd.
### Step 7: Rebuild and Restart
```bash
pnpm run build
./container/build.sh # Required — MCP tool changed
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart nanoclaw
```
Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed.
### Step 8: Test
Tell the user:
> Send a message in your Telegram group asking for a multi-agent task, e.g.:
> "Assemble a team of a researcher and a coder to build me a hello world app"
>
> You should see:
> - The lead agent (main bot) acknowledging and creating the team
> - Each subagent messaging from a different bot, renamed to their role
> - Short, scannable messages from each agent
>
> Check logs: `tail -f logs/nanoclaw.log | grep -i pool`
## Architecture Notes
- Pool bots use Grammy's `Api` class — lightweight, no polling, just send
- Bot names are set via `setMyName` — changes are global to the bot, not per-chat
- A 2-second delay after `setMyName` allows Telegram to propagate the name change before the first message
- Sender→bot mapping is stable within a group (keyed as `{groupFolder}:{senderName}`)
- Mapping resets on service restart — pool bots get reassigned fresh
- If pool runs out, bots are reused (round-robin wraps)
## Troubleshooting
### Pool bots not sending messages
1. Verify tokens: `curl -s "https://api.telegram.org/botTOKEN/getMe"`
2. Check pool initialized: `grep "Pool bot" logs/nanoclaw.log`
3. Ensure all pool bots are members of the Telegram group
4. Check Group Privacy is disabled for each pool bot
### Bot names not updating
Telegram caches bot names client-side. The 2-second delay after `setMyName` helps, but users may need to restart their Telegram client to see updated names immediately.
### Subagents not using send_message
Check the group's `CLAUDE.md` has the Agent Teams instructions. The lead agent reads this when creating teammates and must include the `send_message` + `sender` instructions in each teammate's prompt.
## Removal
To remove Agent Swarm support while keeping basic Telegram:
1. Remove `TELEGRAM_BOT_POOL` from `src/config.ts`
2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`)
3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`)
4. Remove `initBotPool` call from `main()`
5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts`
6. Remove Agent Teams section from group CLAUDE.md files
7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit
8. Rebuild: `pnpm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)
+1 -1
View File
@@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/telegram@4.27.0
pnpm install @chat-adapter/telegram@4.26.0
```
### 6. Build
+6 -6
View File
@@ -90,12 +90,12 @@ onecli secrets list | grep -i vercel
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
```bash
# set-secrets replaces the entire list — read and merge for each agent.
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
for agent in $(onecli agents list | jq -r '.data[].id'); do
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
# For each agent, add the Vercel secret to its assigned secrets list.
# First get current assignments, then set them with the new secret appended.
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//')
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
done
```
@@ -0,0 +1,148 @@
---
name: add-voice-transcription
description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them.
---
# Add Voice Transcription
This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: <transcript>]`.
## Phase 1: Pre-flight
### Check if already applied
Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place.
### Ask the user
Use `AskUserQuestion` to collect information:
AskUserQuestion: Do you have an OpenAI API key for Whisper transcription?
If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys.
## Phase 2: Apply Code Changes
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/voice-transcription
git merge whatsapp/skill/voice-transcription || {
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
}
```
This merges in:
- `src/transcription.ts` (voice transcription module using OpenAI Whisper)
- Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call)
- Transcription tests in `src/channels/whatsapp.test.ts`
- `openai` npm dependency in `package.json`
- `OPENAI_API_KEY` in `.env.example`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
pnpm install
pnpm run build
pnpm exec vitest run src/channels/whatsapp.test.ts
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Configure
### Get OpenAI API key (if needed)
If the user doesn't have an API key:
> I need you to create an OpenAI API key:
>
> 1. Go to https://platform.openai.com/api-keys
> 2. Click "Create new secret key"
> 3. Give it a name (e.g., "NanoClaw Transcription")
> 4. Copy the key (starts with `sk-`)
>
> Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note)
Wait for the user to provide the key.
### Add to environment
Add to `.env`:
```bash
OPENAI_API_KEY=<their-key>
```
Sync to container environment:
```bash
mkdir -p data/env && cp .env data/env/env
```
The container reads environment from `data/env/env`, not `.env` directly.
### Build and restart
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test with a voice note
Tell the user:
> Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: <transcript>]` and respond to its content.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i voice
```
Look for:
- `Transcribed voice message` — successful transcription with character count
- `OPENAI_API_KEY not set` — key missing from `.env`
- `OpenAI transcription failed` — API error (check key validity, billing)
- `Failed to download audio message` — media download issue
## Troubleshooting
### Voice notes show "[Voice Message - transcription unavailable]"
1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env`
2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200`
3. Check OpenAI billing — Whisper requires a funded account
### Voice notes show "[Voice Message - transcription failed]"
Check logs for the specific error. Common causes:
- Network timeout — transient, will work on next message
- Invalid API key — regenerate at https://platform.openai.com/api-keys
- Rate limiting — wait and retry
### Agent doesn't respond to voice notes
Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups.
-52
View File
@@ -1,52 +0,0 @@
# Remove WeChat Channel
Undo `/add-wechat`.
### 1. Remove credentials
Delete WeChat lines from `.env`:
```bash
sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak
cp .env data/env/env
```
### 2. Remove adapter and import
```bash
rm -f src/channels/wechat.ts
sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak
```
### 3. Uninstall the package
```bash
pnpm remove wechat-ilink-client
```
### 4. Remove saved auth + sync state
```bash
rm -rf data/wechat
```
### 5. Remove DB wiring
```sql
-- Remove any sessions first (foreign key)
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
```
### 6. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
-173
View File
@@ -1,173 +0,0 @@
---
name: add-wechat
description: Add WeChat (personal) channel integration via Tencent's official iLink Bot API. Uses long-polling and QR scan — no webhook, no ToS risk, no paid token.
---
# Add WeChat Channel
Adds WeChat support via **iLink Bot API** — the first-party Tencent API for personal WeChat bots (different from WeCom / Official Account).
**Why this is different from wechaty/PadLocal:**
- Official Tencent API — no ToS violation, no ban risk
- Free — no PadLocal token required
- No public webhook URL needed — uses long-poll
- Works with any personal WeChat account
## Prerequisites
- A **personal WeChat account** with the mobile app installed
- A phone to scan the QR code for login
- Node.js >= 20 (already required by NanoClaw)
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in from the `channels` branch.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/wechat.ts` exists
- `src/channels/index.ts` contains `import './wechat.js';`
- `wechat-ilink-client` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './wechat.js';
```
### 4. Install the library (pinned)
```bash
pnpm install wechat-ilink-client@0.1.0
```
### 5. Build
```bash
pnpm run build
```
## Credentials
Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone.
### 1. Enable the channel
Add to `.env`:
```bash
WECHAT_ENABLED=true
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
Restart NanoClaw.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
```bash
tail -f logs/nanoclaw.log | grep WeChat
# or
cat data/wechat/qr.txt
```
Open the URL in a browser (it renders a QR code), then:
1. Open WeChat on your phone
2. Use its built-in QR scanner (top-right "+" → Scan)
3. Approve the authorization on your phone
4. Auth credentials are saved to `data/wechat/auth.json` — do not commit this file
The bot is now connected as your WeChat account.
## Wire your first DM
A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond.
### 1. Trigger the first inbound message
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`.
### 2. Run the wire script
```bash
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
```
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
Non-interactive:
```bash
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \
--platform-id wechat:wxid_xxxxx \
--agent-group ag-xxxxx \
--non-interactive
```
Flags:
- `--platform-id <id>` — wire a specific messaging group (default: most recent unwired)
- `--agent-group <id>` — target agent group (default: prompt; or solo admin group in non-interactive)
- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs)
- `--session-mode shared|per-thread` — default `shared`
### 3. Test
Have the sender message the bot again — the agent should respond.
## Operational notes
- **Only one instance can use a given token at a time.** Don't run multiple NanoClaw instances pointing to the same `data/wechat/auth.json`.
- **Re-login on session expiry:** if you see `WeChat: session expired` in logs, delete `data/wechat/auth.json` and restart — you'll be asked to re-scan.
- **Sync cursor persistence:** `data/wechat/sync-buf.txt` holds the long-poll cursor. Deleting it replays recent history on next start; don't delete it in normal operation.
- **Account safety:** this uses the official Tencent API, so account bans for bot automation aren't a risk. That said, don't spam — normal rate limits still apply.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service to pick up the new channel and wiring.
## Channel Info
- **type**: `wechat`
- **terminology**: WeChat has "contacts" (DMs) and "group chats" (rooms). Each DM or group is a separate messaging group.
- **how-to-find-id**: Send a message to the bot from the target account; the adapter auto-creates a messaging group and logs `WeChat inbound platformId=wechat:<id>`. Use `wechat:<user_id>` for DMs, `wechat:<group_id>` for rooms.
- **admin-user-id**: The operator's WeChat user_id (for `init-first-agent.ts --admin-user-id`) is saved to `data/wechat/auth.json` as `operatorUserId` after the QR scan. Read it with `cat data/wechat/auth.json | jq -r .operatorUserId` and prefix with `wechat:` (i.e. `wechat:<operatorUserId>`).
- **supports-threads**: no (WeChat has no reply threads)
- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed.
- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot.
- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running as part of `bash nanoclaw.sh`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above.
@@ -1,172 +0,0 @@
#!/usr/bin/env pnpm exec tsx
/**
* Wire a WeChat DM (or group) to an agent group.
*
* After /add-wechat installs the adapter and the user scans the QR login,
* the first inbound message from another WeChat account auto-creates a
* `messaging_groups` row. This script finds that row, asks the operator
* which agent group to wire it to, and inserts the `messaging_group_agents`
* join row with sensible defaults the "post-login wiring" step /add-wechat
* otherwise requires manual SQL for.
*
* Usage:
* pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
*
* Flags:
* --platform-id <id> Wire a specific messaging group (default: most recent unwired)
* --agent-group <id> Target agent group (default: interactive pick; or solo admin group)
* --sender-policy <p> public | strict (default: public)
* --session-mode <m> shared | per-thread (default: shared)
* --non-interactive Fail instead of prompting
*/
import Database from 'better-sqlite3';
import path from 'node:path';
import readline from 'node:readline';
const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db');
type SenderPolicy = 'public' | 'strict' | 'request_approval';
interface Args {
platformId?: string;
agentGroupId?: string;
senderPolicy: SenderPolicy;
sessionMode: 'shared' | 'per-thread';
interactive: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = {
// Default matches the router's auto-create (`request_approval`) so the
// admin gets an approval card on the next unknown-sender DM rather than
// a silent allow. Pass `--sender-policy public` to open the channel to
// anyone, or `strict` to require explicit membership.
senderPolicy: 'request_approval',
sessionMode: 'shared',
interactive: true,
};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const val = argv[i + 1];
switch (flag) {
case '--platform-id': args.platformId = val; i++; break;
case '--agent-group': args.agentGroupId = val; i++; break;
case '--sender-policy':
if (val !== 'public' && val !== 'strict' && val !== 'request_approval') {
throw new Error(`bad --sender-policy: ${val} (use public | strict | request_approval)`);
}
args.senderPolicy = val; i++; break;
case '--session-mode':
if (val !== 'shared' && val !== 'per-thread') throw new Error(`bad --session-mode: ${val}`);
args.sessionMode = val; i++; break;
case '--non-interactive': args.interactive = false; break;
case '--help': case '-h':
console.log('See .claude/skills/add-wechat/scripts/wire-dm.ts header for usage.');
process.exit(0);
}
}
return args;
}
async function prompt(q: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); }));
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
// 1. Pick the messaging group
let platformId = args.platformId;
if (!platformId) {
const rows = db.prepare(`
SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at
FROM messaging_groups mg
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
WHERE mg.channel_type = 'wechat' AND mga.id IS NULL
ORDER BY mg.created_at DESC
`).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>;
if (rows.length === 0) {
console.error('No unwired WeChat messaging groups found.');
console.error('Send a message to the bot first (from another WeChat account), then re-run.');
process.exit(1);
}
if (rows.length === 1 || !args.interactive) {
platformId = rows[0].platform_id;
console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`);
} else {
console.log('Unwired WeChat messaging groups:');
rows.forEach((r, i) => {
console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`);
});
const pick = await prompt('Pick one [1]: ');
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice');
platformId = rows[idx].platform_id;
}
}
const mg = db.prepare(
'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?'
).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined;
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`);
// 2. Pick the agent group
let agentGroupId = args.agentGroupId;
if (!agentGroupId) {
const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC')
.all() as Array<{ id: string; name: string; is_admin: number }>;
if (agents.length === 0) throw new Error('no agent groups exist — create one first');
const adminAgents = agents.filter((a) => a.is_admin === 1);
if (adminAgents.length === 1 && !args.interactive) {
agentGroupId = adminAgents[0].id;
console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`);
} else if (args.interactive) {
console.log('Agent groups:');
agents.forEach((a, i) => {
console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`);
});
const pick = await prompt('Pick one [1]: ');
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
if (Number.isNaN(idx) || idx < 0 || idx >= agents.length) throw new Error('invalid choice');
agentGroupId = agents[idx].id;
} else {
throw new Error('multiple agent groups exist; pass --agent-group <id>');
}
}
const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as
{ id: string; name: string } | undefined;
if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`);
// 3. Update sender policy + wire
const tx = db.transaction(() => {
db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?')
.run(args.senderPolicy, mg.id);
db.prepare(`
INSERT INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now'))
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode);
});
tx();
console.log('');
console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`);
db.close();
}
main().catch((err) => {
console.error('FAILED:', err.message);
process.exit(1);
});
+1 -1
View File
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/whatsapp@4.27.0
pnpm install @chat-adapter/whatsapp@4.26.0
```
### 5. Build
+7 -10
View File
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
### 5. Install the adapter packages (pinned)
```bash
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
### 6. Build
@@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **type**: `whatsapp`
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **supports-threads**: no
- **typical-use**: Interactive chat — direct messages or small groups
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
@@ -244,23 +244,20 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
### "waiting for this message" on reactions
Signal sessions corrupted from rapid restarts. Clear sessions.
Run from your NanoClaw project root:
Signal sessions corrupted from rapid restarts. Clear sessions:
```bash
source setup/lib/install-slug.sh
systemctl --user stop $(systemd_unit)
systemctl --user stop nanoclaw
rm store/auth/session-*.json
systemctl --user start $(systemd_unit)
systemctl --user start nanoclaw
```
### Bot not responding
1. Auth exists: `test -f store/auth/creds.json`
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
4. Service running: `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"`
3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
4. Service running: `systemctl --user status nanoclaw`
### "conflict" disconnection
+137
View File
@@ -0,0 +1,137 @@
---
name: channel-formatting
description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill.
---
# Channel Formatting
This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's
responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or
Telegram.
| Channel | Transformation |
|---------|---------------|
| WhatsApp | `**bold**``*bold*`, `*italic*``_italic_`, headings → bold, links → `text (url)` |
| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) |
| Slack | same as WhatsApp, but links become `<url\|text>` |
| Discord | passthrough (Discord already renders Markdown) |
| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill |
Code blocks (fenced and inline) are always protected — their content is never transformed.
## Phase 1: Pre-flight
### Check if already applied
```bash
test -f src/text-styles.ts && echo "already applied" || echo "not yet applied"
```
If `already applied`, skip to Phase 3 (Verify).
## Phase 2: Apply Code Changes
### Ensure the upstream remote
```bash
git remote -v
```
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/channel-formatting
git merge upstream/skill/channel-formatting
```
If there are merge conflicts on `pnpm-lock.yaml`, resolve them by accepting the incoming
version and continuing:
```bash
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
```
For any other conflict, read the conflicted file and reconcile both sides manually.
This merge adds:
- `src/text-styles.ts``parseTextStyles(text, channel)` for marker substitution and
`parseSignalStyles(text)` for Signal native rich text
- `src/router.ts``formatOutbound` gains an optional `channel` parameter; when provided
it calls `parseTextStyles` after stripping `<internal>` tags
- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound`
- `src/formatting.test.ts` — test coverage for both functions across all channels
### Validate
```bash
pnpm install
pnpm run build
pnpm exec vitest run src/formatting.test.ts
```
All 73 tests should pass and the build should be clean before continuing.
## Phase 3: Verify
### Rebuild and restart
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
### Spot-check formatting
Send a message through any registered WhatsApp or Telegram chat that will trigger a
response from Claude. Ask something that will produce formatted output, such as:
> Summarise the three main advantages of TypeScript using bullet points and **bold** headings.
Confirm that the response arrives with native bold (`*text*`) rather than raw double
asterisks.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
## Signal Skill Integration
If you have the Signal skill installed, `src/channels/signal.ts` can import
`parseSignalStyles` from the newly present `src/text-styles.ts`:
```typescript
import { parseSignalStyles, SignalTextStyle } from '../text-styles.js';
```
`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where
`textStyle` is an array of `{ style, start, length }` objects suitable for the
`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`).
## Removal
```bash
# Remove the new file
rm src/text-styles.ts
# Revert router.ts to remove the channel param
git diff upstream/main src/router.ts # review changes
git checkout upstream/main -- src/router.ts
# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText)
# (edit manually or: git checkout upstream/main -- src/index.ts)
pnpm run build
```
@@ -58,7 +58,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
@@ -171,12 +171,9 @@ Expected: Both operations succeed.
### Full integration test
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
Send a message via WhatsApp and verify the agent responds.
+4 -8
View File
@@ -88,19 +88,15 @@ Implementation:
## After Changes
Always tell the user.
Run from your NanoClaw project root:
Always tell the user:
```bash
# Rebuild and restart
pnpm run build
source setup/lib/install-slug.sh
# macOS:
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart $(systemd_unit)
# systemctl --user restart nanoclaw
```
## Example Interaction
+2 -45
View File
@@ -57,50 +57,7 @@ Debug level shows:
## Common Issues
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
```
WARN No adapter for channel type channelType="telegram"
WARN No adapter for channel type channelType="signal"
```
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
**Root cause: two NanoClaw service instances running simultaneously.**
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
**Diagnosis:**
```bash
# Check for duplicate running instances
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
# Check which services are active
systemctl --user list-units 'nanoclaw*' --all
# Confirm channel adapters registered by the current process
grep "Channel adapter started" logs/nanoclaw.log | tail -10
```
**Fix:**
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
2. Stop and disable the stale duplicate service:
```bash
systemctl --user stop nanoclaw.service # or whichever is the old one
systemctl --user disable nanoclaw.service
```
3. If the remaining service unit is missing `EnvironmentFile`, add it:
```bash
# Edit the service unit — add this line under [Service]:
# EnvironmentFile=/home/[user]/nanoclaw/.env
systemctl --user daemon-reload
systemctl --user restart nanoclaw-v2-<id>.service
```
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
### 2. "Claude Code process exited with code 1"
### 1. "Claude Code process exited with code 1"
**Check the container log file** in `groups/{folder}/logs/container-*.log`
@@ -322,7 +279,7 @@ rm -rf data/sessions/
rm -rf data/sessions/{groupFolder}/.claude/
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
```
To verify session resumption is working, check the logs for the same session ID across messages:
+7 -6
View File
@@ -9,7 +9,7 @@ Stand up the first NanoClaw agent for a channel and verify end-to-end delivery b
## Prerequisites
- **Service running.** Check: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) or `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux). If stopped, tell the user to run `/setup` first.
- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
- **Target channel installed.** At least one `/add-<channel>` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`.
- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel.
@@ -54,7 +54,7 @@ Tell the user:
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues.
@@ -87,23 +87,24 @@ The script:
2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-<name>/`.
3. Reuses or creates the DM `messaging_groups` row.
4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row).
5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up.
5. Resolves the session (creates `inbound.db` / `outbound.db`).
6. Writes a `kind: 'chat'`, `sender: 'system'` welcome message into `inbound.db`.
Show the script's output to the user.
## 5. Verify
The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel.
Host sweep runs every ~60s. Within one sweep window the container wakes, the agent processes the system message, and the reply flows through `outbound.db` to the channel.
Do not tail the log or poll in a sleep loop. Ask the user in plain text:
> The welcome DM should arrive shortly. Let me know when you've received it (or if it doesn't arrive within two minutes).
> The welcome DM should arrive within ~60 seconds. Let me know when you've received it (or if it doesn't arrive within two minutes).
Wait for the user's reply. If they confirm receipt, the skill is done.
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
- `sqlite3 data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
+3 -41
View File
@@ -236,12 +236,9 @@ pnpm run build
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first.
Restart the service.
Run from your NanoClaw project root:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
- Linux (systemd): `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
Restart the service:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux (systemd): `systemctl --user restart nanoclaw`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
## Phase 5: Verify
@@ -262,41 +259,6 @@ Tell the user:
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
- To add rate limits or policies: `onecli rules create --help`
## Granting secrets to agents (safe merge)
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
```bash
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
onecli agents secrets --id "$AGENT_ID"
```
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
- `<new-secret-id>` — the `id` from `onecli secrets list`
- Multiple new secrets: append them comma-separated before the `printf` step
### git over HTTPS
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
```json
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "credential.helper",
"GIT_CONFIG_VALUE_0": "",
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
```
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
## Troubleshooting
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed.
+1 -16
View File
@@ -11,22 +11,7 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user
## Assess Current State
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`).
Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "<query>"
```
```sql
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
```
Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.
+3 -8
View File
@@ -41,12 +41,7 @@ npx tsx setup/index.ts --step mounts --force -- --empty
## After Changes
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
Restart the service so containers pick up the new config:
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
-232
View File
@@ -1,232 +0,0 @@
---
name: migrate-from-v1
description: Finish migrating a NanoClaw v1 install into v2. Run after `bash migrate-v2.sh` completes. Seeds the owner, cleans up CLAUDE.local.md files, reconciles container configs, and helps port custom v1 code. Triggers on "migrate from v1", "finish migration", "v1 migration".
---
# Finish v1 → v2 migration
`bash migrate-v2.sh` already ran the deterministic migration. It handled:
- .env keys merged
- v2 DB seeded (agent_groups, messaging_groups, wiring)
- Group folders copied (v1 CLAUDE.md → v2 CLAUDE.local.md)
- Session data copied with conversation continuity (incl. Claude Code memory + JSONL transcripts)
- Scheduled tasks ported
- Channel code installed and auth state copied (incl. WhatsApp Baileys keystore)
- WhatsApp LIDs resolved from `store/auth` and aliased into `messaging_groups`
- Container skills copied
- Container image built
Your job is the parts that need human judgment: triage any failed steps, seed the owner, clean up CLAUDE.local.md files, reconcile configs, and port any fork customizations.
Read `logs/setup-migration/handoff.json` first — it has `overall_status`, per-step results in `steps`, and a `followups` list.
## Preflight: was the script run?
Before anything else, check that `logs/setup-migration/handoff.json` exists. If it doesn't, the user is invoking this skill before `migrate-v2.sh` ran. Stop and tell them, verbatim:
> This skill finishes a migration that `migrate-v2.sh` started. Run that first, in your terminal — not from inside Claude:
>
> ```bash
> bash migrate-v2.sh
> ```
>
> It needs interactive prompts (channel selection, service switchover) and runs Node/pnpm bootstrap, Docker, OneCLI setup, and a container build that don't fit inside a Claude session. When it finishes, it'll hand control back to Claude automatically — at which point this skill picks up.
Do not attempt to run the script yourself, simulate its effects, or pick up the migration mid-stream. The deterministic side has dependencies on a real interactive shell.
Once `handoff.json` exists, proceed to Phase 0.
## Phase 0: Get v2 routing real messages
Before any deeper migration work, prove v2 actually answers messages on the user's real channels. v1 is paused, not touched — flipping back is a service restart.
### 0a — Fix blockers only
Walk `handoff.steps`. Fix only the failures that would stop the bot from routing one message; defer the rest to its later phase.
### 0b — Smoke test, then continue
Tell the user the switch is non-destructive (v1 is paused, not modified; reverting is one command). Help them stop v1's service unit and start v2's, tail the host log for a clean boot, and have them send a real test message. Use `AskUserQuestion` to confirm the bot responded.
If yes, continue to Phase 1. If no, diagnose from `logs/nanoclaw.log` and re-test — don't proceed to deeper work on a broken router.
### Deferred failures
Re-visit anything you skipped in 0a before declaring the migration done. Most surface naturally in later phases (`1c-groups` ↔ Phase 2, `1e-tasks` ↔ task verification).
## Phase 1: Owner and access
v2 auto-creates a `users` row for every sender it sees (via `extractAndUpsertUser` in `src/modules/permissions/index.ts`). By the time this skill runs, the owner's row likely already exists — it just needs the `owner` role granted.
**User ID format**: always `<channel_type>:<platform_handle>`. Each channel populates this differently:
- **Telegram**: `telegram:<numeric_user_id>` (e.g. `telegram:6037840640`)
- **Discord**: `discord:<snowflake_user_id>` (e.g. `discord:123456789012345678`)
- **WhatsApp**: `whatsapp:<phone>@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`)
- **Slack**: `slack:<user_id>` (e.g. `slack:U04ABCDEF`)
- **Others**: `<channel_type>:<platform_id>`
**Steps:**
1. Query `users` table: `SELECT id, kind, display_name FROM users`.
2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `<display_name>` (`<id>`) you?" — Yes / No, let me type it.
3. If multiple users exist, present them as options in `AskUserQuestion`.
4. If no users exist yet (service hasn't received a message), ask the user to send a test message first, then re-query.
5. Once confirmed, check `user_roles` — if the owner role already exists, skip. Otherwise insert:
```sql
INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('<user_id>', 'owner', NULL, NULL, datetime('now'))
```
Use the DB helpers in `src/db/user-roles.ts` — they keep indexes correct. Init the DB first:
```ts
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { DATA_DIR } from '../src/config.js';
import path from 'path';
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
```
### Access policy
After seeding the owner, discuss the access policy. v2's `messaging_groups.unknown_sender_policy` controls who can interact with the bot. `migrate-v2.sh` set it to `public` so the bot would respond during the switchover test, but the user may want to tighten it.
Present the options via `AskUserQuestion`:
1. **Public** (current) — anyone can message the bot. Good for personal DM bots.
2. **Known users only** — only users in `agent_group_members` can trigger the bot. Others are silently dropped.
3. **Approval required** — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members.
If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at `<handoff.v1_path>/store/messages.db`. It has a `messages` table with `sender` and `sender_name` columns. For each group:
```sql
-- v1: unique senders per chat (excluding bot messages)
SELECT DISTINCT sender, sender_name
FROM messages
WHERE chat_jid = '<v1_jid>' AND is_from_me = 0 AND sender IS NOT NULL
```
The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v2/shared.ts`) and combining: `<channel_type>:<sender>`.
For each sender:
1. Upsert into `users(id, kind, display_name)` if not already present.
2. Insert into `agent_group_members(user_id, agent_group_id)` for each agent group wired to that messaging group.
Show the user the list of senders being imported and let them deselect any they don't want.
Then update the messaging groups:
```sql
UPDATE messaging_groups SET unknown_sender_policy = '<chosen_policy>'
WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN (<migrated_channels>))
```
## Phase 2: Clean up CLAUDE.local.md
The migration copied v1's entire CLAUDE.md into CLAUDE.local.md for each group. This file now contains v1 boilerplate that v2 handles through its own composed fragments (`container/CLAUDE.md` + `.claude-fragments/module-*.md`). The user's customizations are buried inside.
For each group that has a `CLAUDE.local.md`:
1. Read the file.
2. Read the v1 template it was based on. Determine which template by checking the v1 install:
- If the group had `is_main=1` in v1's `registered_groups`, the template was `groups/main/CLAUDE.md`
- Otherwise, the template was `groups/global/CLAUDE.md`
- The v1 path is in `handoff.json``v1_path`
3. Diff the file against the template. Identify sections that are:
- **Stock boilerplate** (identical to template) — remove. v2's fragments cover this.
- **User customizations** (added sections, modified sections) — keep.
4. The following v1 sections are now handled by v2 fragments and should be removed even if slightly modified:
- "What You Can Do" → v2 runtime system prompt
- "Communication" / "Internal thoughts" / "Sub-agents" → `container/CLAUDE.md` + `module-core.md`
- "Your Workspace" / workspace path references → `container/CLAUDE.md`
- "Memory" (the stock version) → `container/CLAUDE.md`
- "Message Formatting" → `container/CLAUDE.md`
- "Admin Context" → v2 uses `user_roles`, not is_main
- "Authentication" → v2 uses OneCLI
- "Container Mounts" → v2 mounts are different
- "Managing Groups" / "Finding Available Groups" / "Registered Groups Config" → v2 entity model, no IPC
- "Global Memory" → v2 has `.claude-shared.md` symlink
- "Scheduling for Other Groups" → `module-scheduling.md`
- "Task Scripts" → `module-scheduling.md`
- "Sender Allowlist" → v2 uses `unknown_sender_policy` + `user_roles`
5. Fix path references in kept sections:
- `/workspace/group/``/workspace/agent/`
- `/workspace/project/` → these paths don't exist in v2; discuss with the user
- `/workspace/ipc/` → gone; remove references
- `/workspace/extra/` → v2 uses `container.json` `additionalMounts`; keep but note the path may change
6. Keep the `# Name` heading and first paragraph (identity) — this is the user's agent personality.
7. Show the user the proposed new CLAUDE.local.md before writing it. Use `AskUserQuestion`: "Here's what I'd keep — look right?" with options to approve, edit, or keep the original.
If a CLAUDE.local.md has no user customizations (pure template copy), write a minimal file with just the identity heading.
## Phase 3: Container config
`migrate-v2.sh` writes `container.json` directly from v1's `container_config` (the `additionalMounts` shape is identical). If the v1 config was unparseable, it falls back to a `.v1-container-config.json` sidecar.
For each group, check:
1. If `container.json` exists, read it and verify the `additionalMounts` host paths are still valid on this machine. Flag any that don't exist.
2. If `.v1-container-config.json` exists (parse failure fallback), read it, discuss with the user, and write a proper `container.json`. Then delete the sidecar.
3. Check for `env` or `packages` fields — `env` may overlap with OneCLI vault, `packages` (apt/npm) are portable.
## Phase 4: Fork customizations
Check whether the user's v1 install was a customized fork.
```bash
cd <v1_path>
git remote -v
git log --oneline <upstream>/main..HEAD 2>/dev/null
```
If no commits ahead of upstream: stock v1, skip this phase.
If there are commits:
1. Show the commit list to the user.
2. `AskUserQuestion`: "How do you want to handle your v1 customizations?"
- **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v2/shared.ts`.
- **Full walkthrough** — go commit by commit, decide together.
- **Reference only** — stash to `docs/v1-fork-reference/` for later.
3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate.
## Principles
- **v1 checkout is read-only.** Never modify files under `handoff.v1_path`.
- **Show before writing.** Show diffs/proposed content before modifying CLAUDE.local.md or container.json.
- **Mask credentials** when displaying (first 4 + `...` + last 4 characters).
- **`handoff.json` is the recovery point.** If context gets compacted, re-read it and `git status` to recover state.
## Setup steps you can run
The setup flow at `setup/index.ts` has individual steps you can invoke if something is missing or failed:
```bash
pnpm exec tsx setup/index.ts --step <name>
```
| Step | When to use |
|------|-------------|
| `onecli` | OneCLI not installed or not healthy |
| `auth` | No Anthropic credential in vault |
| `container` | Container image needs rebuild |
| `service` | Service not installed or not running |
| `mounts` | Mount allowlist missing |
| `verify` | End-to-end health check (run after everything else) |
| `environment` | System check (Node, dirs) |
## When done
1. Run the verify step to confirm everything works:
```bash
pnpm exec tsx setup/index.ts --step verify
```
2. Delete `logs/setup-migration/handoff.json` — offer to save as `docs/migration-<date>.md` first.
3. Restart the service if running so changes take effect:
```bash
# Linux
systemctl --user restart nanoclaw-v2-*
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-*
```
+1 -1
View File
@@ -34,7 +34,7 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/nanocoai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
@@ -45,7 +45,8 @@ rm /tmp/nanoclaw-diagnostics.json
**No**: `rm /tmp/nanoclaw-diagnostics.json`
**Never ask again**:
1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Remove the diagnostics sections from each corresponding SKILL.md
4. `rm /tmp/nanoclaw-diagnostics.json`
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
4. Remove the diagnostics sections from each corresponding SKILL.md
5. `rm /tmp/nanoclaw-diagnostics.json`
+343 -3
View File
@@ -1,10 +1,350 @@
---
name: setup
description: Run initial NanoClaw setup. Use when user wants to install NanoClaw, configure it, or go through first-time setup. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests.
description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate messaging channels, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests.
allowed-tools: Bash(git remote*), Bash(bash setup.sh), Bash(pnpm exec tsx setup/index.ts*)
---
# NanoClaw Setup
Tell the user to run `bash nanoclaw.sh` in their terminal. That script handles the full end-to-end setup — dependencies, container image, OneCLI vault, Anthropic credential, service, first agent, and optional channel wiring.
Welcome the user to NanoClaw. Introduce yourself — you'll be walking them through the entire setup process step by step, from installing dependencies to getting their first message through. Keep it warm and brief (2-3 sentences).
If they hit an error partway through, it will offer Claude-assisted recovery inline — no need to come back here.
Then explain that setup involves running many shell commands (installing packages, building containers, starting services), and recommend pre-approving the standard setup commands so they don't have to confirm each one individually.
Use `AskUserQuestion` with these options:
1. **Pre-approve (recommended)** — description: "Pre-approve standard setup commands so you don't have to confirm each one. You can review the list first if you'd like."
2. **No thanks** — description: "I'll approve each command individually as it comes up."
3. **Show me the list first** — description: "Show me exactly which commands will be pre-approved before I decide."
If they pick option 1: read `.claude/skills/setup/setup-permissions.json`, then read the project settings file at `.claude/settings.json` (create it if it doesn't exist with `{}`), and directly edit it to add/merge the permissions into the `permissions.allow` array. Do NOT use the `update-config` skill.
If they pick option 3: read and display `.claude/skills/setup/setup-permissions.json`, then re-ask with just options 1 and 2.
If they decline, continue — they'll approve commands individually.
---
**Internal guidance (do not show to user):**
- Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices).
- Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step <name>` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`.
- **Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair.
- **UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "which credential method?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply.
- **Timeouts:** Use 5m timeouts for install and build steps.
- **Waiting on user:** When the user needs to do something (change a setting, get a token, open a browser, etc.), stop and wait. Give clear instructions, then say "Let me know when done or if you need help." Do NOT continue to the next step. If they ask for help, give more detail, ask where they got stuck, and try to assist.
## 0. Git Upstream
Ensure `upstream` remote points to `qwibitai/nanoclaw`. If missing, add it silently.
```!
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
git remote -v
```
## 1. Bootstrap (Node.js + Dependencies)
Output from !`bash setup.sh` — parse the status block.
- If NODE_OK=false → Node.js is missing or too old. Use `AskUserQuestion: Would you like me to install Node.js 22?` If confirmed:
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
- After installing Node, re-run `bash setup.sh`
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
- Record PLATFORM and IS_WSL for later steps.
## 2. Check Environment
Output from !`pnpm exec tsx setup/index.ts --step environment` — parse the status block.
- If HAS_AUTH=true → WhatsApp is already configured, note for step 5
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
- Record DOCKER value for step 3
### OpenClaw Migration Detection
If OPENCLAW_PATH is not `none` from the environment check above, AskUserQuestion:
1. **Migrate now** — "Import identity, credentials, and settings from OpenClaw before continuing setup."
2. **Fresh start** — "Skip migration and set up NanoClaw from scratch."
3. **Migrate later** — "Continue setup now, run `/migrate-from-openclaw` anytime later."
If "Migrate now": invoke `/migrate-from-openclaw`, then return here and continue at step 2a (Timezone).
## 2a. Timezone
Output from !`pnpm exec tsx setup/index.ts --step timezone` — parse the status block.
- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `pnpm exec tsx setup/index.ts --step timezone -- --tz <their-answer>`.
- If STATUS=success and RESOLVED_TZ is `UTC` or `Etc/UTC` → confirm with the user: "Your system timezone is UTC — is that correct, or are you on a remote server?" If wrong, ask for their actual timezone and re-run with `--tz`.
- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference.
## 3. Container Runtime (Docker)
### 3a. Install Docker
- DOCKER=running → continue to step 4
- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`.
- DOCKER=not_found → Use `AskUserQuestion: Docker is required for running agents. Would you like me to install it?` If confirmed:
- macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop
- Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership.
### 3b. CJK fonts
Agent containers skip CJK fonts by default (~200MB saved). Without them, Chromium-rendered screenshots and PDFs show tofu for Chinese/Japanese/Korean.
- **User writing to you in Chinese, Japanese, or Korean** → enable without asking. Mention it briefly.
- **Resolved timezone from step 2a is a CJK region** (`Asia/Tokyo`, `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Taipei`, `Asia/Seoul`) or other signal short of active CJK use → ask: "Enable CJK fonts? Adds ~200MB, lets the agent render CJK in screenshots and PDFs."
- **Otherwise** → skip.
To enable, write `INSTALL_CJK_FONTS=true` to `.env`:
```bash
grep -q '^INSTALL_CJK_FONTS=' .env && sed -i.bak 's/^INSTALL_CJK_FONTS=.*/INSTALL_CJK_FONTS=true/' .env && rm -f .env.bak || echo 'INSTALL_CJK_FONTS=true' >> .env
```
The next step's build picks it up automatically.
### 3c. Build and test
Run `pnpm exec tsx setup/index.ts --step container -- --runtime docker` and parse the status block.
**If BUILD_OK=false:** Read `logs/setup.log` tail for the build error.
- Cache issue (stale layers): `docker builder prune -f`. Retry.
- Dockerfile syntax or missing files: diagnose from the log and fix, then retry.
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
## 4. Credential System
### 4a. OneCLI
Install OneCLI and its CLI tool:
```bash
curl -fsSL onecli.sh/install | sh
curl -fsSL onecli.sh/cli/install | sh
```
Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it:
```bash
export PATH="$HOME/.local/bin:$PATH"
# Persist for future sessions (append to shell profile if not already present)
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
```
Then re-verify with `onecli version`.
Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above:
```bash
onecli config set api-host ${ONECLI_URL}
```
Ensure `.env` has the OneCLI URL (create the file if it doesn't exist):
```bash
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env
```
Check if a secret already exists:
```bash
onecli secrets list
```
If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5.
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
#### Subscription path
Tell the user:
> Run `claude setup-token` in another terminal. It will output a token — copy it but don't paste it here.
Then stop and wait for the user to confirm they have the token. Do NOT proceed until they respond.
Once they confirm, they register it with OneCLI. AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
#### API key path
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
Then AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
#### After either path
Ask them to let you know when done.
**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
## 5. Set Up Channels
Show the full list of available channels in plain text (do NOT use AskUserQuestion — it limits to 4 options). Ask which one they want to start with. They can add more later with `/customize`.
Channels where the agent gets its own identity (name and avatar) are marked as recommended.
1. Discord *(recommended — agent gets own identity)*
2. Slack *(recommended — agent gets own identity)*
3. Telegram *(recommended — agent gets own identity)*
4. Microsoft Teams *(recommended — agent gets own identity)*
5. Webex *(recommended — agent gets own identity)*
6. WhatsApp
7. WhatsApp Cloud API
8. iMessage
9. GitHub
10. Linear
11. Google Chat
12. Resend (email)
13. Matrix
**Delegate to the selected channel's skill.** Each channel skill handles its own package installation, authentication, registration, and configuration.
Invoke the matching skill:
- **Discord:** Invoke `/add-discord`
- **Slack:** Invoke `/add-slack`
- **Telegram:** Invoke `/add-telegram`
- **GitHub:** Invoke `/add-github`
- **Linear:** Invoke `/add-linear`
- **Microsoft Teams:** Invoke `/add-teams`
- **Google Chat:** Invoke `/add-gchat`
- **WhatsApp Cloud API:** Invoke `/add-whatsapp-cloud`
- **WhatsApp Baileys:** Invoke `/add-whatsapp`
- **Resend:** Invoke `/add-resend`
- **Matrix:** Invoke `/add-matrix`
- **Webex:** Invoke `/add-webex`
- **iMessage:** Invoke `/add-imessage`
The skill will:
1. Install the Chat SDK adapter package
2. Uncomment the channel import in `src/channels/index.ts`
3. Collect credentials/tokens and write to `.env`
4. Build and verify
**After the channel skill completes**, install dependencies and rebuild — channel merges may introduce new packages:
```bash
pnpm install && pnpm run build
```
If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 5a.
## 6. Mount Allowlist
Set empty mount allowlist (agents only access their own workspace). Users can configure mounts later with `/manage-mounts`.
```bash
pnpm exec tsx setup/index.ts --step mounts -- --empty
```
## 7. Start Service
If service already running: unload first.
- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
- Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root)
Run `pnpm exec tsx setup/index.ts --step service` and parse the status block.
**If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper.
**If DOCKER_GROUP_STALE=true:** The user was added to the docker group after their session started — the systemd service can't reach the Docker socket. Ask user to run these two commands:
1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock`
2. Persistent fix (re-applies after every Docker restart):
```bash
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF'
[Service]
ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock
EOF
sudo systemctl daemon-reload
```
Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step.
**If SERVICE_LOADED=false:**
- Read `logs/setup.log` for the error.
- macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`.
- Linux: check `systemctl --user status nanoclaw`.
- Re-run the service step after fixing.
## 7a. Wire Channels to Agent Groups
The service is now running, so polling-based adapters (Telegram) can observe inbound messages — required for pairing.
Invoke `/manage-channels` to wire the installed channels to agent groups. This step:
1. Creates the agent group(s) and assigns a name to the assistant
2. Resolves each channel's platform-specific ID (Telegram via pairing code; other channels via the platform's own ID lookup)
3. Decides the isolation level — whether channels share an agent, session, or are fully separate
The `/manage-channels` skill reads each channel's `## Channel Info` section from its SKILL.md for platform-specific guidance (terminology, how to find IDs, recommended isolation).
**This step is required.** Without it, channels are installed but not wired — messages will be silently dropped because the router has no agent group to route to.
## 7b. Dashboard & Web Applications
AskUserQuestion: Do you want to create a dashboard and build web applications?
1. **Yes (recommended)** — description: "Get a NanoClaw dashboard to monitor your agents and build custom websites however you want. Deploys to Vercel."
2. **Not now** — description: "You can add this later with `/add-vercel`."
If yes: invoke `/add-vercel`.
## 8. Verify
Run `pnpm exec tsx setup/index.ts --step verify` and parse the status block.
**If STATUS=failed, fix each:**
- SERVICE=stopped → `pnpm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
- SERVICE=not_found → re-run step 7
- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list`)
- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
- REGISTERED_GROUPS=0 → re-invoke `/manage-channels` from step 7a
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
## Troubleshooting
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (check `curl ${ONECLI_URL}/api/health`), missing channel credentials (re-invoke channel skill).
**Container agent fails ("Claude Code process exited with code 1"):** Ensure Docker is running — `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.
**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `pnpm exec tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`.
**Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change.
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
## 9. Diagnostics
1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`.
2. Follow every step in that file before completing setup.
## 10. Fork Setup
Only run this after the user has confirmed 2-way messaging works.
Check `git remote -v`. If `origin` points to `qwibitai/nanoclaw` (not a fork), ask in plain text:
> We recommend forking NanoClaw so you can push your customizations and pull updates easily. Would you like to set up a fork now?
If yes: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask for their GitHub username. Run:
```bash
git remote rename origin upstream
git remote add origin https://github.com/<their-username>/nanoclaw.git
git push --force origin main
```
If no: skip — upstream is already configured from step 0.
+49
View File
@@ -0,0 +1,49 @@
# Diagnostics
Gather system info:
```bash
node -p "require('./package.json').version"
uname -s
uname -m
node -p "process.versions.node.split('.')[0]"
```
Check if the user migrated from OpenClaw during this setup session (i.e. `/migrate-from-openclaw` was invoked). If you're unsure (e.g. after context compaction), check for `migration-state.md` in the project root — it exists during and sometimes after migration.
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
```json
{
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
"event": "setup_complete",
"distinct_id": "<uuid>",
"properties": {
"success": true,
"nanoclaw_version": "1.2.21",
"os_platform": "darwin",
"arch": "arm64",
"node_major_version": 22,
"channels_selected": ["telegram", "whatsapp"],
"migrated_from_openclaw": false,
"error_count": 0,
"failed_step": null
}
}
```
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
**Yes**:
```bash
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
rm /tmp/nanoclaw-diagnostics.json
```
**No**: `rm /tmp/nanoclaw-diagnostics.json`
**Never ask again**:
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
4. `rm /tmp/nanoclaw-diagnostics.json`
@@ -0,0 +1,34 @@
[
"Bash(bash setup.sh*)",
"Bash(git remote *)",
"Bash(npx tsx setup/index.ts*)",
"Bash(npx tsx scripts/init-first-agent.ts*)",
"Bash(npm install @chat-adapter/*)",
"Bash(npm install chat-adapter-imessage*)",
"Bash(npm install @bitbasti/chat-adapter-webex*)",
"Bash(npm install @resend/chat-sdk-adapter*)",
"Bash(npm install @whiskeysockets/baileys*)",
"Bash(npm install @beeper/chat-adapter-matrix*)",
"Bash(npm install @nanoco/nanoclaw-dashboard*)",
"Bash(npm ci*)",
"Bash(npm run build*)",
"Bash(curl -fsSL onecli.sh*)",
"Bash(onecli *)",
"Bash(grep -q *)",
"Bash(echo *>> .env)",
"Bash(ls *)",
"Bash(cat ~/.config/nanoclaw/*)",
"Bash(tail *logs/*)",
"Bash(launchctl *nanoclaw*)",
"Bash(sqlite3 data/*)",
"Bash(docker info*)",
"Bash(docker logs *)",
"Bash(mkdir -p *)",
"Bash(cp .env *)",
"Bash(rsync -a .claude/skills/*)",
"Bash(head *)",
"Bash(xattr *)",
"Bash(find ~/.npm *)",
"Bash(which onecli*)",
"Bash(./container/build.sh*)"
]
+15 -54
View File
@@ -11,15 +11,14 @@ Run `/update-nanoclaw` in Claude Code.
## How it works
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/nanocoai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.
**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories:
- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill
- **Host source** (`src/`): may conflict if you modified the same files
- **Container** (`container/`): triggers container rebuild
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Source** (`src/`): may conflict if you modified the same files
- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed
**Update paths** (you pick one):
- `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass.
@@ -31,7 +30,7 @@ Run `/update-nanoclaw` in Claude Code.
**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact.
**Validation**: runs `pnpm run build` and `pnpm test`. If container files changed, also runs the container typecheck and `./container/build.sh`.
**Validation**: runs `pnpm run build` and `pnpm test`.
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.
@@ -69,7 +68,7 @@ If output is non-empty:
Confirm remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Add it: `git remote add upstream <user-provided-url>`
- Then: `git fetch upstream --prune`
@@ -109,10 +108,9 @@ Show file-level impact from upstream:
Bucket the upstream changed files:
- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
- **Host source** (`src/`): may conflict if user modified the same files
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Other**: docs, tests, setup scripts, misc
- **Source** (`src/`): may conflict if user modified the same files
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`, `container/`, `launchd/`): review needed
- **Other**: docs, tests, misc
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
@@ -175,31 +173,11 @@ If it gets messy (more than 3 rounds of conflicts):
- `git rebase --abort`
- Recommend merge instead.
# Step 4.5: Install dependencies (if lockfiles changed)
Check if the merge changed any lockfiles or package manifests:
- `git diff <backup-tag-from-step-1>..HEAD --name-only | grep -E '^(pnpm-lock\.yaml|package\.json)$'`
- If matched: `pnpm install`
- `git diff <backup-tag-from-step-1>..HEAD --name-only | grep -E '^container/agent-runner/(bun\.lock|package\.json)$'`
- If matched AND `command -v bun` succeeds: `cd container/agent-runner && bun install`
- If bun is not installed on the host, skip — container deps will be installed during `./container/build.sh`
Skip this step if neither lockfile changed.
# Step 5: Validation
Check which areas changed to determine what to validate:
- `CHANGED_FILES=$(git diff --name-only <backup-tag-from-step-1>..HEAD)`
**Host build** (always):
Run:
- `pnpm run build`
- `pnpm test` (do not fail the flow if tests are not configured)
**Container typecheck** (only if `container/agent-runner/src/` files are in CHANGED_FILES AND bun types are available):
- Check: `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`
- If this fails because bun types are missing (`Cannot find type definition file for 'bun'`), skip with a note — type errors will surface at container runtime instead
**Container image rebuild** (only if any `container/` files are in CHANGED_FILES):
- `./container/build.sh`
If build fails:
- Show the error.
- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
@@ -231,10 +209,8 @@ If one or more `[BREAKING]` lines are found:
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
# Step 7: Check for skill and channel/provider updates
## 7a: Skill branches
Check if skills are distributed as branches in this repo:
# Step 7: Check for skill updates
After the summary, check if skills are distributed as branches in this repo:
- `git branch -r --list 'upstream/skill/*'`
If any `upstream/skill/*` branches exist:
@@ -242,21 +218,7 @@ If any `upstream/skill/*` branches exist:
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
- If user selects yes, invoke `/update-skills` using the Skill tool.
## 7b: Channel and provider updates
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
- List the installed channels/providers.
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
- "Skip — I'll update them later"
- Set `multiSelect: true`
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
If no channels/providers are installed, skip silently.
Proceed to Step 8.
- After the skill completes (or if user selected no), proceed to Step 8.
# Step 8: Summary + rollback instructions
Show:
@@ -270,10 +232,9 @@ Show:
Tell the user:
- To rollback: `git reset --hard <backup-tag-from-step-1>`
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
- Restart the service to apply changes. The unit/label names are per-install — derive them with `setup/lib/install-slug.sh`. Run from your NanoClaw project root:
- **macOS (Darwin)**: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)`
- **Linux**: `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (or, if you want to confirm the unit name first: `systemctl --user list-units --type=service | grep "$(. setup/lib/install-slug.sh && systemd_unit)"`)
- **Manual** (no service found): restart `pnpm run dev`
- Restart the service to apply changes:
- If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
- If running manually: restart `pnpm run dev`
## Diagnostics
@@ -43,6 +43,7 @@ rm /tmp/nanoclaw-diagnostics.json
**No**: `rm /tmp/nanoclaw-diagnostics.json`
**Never ask again**:
1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
2. Remove the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
3. `rm /tmp/nanoclaw-diagnostics.json`
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
4. `rm /tmp/nanoclaw-diagnostics.json`
+1 -1
View File
@@ -42,7 +42,7 @@ Check remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- `git remote add upstream <url>`
Fetch:
+152
View File
@@ -0,0 +1,152 @@
---
name: use-local-whisper
description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first.
---
# Use Local Whisper
Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost.
**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them.
**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`.
## Prerequisites
- `voice-transcription` skill must be applied first (WhatsApp channel)
- macOS with Apple Silicon (M1+) recommended
- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary)
- `ffmpeg` installed: `brew install ffmpeg`
- A GGML model file downloaded to `data/models/`
## Phase 1: Pre-flight
### Check if already applied
Check if `src/transcription.ts` already uses `whisper-cli`:
```bash
grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
### Check dependencies are installed
```bash
whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING"
ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING"
```
If missing, install via Homebrew:
```bash
brew install whisper-cpp ffmpeg
```
### Check for model file
```bash
ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL"
```
If no model exists, download the base model (148MB, good balance of speed and accuracy):
```bash
mkdir -p data/models
curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin"
```
For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB).
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/local-whisper
git merge whatsapp/skill/local-whisper || {
git checkout --theirs pnpm-lock.yaml
git add pnpm-lock.yaml
git merge --continue
}
```
This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.
### Validate
```bash
pnpm run build
```
## Phase 3: Verify
### Ensure launchd PATH includes Homebrew
The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH.
Check the current PATH:
```bash
grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist
```
If `/opt/homebrew/bin` is missing, add it to the `<string>` value inside the `PATH` key in the plist. Then reload:
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
```
### Build and restart
```bash
pnpm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
### Test
Send a voice note in any registered group. The agent should receive it as `[Voice: <transcript>]`.
### Check logs
```bash
tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper"
```
Look for:
- `Transcribed voice message` — successful transcription
- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH
## Configuration
Environment variables (optional, set in `.env`):
| Variable | Default | Description |
|----------|---------|-------------|
| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary |
| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file |
## Troubleshooting
**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually:
```bash
ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y
whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt
```
**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3.
**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing.
**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`.
@@ -40,7 +40,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
@@ -128,12 +128,9 @@ echo 'ANTHROPIC_API_KEY=<key>' >> .env
pnpm run build
```
Then restart the service.
Run from your NanoClaw project root:
- macOS: `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
- Linux: `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
Then restart the service:
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
2. Check logs for successful proxy startup:
+10 -23
View File
@@ -38,8 +38,6 @@ Before using this skill, ensure:
## Quick Start
Run from your NanoClaw project root:
```bash
# 1. Setup authentication (interactive)
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
@@ -51,10 +49,9 @@ pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/s
# 3. Rebuild host and restart service
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
# Verify: launchctl list | grep "$(launchd_label)" (macOS) or systemctl --user status $(systemd_unit) (Linux)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
```
## Configuration
@@ -273,23 +270,16 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
### 4. Restart Service
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
**Verify success.**
Run from your NanoClaw project root:
**Verify success:**
```bash
source setup/lib/install-slug.sh
launchctl list | grep "$(launchd_label)" # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status $(systemd_unit)
launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status nanoclaw
```
## Usage via WhatsApp
@@ -353,13 +343,10 @@ echo '{"content":"Test"}' | pnpm exec tsx .claude/skills/x-integration/scripts/p
### Authentication Expired
Run from your NanoClaw project root:
```bash
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
### Browser Lock Files
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
bump-version:
if: github.repository == 'nanocoai/nanoclaw'
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+1 -6
View File
@@ -1,12 +1,7 @@
name: Label PR
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
# because `pull_request_target` executes in the context of the base branch.
# Keep it metadata-only — do NOT add actions/checkout or any step that
# executes PR-supplied content (install scripts, build commands, etc.).
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
on:
pull_request_target:
pull_request:
types: [opened, edited]
jobs:
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
update-tokens:
if: github.repository == 'nanocoai/nanoclaw'
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+7 -8
View File
@@ -11,19 +11,18 @@ store/
data/
logs/
# Groups - per-installation state, not tracked
# Groups - only track base structure and specific CLAUDE.md files
groups/*
# Composer-managed CLAUDE.md artifacts (regenerated every spawn) and
# per-group memory (CLAUDE.local.md) must never be committed.
**/CLAUDE.local.md
**/.claude-shared.md
**/.claude-fragments/
!groups/main/
!groups/global/
groups/main/*
groups/global/*
!groups/main/CLAUDE.md
!groups/global/CLAUDE.md
# Secrets
*.keys.json
.env
.env*
# Temp files
.tmp-*
-4
View File
@@ -1,5 +1 @@
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
pnpm run format:fix
if [ -n "$staged" ]; then
echo "$staged" | xargs git add
fi
+1 -46
View File
@@ -2,52 +2,7 @@
All notable changes to NanoClaw will be documented in this file.
## [2.0.63] - 2026-05-15
Rollup release covering v2.0.55 through v2.0.63 — everything merged since the v2.0.54 tag. Starting with this release, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`; see [RELEASING.md](RELEASING.md).
- [BREAKING] **Service names are now per-install.** On v2 installs the launchd label and systemd unit are slugged to your project root: `com.nanoclaw.<sha1(projectRoot)[:8]>` and `nanoclaw-<slug>.service`. The old `com.nanoclaw` / `nanoclaw.service` names no longer match a real service — update any copy-pasted restart or status commands. Find your install's names with `source setup/lib/install-slug.sh && launchd_label` (macOS) or `systemd_unit` (Linux). The `ncl` transport-error help text and 26 skill files now use the canonical helper-driven pattern; see [setup/lib/install-slug.sh](setup/lib/install-slug.sh).
- **Compaction destination reminder placement fixed.** The reminder injected after SDK auto-compaction now appears at the end of the compaction summary so it isn't stripped during truncation. Replaces the placement shipped in v2.0.54.
- **Stronger message-wrapping enforcement.** The poll loop nudges the agent when its output lacks `<message>` wrapping, and `CLAUDE.md` core instructions now require wrapping even for single-destination agents. The welcome flow no longer double-greets.
- **OneCLI credentials after MCP install.** MCP servers added through `add_mcp_server` now inherit OneCLI gateway routing — fixes the case where the agent kept asking for API keys after installing a new server.
- **CLI scope hardening.** `scopeField` now fails closed when scope is missing, and `sessions get` is guarded against cross-group oracle access from group-scoped agents.
- **gmail/gcal skills aligned with v2.** `/add-gmail-tool` and `/add-gcal-tool` now reflect the v2 container-config model — DB-backed mounts, no dead `TOOL_ALLOWLIST` edits, no `container.json` writes that get clobbered on next spawn. Manual sqlite3/JSON1 invocations corrected.
- **Repo-rename cleanup.** Remaining `qwibitai/nanoclaw` references swept to `nanocoai/nanoclaw` across code and docs; CI workflow guards updated so they no longer no-op after the rename.
- Slack scope checklist now includes `files:read` and `files:write` for skills that read or post attachments.
- The internal-tag description in destination instructions no longer mentions scratchpads (which confused agents into routing them incorrectly).
- Container startup is now graceful when the `on_wake` column is missing on older sessions DBs.
## [2.0.54] - 2026-05-10
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
## [2.0.48] - 2026-05-09
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
## [2.0.45] - 2026-05-08
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md``CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
## [2.0.0] - 2026-04-22
Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work.
- [BREAKING] **New entity model.** Users, roles (owner/admin), messaging groups, and agent groups are now tracked as separate entities, wired via `messaging_group_agents`. Privilege is user-level instead of channel-level, so the old "main channel = admin" concept is retired. See [docs/architecture.md](docs/architecture.md) and [docs/isolation-model.md](docs/isolation-model.md).
- [BREAKING] **Two-DB session split.** Each session now has `inbound.db` (host writes, container reads) and `outbound.db` (container writes, host reads) with exactly one writer each. Replaces the single shared session DB and eliminates cross-mount SQLite contention. See [docs/db-session.md](docs/db-session.md).
- [BREAKING] **Install flow replaced.** `bash nanoclaw.sh` is the new default: a scripted installer that hands off to Claude Code for error recovery and guided decisions. The `/setup` Claude-guided skill still works as an alternative.
- [BREAKING] **Channels moved to the `channels` branch.** Trunk no longer ships Discord, Slack, Telegram, WhatsApp, iMessage, Teams, Linear, GitHub, WeChat, Matrix, Google Chat, Webex, Resend, or WhatsApp Cloud. Install them per fork via `/add-<channel>` skills, which copy from the `channels` branch. `/update-nanoclaw` will re-install the channels your fork had.
- [BREAKING] **Alternative providers moved to the `providers` branch.** OpenCode, Codex, and Ollama install via `/add-opencode`, `/add-codex`, `/add-ollama-provider`. Claude remains the default provider baked into trunk.
- [BREAKING] **Three-level channel isolation.** Wire channels to their own agent (separate agent groups), share an agent with independent conversations (`session_mode: 'shared'`), or merge channels into one shared session (`session_mode: 'agent-shared'`). Chosen per channel via `/manage-channels`.
- [BREAKING] **Apple Container removed from default setup.** Still available as an opt-in via `/convert-to-apple-container`.
- **Shared-source agent-runner.** Per-group `agent-runner-src/` overlays are gone; all groups mount the same agent-runner read-only. Per-group customization flows through composed `CLAUDE.md` (shared base + per-group fragments).
- **Agent-runner runtime moved from Node to Bun.** Container image is self-contained; no host-side impact. Host remains on Node + pnpm.
- **OneCLI Agent Vault is the sole credential path.** Containers never receive raw API keys; credentials are injected at request time.
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
## [1.2.36] - 2026-03-26
+8 -136
View File
@@ -1,18 +1,3 @@
# ⚠️ STOP — READ THIS FIRST IF YOU ARE CLAUDE ⚠️
**If you are reading this because you just ran `git pull`, `git merge`, `git fetch && git merge`, or any equivalent to bring in upstream changes — and you see merge conflicts or a large diff involving this file — HALT IMMEDIATELY.**
This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It cannot be merged into an existing v1 install. Attempting to resolve the conflicts by hand, run builds, or "fix" anything will corrupt the user's install and burn tokens for no result.
**Do this instead:**
1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed).
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Exit Claude Code (or open a separate terminal) and run `bash migrate-v2.sh` from the shell."*
3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and cannot be run from within Claude Code.
If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below.
---
# NanoClaw
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
@@ -53,8 +38,6 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts <db> "<sql>"`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically.
## Key Files
| File | Purpose |
@@ -66,49 +49,17 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup |
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
| `src/access.ts` | `pickApprover`, `pickApprovalDelivery`, admin resolution for `NANOCLAW_ADMIN_USER_IDS` |
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
| `container/skills/` | Container skills mounted into every agent session |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
## Admin CLI (`ncl`)
`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`).
```
ncl <resource> <verb> [<id>] [--flags]
ncl <resource> help
ncl help
```
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| sessions | list, get | Active sessions (read-only) |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
## Channels and Providers (skill-installed)
@@ -123,70 +74,13 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
One tier of agent self-modification today:
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`.
1. **`install_packages` / `add_mcp_server` / `request_rebuild`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Admin approval, rebuild, container restart. `container/agent-runner/src/mcp-tools/self-mod.ts`.
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
## Container Config
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
## Container Restart
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
## Secrets / Credentials / OneCLI
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
### Gotcha: auto-created agents start in `selective` secret mode
When the host first spawns a session for a new agent group, `container-runner.ts:385` calls `onecli.ensureAgent({ name, identifier })`. The OneCLI `POST /api/agents` endpoint creates the agent in **`selective`** secret mode — meaning **no secrets are assigned to it by default**, even if the secrets exist in the vault and have host patterns that would otherwise match.
Symptom: container starts, the proxy + CA cert are wired correctly, but the agent gets `401 Unauthorized` (or similar) from APIs whose credentials *are* in the vault. The credential just isn't in this agent's allow-list.
The SDK does not expose `setSecretMode` — the only fix is the CLI (or the web UI at `http://127.0.0.1:10254`).
```bash
# Find the agent (identifier is the agent group id)
onecli agents list
# Flip to "all" so every vault secret with a matching host pattern gets injected
onecli agents set-secret-mode --id <agent-id> --mode all
# Or, stay selective and assign specific secrets
onecli secrets list # find secret ids
onecli agents set-secrets --id <agent-id> --secret-ids <id1>,<id2>
# Inspect what an agent currently has
onecli agents secrets --id <agent-id> # secrets assigned to this agent
onecli secrets list # all vault secrets (with host patterns)
```
If you've just enabled `mode all`, no container restart is needed — the gateway looks up secrets per request, so the next API call from the running container will see the new credentials.
### Requiring approval for credential use
Approval-gating credentialed actions is a **two-sided** flow:
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only.
If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired.
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
## Skills
@@ -195,7 +89,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`).
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
| Skill | When to Use |
|-------|-------------|
@@ -211,17 +105,6 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
## PR Hygiene
Before creating a PR, run these checks:
```bash
git diff upstream/main --stat HEAD
git log upstream/main..HEAD --oneline
```
Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
## Development
Run commands directly — don't tell the user to run them.
@@ -251,17 +134,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
systemctl --user start|stop|restart nanoclaw
```
## Troubleshooting
Check these first when something goes wrong:
| What | Where |
|------|-------|
| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain |
| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) |
| Session DBs | `data/v2-sessions/<agent-group>/<session>/``inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) |
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here).
## Supply Chain Security (pnpm)
@@ -284,10 +157,9 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
| [docs/agent-runner-details.md](docs/agent-runner-details.md) | Agent-runner internals + MCP tool interface |
| [docs/isolation-model.md](docs/isolation-model.md) | Three-level channel isolation model |
| [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow |
| [docs/checklist.md](docs/checklist.md) | Rolling status checklist across all subsystems |
| [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture |
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
## Container Build Cache
+4 -5
View File
@@ -4,8 +4,8 @@
1. **Check for existing work.** Search open PRs and issues before starting:
```bash
gh pr list --repo nanocoai/nanoclaw --search "<your feature>"
gh issue list --repo nanocoai/nanoclaw --search "<your feature>"
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
```
If a related PR or issue exists, build on it rather than duplicating effort.
@@ -43,7 +43,7 @@ Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setu
3. Claude walks through interactive setup (env vars, bot creation, etc.)
**Contributing a feature skill:**
1. Fork `nanocoai/nanoclaw` and branch from `main`
1. Fork `qwibitai/nanoclaw` and branch from `main`
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
4. Open a PR. We'll create the `skill/<name>` branch from your work
@@ -123,8 +123,7 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
| Checkbox | Label |
|----------|-------|
-1
View File
@@ -16,7 +16,6 @@ Thanks to everyone who has contributed to NanoClaw!
- [flobo3](https://github.com/flobo3) — Flo
- [edwinwzhe](https://github.com/edwinwzhe) — Edwin He
- [scottgl9](https://github.com/scottgl9) — Scott Glover
- [ingyukoh](https://github.com/ingyukoh) — Ingyu Koh
- [cschmidt](https://github.com/cschmidt) — Carl Schmidt
- [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler
- [moktamd](https://github.com/moktamd)
+61 -68
View File
@@ -12,7 +12,7 @@
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
</p>
---
@@ -26,61 +26,55 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
## Quick Start
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
gh repo fork qwibitai/nanoclaw --clone
cd nanoclaw
claude
```
`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.
<details>
<summary><strong>Migrating from NanoClaw v1?</strong></summary>
<summary>Without GitHub CLI</summary>
Run from a fresh v2 checkout next to your v1 install:
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
`migrate-v2.sh` finds your v1 install (sibling directory, or `NANOCLAW_V1_PATH=/path/to/nanoclaw`), migrates state into the v2 checkout, then `exec`s into Claude Code to finish the parts that need judgment (owner seeding, CLAUDE.local.md cleanup, fork-customisation replay).
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different and [docs/migration-dev.md](docs/migration-dev.md) for development notes.
1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button)
2. `git clone https://github.com/<your-username>/nanoclaw.git`
3. `cd nanoclaw`
4. `claude`
</details>
Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration.
> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code).
## Philosophy
**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.
**Secure by isolation.** Agents run in Linux containers and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
**Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker) and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
**Built for the individual user.** NanoClaw isn't a monolithic framework; it's software that fits each user's exact needs. Instead of becoming bloatware, NanoClaw is designed to be bespoke. You make your own fork and have Claude Code modify it to match your needs.
**Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that it's safe to make changes.
**AI-native, hybrid by design.** The install and onboarding flow is an optimized scripted path, fast and deterministic. When a step needs judgment, whether a failed install, a guided decision, or a customization, control hands off to Claude Code seamlessly. Beyond setup there's no monitoring dashboard or debugging UI either: describe the problem in chat and Claude Code handles it.
**AI-native.**
- No installation wizard; Claude Code guides setup.
- No monitoring dashboard; ask Claude what's happening.
- No debugging tools; describe the problem and Claude fixes it.
**Skills over features.** Trunk ships the registry and infrastructure, not specific channel adapters or alternative agent providers. Channels (Discord, Slack, Telegram, WhatsApp, …) live on a long-lived `channels` branch; alternative providers (OpenCode, Ollama) live on `providers`. You run `/add-telegram`, `/add-opencode`, etc. and the skill copies exactly the module(s) you need into your fork. No feature you didn't ask for.
**Skills over features.** Instead of adding features (e.g. support for Telegram) to the codebase, contributors submit [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need.
**Best harness, best model.** NanoClaw natively uses Claude Code via Anthropic's official Claude Agent SDK, so you get the latest Claude models and Claude Code's full toolset, including the ability to modify and expand your own NanoClaw fork. Other providers are drop-in options: `/add-codex` for OpenAI's Codex (ChatGPT subscription or API key), `/add-opencode` for OpenRouter, Google, DeepSeek and more via OpenCode, and `/add-ollama-provider` for local open-weight models. Provider is configurable per agent group.
**Best harness, best model.** NanoClaw runs on the Claude Agent SDK, which means you're running Claude Code directly. Claude Code is highly capable and its coding and problem-solving capabilities allow it to modify and expand NanoClaw and tailor it to each user.
## What It Supports
- **Multi-channel messaging** WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time.
- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted to it.
- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
- **Web access** - Search and fetch content from the Web
- **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS)
- **Credential security** - Agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
## Usage
@@ -92,7 +86,7 @@ Talk to your assistant with the trigger word (default: `@Andy`):
@Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing
```
From a channel you own or administer, you can manage groups and tasks:
From the main channel (your self-chat), you can manage groups and tasks:
```
@Andy list all scheduled tasks across groups
@Andy pause the Monday briefing task
@@ -116,58 +110,54 @@ The codebase is small enough that Claude can safely modify it.
**Don't add features. Add skills.**
If you want to add a new channel or agent provider, don't add it to trunk. New channel adapters land on the `channels` branch; new agent providers land on `providers`. Users install them in their own fork with `/add-<name>` skills, which copy the relevant module(s) into the standard paths, wire the registration, and pin dependencies.
If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork.
This keeps trunk as pure registry and infra, and every fork stays lean — users get the channels and providers they asked for and nothing else.
Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case.
### RFS (Request for Skills)
Skills we'd like to see:
**Communication Channels**
- `/add-signal` Add Signal as a channel
- `/add-signal` - Add Signal as a channel
## Requirements
- macOS or Linux (Windows via WSL2)
- Node.js 20+ and pnpm 10+ (the installer will install both if missing)
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) or Docker Engine (Linux)
- [Claude Code](https://claude.ai/download) for `/customize`, `/debug`, error recovery during setup, and all `/add-<channel>` skills
- macOS, Linux, or Windows (via WSL2)
- Node.js 20+
- [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
## Architecture
```
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response
```
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.
For the full architecture writeup see [docs/architecture.md](docs/architecture.md); for the three-level isolation model see [docs/isolation-model.md](docs/isolation-model.md).
For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture).
Key files:
- `src/index.ts` — entry point: DB init, channel adapters, delivery polls, sweep
- `src/router.ts` — inbound routing: messaging group → agent group → session → `inbound.db`
- `src/delivery.ts` — polls `outbound.db`, delivers via adapter, handles system actions
- `src/host-sweep.ts` — 60s sweep: stale detection, due-message wake, recurrence
- `src/session-manager.ts` — resolves sessions, opens `inbound.db` / `outbound.db`
- `src/container-runner.ts` — spawns per-agent-group containers, OneCLI credential injection
- `src/db/` — central DB (users, roles, agent groups, messaging groups, wiring, migrations)
- `src/channels/` — channel adapter infra (adapters installed via `/add-<channel>` skills)
- `src/providers/` — host-side provider config (`claude` baked in; others via skills)
- `container/agent-runner/` — Bun agent-runner: poll loop, MCP tools, provider abstraction
- `groups/<folder>/` — per-agent-group filesystem (`CLAUDE.md`, skills, container config)
- `src/index.ts` - Orchestrator: state, message loop, agent invocation
- `src/channels/registry.ts` - Channel registry (self-registration at startup)
- `src/ipc.ts` - IPC watcher and task processing
- `src/router.ts` - Message formatting and outbound routing
- `src/group-queue.ts` - Per-group queue with global concurrency limit
- `src/container-runner.ts` - Spawns streaming agent containers
- `src/task-scheduler.ts` - Runs scheduled tasks
- `src/db.ts` - SQLite operations (messages, groups, sessions, state)
- `groups/*/CLAUDE.md` - Per-group memory
## FAQ
**Why Docker?**
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
**Can I run this on Linux or Windows?**
Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `bash nanoclaw.sh`.
Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`.
**Is this secure?**
@@ -179,28 +169,33 @@ We don't want configuration sprawl. Every user should customize NanoClaw so that
**Can I use third-party or open-source models?**
Yes. The supported path is `/add-opencode` (OpenRouter, OpenAI, Google, DeepSeek, and more via OpenCode config) or `/add-ollama-provider` (local open-weight models via Ollama). Both are configurable per agent group, so different agents can run on different backends in the same install.
For one-off experiments, any Claude API-compatible endpoint also works via `.env`:
Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
This allows you to use:
- Local models via [Ollama](https://ollama.ai) with an API proxy
- Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc.
- Custom model deployments with Anthropic-compatible APIs
Note: The model must support the Anthropic API format for best compatibility.
**How do I debug issues?**
Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw.
**Why isn't the setup working for me?**
If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill.
If you have issues, during setup, Claude will try to dynamically fix them. If that doesn't work, run `claude`, then run `/debug`. If Claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md.
**What changes will be accepted into the codebase?**
Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all.
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills on the `channels` or `providers` branch.
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills.
This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want.
@@ -215,5 +210,3 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist
## License
MIT
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
+101 -63
View File
@@ -8,56 +8,90 @@
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">ドキュメント</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
</p>
---
<h2 align="center">🐳 Dockerサンドボックスで動作</h2>
<p align="center">各エージェントはマイクロVM内の独立したコンテナで実行されます。<br>ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。</p>
**macOS (Apple Silicon)**
```bash
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash
```
**Windows (WSL)**
```bash
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
```
> 現在、macOSApple Silicon)とWindowsx86)に対応しています。Linux対応は近日公開予定。
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">発表記事を読む →</a>&nbsp; · &nbsp;<a href="docs/docker-sandboxes.md">手動セットアップガイド →</a></p>
---
## NanoClawを作った理由
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、自分が理解しきれない複雑なソフトウェアに生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOSレベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
## クイックスタート
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
gh repo fork qwibitai/nanoclaw --clone
cd nanoclaw
claude
```
`nanoclaw.sh`は、まっさらなマシンから、メッセージを送れる名前付きエージェントが動く状態までを一気通貫で案内します。NodeやpnpmやDockerが無ければインストールし、AnthropicクレデンシャルをOneCLIに登録し、エージェントコンテナをビルドし、最初のチャネル(Telegram、Discord、WhatsApp、またはローカルCLI)とペアリングします。途中でステップが失敗すれば自動的にClaude Codeが呼び出され、原因を診断して中断箇所から再開します。
<details>
<summary>GitHub CLIなしの場合</summary>
1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック)
2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git`
3. `cd nanoclaw`
4. `claude`
</details>
その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。
> **注意:** `/`で始まるコマンド(`/setup``/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。
## 設計思想
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を把握したいなら、Claude Codeに説明を求めれば十分です。
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。
**分離によるセキュリティ。** エージェントはLinuxコンテナで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセス安全です。
**分離によるセキュリティ。** エージェントはLinuxコンテナmacOSではApple Container、またはDockerで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセス安全です。
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドであるよう設計されています。自分のフォークを作、Claude Codeにニーズに合わせて変更させます。
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。
**カスタマイズ=コード変更。** 設定の肥大化はありません。動作を変えたいならコードを変える。コードベースは変更しても安全な規模です。
**カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたいコードを変更するだけ。コードベースは変更しても安全な規模です。
**AIネイティブ、設計としてハイブリッド。** インストールとオンボーディングは最適化されたスクリプトのパスで、速く決定的です。判断が必要なところ(インストール失敗、対話的な決定、カスタマイズ)では、制御はシームレスにClaude Codeへ渡されます。セットアップ以降も、監視ダッシュボードやデバッグUIは用意しません。問題をチャットで説明すれば、Claude Codeが処理します
**AIネイティブ。**
- インストールウィザードなし — Claude Codeがセットアップを案内。
- モニタリングダッシュボードなし — Claudeに状況を聞くだけ。
- デバッグツールなし — 問題を説明すればClaudeが修正。
**機能ではなくスキル。** トランクにはレジストリとインフラのみを同梱し、個別のチャネルアダプターや代替プロバイダーは含めません。チャネル(Discord、Slack、Telegram、WhatsAppなど)は長期運用される`channels`ブランチに、代替プロバイダー(OpenCode、Ollama)は`providers`ブランチに置かれます。`/add-telegram``/add-opencode`などを実行すると、スキルが必要なモジュールだけを正確にフォークへコピーします。要求していない機能は一切入りません
**機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入りま
**最高のハーネス、最高のモデル。** NanoClawはAnthropic公式のClaude Agent SDK経由でネイティブにClaude Codeを使用します。最新のClaudeモデルとClaude Codeの全ツールセット(自分のNanoClawフォークを変更・拡張する能力を含む)が手に入ります。他プロバイダーはドロップイン・オプションです。OpenAIのCodex(ChatGPTサブスクリプションまたはAPIキー)向けには`/add-codex`、OpenCode経由のOpenRouter、Google、DeepSeekなどには`/add-opencode`、ローカルのオープンウェイトモデルには`/add-ollama-provider`。プロバイダーはエージェントグループごとに設定可能です。
**最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。
## サポート機能
- **マルチチャネルメッセージング** WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat、Resend経由のメール。`/add-<channel>`スキルでオンデマンドにインストール。1つでも複数でも同時に実行可能。
- **柔軟な分離モデル** — チャネルごとに専用エージェントを割り当てて完全プライバシーを確保することも、複数チャネルで1つのエージェントを共有して会話は分離しつつメモリを統一することも、複数チャネルを1つの共有セッションにまとめて会話を横断させることもできます。`/manage-channels`でチャネル単位に選択。[docs/isolation-model.md](docs/isolation-model.md)参照
- **エージェントごとのワークスペース** — 各エージェントグループは独自の`CLAUDE.md`、独自のメモリ、独自のコンテナ、そしてあなたが許可したマウントのみを持ちます。明示的に配線しない限り、境界を越えるものはありません
- **スケジュールタスク** Claudeを実行し、結果を返信できる定期ジョブ。
- **Webアクセス** Webからの検索とコンテンツ取得。
- **コンテナ分離** エージェントはDockerサンドボックス化されます(macOS/Linux/WSL2)。[Docker Sandboxes](docs/docker-sandboxes.md)によるマイクロVM分離や、macOSネイティブのオプトインとしてApple Containerも選択可能です
- **クレデンシャルのセキュリティ** — エージェントは生のAPIキーを保持しません。アウトバウンドリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、リクエスト時に認証情報を注入して、エージェントごとのポリシーとレート制限を適用します
- **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp``/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。
- **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行
- **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離
- **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。
- **Webアクセス** - Webからのコンテンツ検索・取得。
- **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple ContainermacOS)、またはDockermacOS/Linux)でサンドボックス化
- **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動
- **オプション連携** - Gmail`/add-gmail`)などをスキルで追加。
## 使い方
@@ -69,7 +103,7 @@ bash nanoclaw.sh
@Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って
```
所有または管理しているチャネルから、グループやタスクを管理できます:
メインチャネル(セルフチャット)から、グループやタスクを管理できます:
```
@Andy 全グループのスケジュールタスクを一覧表示して
@Andy 月曜のブリーフィングタスクを一時停止して
@@ -78,14 +112,14 @@ bash nanoclaw.sh
## カスタマイズ
NanoClawは設定ファイルを使いません。変更したいときは、Claude Codeにやりたいことを伝えるだけです:
NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです:
- 「トリガーワードを@Bobに変更して
- 「今後はレスポンスをもっと短く直接的にして」
- 「おはようと言ったらカスタム挨拶を追加して」
- 「会話の要約を毎週保存して」
または`/customize`を実行すればガイド付き変更できます。
または`/customize`を実行してガイド付き変更を行えます。
コードベースは十分に小さいため、Claudeが安全に変更できます。
@@ -93,101 +127,105 @@ NanoClawは設定ファイルを使いません。変更したいときは、Cla
**機能を追加するのではなく、スキルを追加してください。**
新しいチャネルやエージェントプロバイダーを追加したい場合、トランクには追加しないでください。新しいチャネルアダプターは`channels`ブランチに、新しいエージェントプロバイダーは`providers`ブランチに追加します。ユーザーはそれぞれのフォークで`/add-<name>`スキルを実行し、スキルが必要なモジュールを標準パスへコピーし、登録を配線し、依存関係をピン留めします。
Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。
こうすることでトランクは純粋なレジストリ/インフラのまま保たれ、どのフォークもスリムなままです。ユーザーは求めたチャネルとプロバイダーだけを受け取り、それ以外は入りません
ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入りま
### RFS(スキル募集)
私たちが見たいスキル:
私たちが求めているスキル:
**コミュニケーションチャネル**
- `/add-signal` Signalをチャネルとして追加
- `/add-signal` - Signalをチャネルとして追加
**セッション管理**
- `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。
## 必要条件
- macOSまたはLinuxWindowsはWSL2経由)
- Node.js 20以上とpnpm 10以上(インストーラーが未インストールなら両方をインストールします)
- [Docker Desktop](https://docker.com/products/docker-desktop)macOS/Windows)または Docker EngineLinux
- [Claude Code](https://claude.ai/download)`/customize``/debug`、セットアップ時のエラー復旧、全ての`/add-<channel>`スキルで使用
- macOSまたはLinux
- Node.js 20以上
- [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container)macOS)または[Docker](https://docker.com/products/docker-desktop)macOS/Linux
## アーキテクチャ
```
メッセージングアプリ → ホストプロセス(ルーター) → inbound.db → コンテナ(Bun、Claude Agent SDK → outbound.db → ホストプロセス(配信) → メッセージングアプリ
チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK --> レスポンス
```
単一のNodeホストがセッションごとのエージェントコンテナをオーケストレーションします。メッセージが到着すると、ホストはエンティティモデル(ユーザー → メッセージンググループ → エージェントグループ → セッション)に沿ってルーティングし、セッションの`inbound.db`に書き込み、コンテナを起こします。コンテナ内部のagent-runnerは`inbound.db`をポーリングしてClaudeを実行し、レスポンスを`outbound.db`に書き込みます。ホストは`outbound.db`をポーリングし、チャネルアダプターを通じて配信します
単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC
セッションごとに2つのSQLiteファイル、各ファイルにライターは1つだけ — クロスマウントの競合なし、IPCなし、stdinパイプなし。チャネルと代替プロバイダーは起動時に自己登録します。トランクはレジストリとChat SDKブリッジを同梱し、アダプター本体はフォークごとにスキルでインストールされます
詳しいアーキテクチャ説明は[docs/architecture.md](docs/architecture.md)を、3階層の分離モデルについては[docs/isolation-model.md](docs/isolation-model.md)を参照してください。
詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください
主要ファイル:
- `src/index.ts` — エントリーポイント:DB初期化、チャネルアダプター、配信ポーリング、sweep
- `src/router.ts` — インバウンドルーティング:メッセージンググループ → エージェントグループ → セッション → `inbound.db`
- `src/delivery.ts``outbound.db`をポーリングし、アダプター経由で配信、システムアクションを処理
- `src/host-sweep.ts` — 60秒ごとのsweep:ストール検出、期限到来メッセージの起動、繰り返し
- `src/session-manager.ts` — セッションの解決、`inbound.db``outbound.db`のオープン
- `src/container-runner.ts` — エージェントグループごとのコンテナ起動、OneCLIによるクレデンシャル注入
- `src/db/` — セントラルDB(ユーザー、ロール、エージェントグループ、メッセージンググループ、配線、マイグレーション)
- `src/channels/` — チャネルアダプターのインフラ(アダプターは`/add-<channel>`スキルでインストール
- `src/providers/` — ホスト側プロバイダー設定(`claude`はバンドル、その他はスキル経由)
- `container/agent-runner/` — Bun製agent-runner:ポーリングループ、MCPツール、プロバイダー抽象化
- `groups/<folder>/` — エージェントグループごとのファイルシステム(`CLAUDE.md`、スキル、コンテナ設定)
- `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し
- `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録)
- `src/ipc.ts` - IPCウォッチャーとタスク処理
- `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング
- `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー
- `src/container-runner.ts` - ストリーミングエージェントコンテナ起動
- `src/task-scheduler.ts` - スケジュールタスクの実行
- `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態
- `groups/*/CLAUDE.md` - グループごとのメモリ
## FAQ
**なぜDockerなのか?**
Dockerはクロスプラットフォーム対応(macOS、Linux、WSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使えます。さらに強い分離が必要なら、[Docker Sandboxes](docs/docker-sandboxes.md)が各コンテナをマイクロVM内で動作させます。
Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。
**LinuxやWindowsで実行できますか?**
**Linuxで実行できますか?**
はい。DockerがデフォルトのランタイムでmacOSLinux、Windows(WSL2経由)で動作します。`bash nanoclaw.sh`を実行するだけです。
はい。DockerがデフォルトのランタイムでmacOSLinuxの両方で動作します。`/setup`を実行するだけです。
**セキュリティは大丈夫ですか?**
エージェントはアプリケーションレベルのパーミッションチェックではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセス可能です。クレデンシャルはコンテナに渡されず、アウトバウンドAPIリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、プロキシレベルで認証を注入し、レートリミットやアクセスポリシーをサポートします。実行するものレビューすべきですが、コードベースは実際にレビュー可能な規模です。完全なセキュリティモデルについては[セキュリティドキュメント](https://docs.nanoclaw.dev/concepts/security)を参照してください。
エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。
**なぜ設定ファイルがないのか?**
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが自分の望み通りに動くようにすべきです。設定ファイルが欲しければClaudeに追加するよう伝えれば実現できます。
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。
**サードパーティやオープンソースモデルを使えますか?**
はい。推奨される方法は`/add-opencode`OpenCode設定経由でOpenRouter、OpenAI、Google、DeepSeekなど)か`/add-ollama-provider`(Ollama経由でローカルのオープンウェイトモデル)です。どちらもエージェントグループごとに設定可能なので、同じインストール内で異なるエージェントが異なるバックエンドで動作できます。
一時的な実験用には、Claude API互換のエンドポイントも`.env`で利用できます:
はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
以下が使用可能です:
- [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル
- [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル
- Anthropic互換APIのカスタムモデルデプロイメント
注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。
**問題のデバッグ方法は?**
Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。
**セットアップがうまくいかない場合は?**
ステップが失敗した場合、`nanoclaw.sh`は診断と再開のためにClaude Codeへ制御を渡します。それでも解決しなければ`claude`を実行して`/debug`呼び出してください。他のユーザーにも影響しそうな問題をClaudeが特定した場合は、該当のセットアップステップまたはスキルにPRを送ってください。
問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は`claude`を実行してから`/debug`実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。
**どのような変更がコードベースに受け入れられますか?**
ベース設定に受け入れられるのは、セキュリティ修正、バグ修正、明確な改善のみす。それだけです。
セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。
それ以外(新機能、OS互換性、ハードウェアサポート、拡張など)は、`channels`または`providers`ブランチのスキルとしてコントリビュートしてください
それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです
これにより、ベースシステムを最小限に保ち、ユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。
これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。
## コミュニティ
質問やアイデアがありますか[Discordに参加](https://discord.gg/VDdww8qS42)してください。
質問やアイデア[Discordに参加](https://discord.gg/VDdww8qS42)してください。
## 変更履歴
破壊的変更については[CHANGELOG.md](CHANGELOG.md)を、完全なリリース履歴はドキュメントサイトの[full release history](https://docs.nanoclaw.dev/changelog)を参照してください。
破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。
## ライセンス
+85 -78
View File
@@ -3,87 +3,90 @@
</p>
<p align="center">
一个将智能体安全运行在独立容器中的 AI 助手。轻量、易于理解,并根据您的需求完全定制。
NanoClaw —— 您的专属 Claude 助手,在容器中安全运行。它轻巧易懂,并根据您的个人需求灵活定制。
</p>
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">文档</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
</p>
通过 Claude CodeNanoClaw 可以动态重写自身代码,根据您的需求定制功能。
---
**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。
## 我为什么创建 NanoClaw
## 我为什么创建这个项目
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的复杂软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(白名单、配对码),而非真正的操作系统级隔离。所有东西都在一个共享内存的 Node 进程中运行。
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能一个进程,少数几个文件。Claude 智能体运行在具有文件系统隔离的独立 Linux 容器中,而不是仅靠权限检查。
NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体Agent运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
## 快速开始
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
git clone https://github.com/qwibitai/nanoclaw.git
cd nanoclaw
claude
```
`nanoclaw.sh` 会把您从一台全新机器一直带到一个可以直接发消息的命名智能体。它会在缺失时安装 Node、pnpm 和 Docker,向 OneCLI 注册您的 Anthropic 凭据,构建智能体容器,并配对您的第一个渠道(Telegram、Discord、WhatsApp 或本地 CLI)。如果某一步失败,会自动调用 Claude Code 进行诊断并从中断处继续
然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置
> **注意:**`/` 开头的命令(如 `/setup``/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。
## 设计哲学
**小到可以理解。** 单一进程,少量源文件无微服务。如果您想了解完整的 NanoClaw 代码库,直接让 Claude Code 给您讲一遍就行
**小巧易懂:** 单一进程,少量源文件无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手
**通过隔离实现安全** 智能体运行在 Linux 容器中,只能看到明确挂载的内容。Bash 访问安全,因为命令在容器内执行,而不是在您的宿主机
**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令在容器内执行,不会直接操作您的宿主机。
**为个人用户打造** NanoClaw 不是一个单体框架,而是能精确匹配每个用户需求的软件。它被设计成量身定制的,而不是臃肿膨胀。您创建自己的 fork,让 Claude Code 按您的需求修改它
**为单一用户打造:** 不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配
**定制 = 修改代码。** 没有配置膨胀。想要不同的行为?改代码。代码库小到改动是安全的。
**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。
**AI 原生,混合式设计。** 安装与上手流程走的是经过优化的脚本路径,快速且确定。当某一步需要判断(安装失败、引导决策、定制化)时,控制权会无缝地交给 Claude Code。安装之后也不提供监控仪表盘或调试 UI:您在聊天中描述问题,Claude Code 来处理
**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)
**技能优于功能。** 主干只发布注册表和基础设施,不包含具体的渠道适配器或替代智能体提供者。各个渠道(Discord、Slack、Telegram、WhatsApp……)放在长期存在的 `channels` 分支上;替代提供者(OpenCode、Ollama)放在 `providers` 分支上。您运行 `/add-telegram``/add-opencode` 等,技能会把您所需要的模块精确地复制到您的 fork 里。不会出现您没要求的功能
**技能Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码
**最强的 harness,最的模型** NanoClaw 通过 Anthropic 官方的 Claude Agent SDK 原生使用 Claude Code,所以您能用上最新的 Claude 模型以及 Claude Code 的完整工具集——包括修改和扩展自己的 NanoClaw fork 的能力。其他提供者是可插拔选项:`/add-codex` 对应 OpenAI 的 CodexChatGPT 订阅或 API key),`/add-opencode` 通过 OpenCode 接入 OpenRouter、Google、DeepSeek 等,`/add-ollama-provider` 用于本地开源权重模型。提供者可按智能体组单独配置
**最好的工具套件,最的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制
## 功能支持
- **多渠道消息** WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat,以及通过 Resend 的邮件。按需通过 `/add-<channel>` 技能安装。可同时运行一个或多个。
- **灵活的隔离模式** — 可为每个渠道配一个独立智能体以获得完全隐私,也可让一个智能体在多个渠道上共享、统一记忆但会话独立,或者把多个渠道合并到一个共享会话里,让一场对话横跨多个入口。通过 `/manage-channels` 按渠道选择。详见 [docs/isolation-model.md](docs/isolation-model.md)
- **每个智能体的独立工作区** — 每个智能体组都有自己的 `CLAUDE.md`、自己的记忆、自己的容器,以及您允许的挂载点。除非您明确接线,否则不会有东西越过边界。
- **计划任务** 运行 Claude 的周期性作业,可以给您回发消息
- **网络访问** 搜索和抓取网页内容
- **容器隔离** 智能体在 DockermacOS/Linux/WSL2)中沙箱化运行,可选 [Docker Sandboxes](docs/docker-sandboxes.md) 的微虚拟机隔离,或在 macOS 上选用 Apple Container 作为原生运行时。
- **凭据安全** — 智能体不持有原始 API key。出站请求经由 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli),在请求时注入凭据,并按每个智能体执行策略和速率限制。
- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp``/add-telegram` 技能添加渠道,可同时运行一个或多个。
- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统
- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离
- **计划任务** - 运行 Claude 的周期性作业,可以给您回发消息
- **网络访问** - 搜索和抓取网页内容
- **容器隔离** - 智能体在 Apple Container (macOS) 或 Docker (macOS/Linux) 的沙箱中运行
- **智能体集群(Agent Swarms** - 启动多个专业智能体团队,协作完成复杂任务(首个支持此功能的个人 AI 助手)
- **可选集成** - 通过技能添加 Gmail (`/add-gmail`) 等更多功能
## 使用方法
用触发词(默认为 `@Andy`)与您的助手对话:
使用触发词(默认为 `@Andy`)与您的助手对话:
```
@Andy个工作日早上 9 点给我发一份销售渠道概览(可以访问我的 Obsidian vault 文件夹)
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它
@Andy 每周一早上 8 点,从 Hacker News 和 TechCrunch 收集 AI 相关资讯,给我一份简报
@Andy周一到周五早上9点,给我发一份销售渠道概览(需要访问我的 Obsidian vault 文件夹)
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它
@Andy 每周一早上8点,从 Hacker News 和 TechCrunch 收集关于 AI 发展的资讯,然后发给我一份简报
```
您拥有或管理的渠道里,还可以管理群组和任务:
主频道(您的self-chat)中,可以管理群组和任务:
```
@Andy 列出所有群组的计划任务
@Andy 列出所有群组的计划任务
@Andy 暂停周一简报任务
@Andy 加入"家庭聊天"群组
```
## 定制
NanoClaw 不用配置文件。想改就直接告诉 Claude Code
没有需要学习的配置文件。直接告诉 Claude Code 您想要什么
- "把触发词改成 @Bob"
- "以后回答更简短、更直接"
- "我说早上好的时候加一个自定义问候"
- "每周存一次话摘要"
- "记住以后回答更简短直接"
- "我说早上好的时候加一个自定义问候"
- "每周存一次话摘要"
或者运行 `/customize` 进行引导式修改。
@@ -91,103 +94,107 @@ NanoClaw 不用配置文件。想改就直接告诉 Claude Code
## 贡献
**不要加功能,加技能。**
**不要加功能,而是添加技能。**
如果您想添加新的渠道或智能体提供者,不要把它加到主干上。新的渠道适配器进入 `channels` 分支;新的智能体提供者进入 `providers` 分支。用户在自己的 fork 上运行 `/add-<name>` 技能,由技能把相关模块复制到标准路径、接好注册、固定依赖版本
如果您想添加 Telegram 支持,不要创建一个 PR 同时添加 Telegram 和 WhatsApp。而是贡献一个技能文件 (`.claude/skills/add-telegram/SKILL.md`),教 Claude Code 如何改造一个 NanoClaw 安装以使用 Telegram
这样主干始终保持为纯粹的注册表和基础设施,每个 fork 也都保持精简——用户只获得他们要求的渠道和提供者,其它什么也不会混进来
然后用户在自己的 fork 上运行 `/add-telegram`,就能得到只做他们需要事情的整洁代码,而不是一个试图支持所有用例的臃肿系统
### RFS技能征集
### RFS (技能征集)
我们希望看到的技能:
**通信渠道**
- `/add-signal` 添加 Signal 作为渠道
- `/add-signal` - 添加 Signal 作为渠道
**会话管理**
- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
## 系统要求
- macOS 或 LinuxWindows 通过 WSL2
- Node.js 20+ 和 pnpm 10+(安装脚本会在缺失时自动安装)
- [Docker Desktop](https://docker.com/products/docker-desktop)macOS/Windows)或 Docker EngineLinux
- [Claude Code](https://claude.ai/download),用于 `/customize``/debug`、安装过程中的错误恢复以及所有 `/add-<channel>` 技能
- macOS 或 Linux
- Node.js 20+
- [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container) (macOS) 或 [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
## 架构
```
消息应用 → 主机进程(路由器) → inbound.db → 容器(Bun、Claude Agent SDK → outbound.db → 主机进程(投递) → 消息应用
渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
```
单一 Node 主机编排每个会话的智能体容器。当一条消息到来时,主机按实体模型(用户 → 消息组 → 智能体组 → 会话)进行路由,写入该会话的 `inbound.db`,并唤醒容器。容器内部的 agent-runner 轮询 `inbound.db`,调用 Claude,并把响应写入 `outbound.db`。主机轮询 `outbound.db`,通过渠道适配器投递回去
单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC
每个会话两个 SQLite 文件,每个文件只有一个写入者——没有跨挂载的锁争用,没有 IPC,没有 stdin 管道。渠道和替代提供者在启动时自注册;主干提供注册表和 Chat SDK 桥接,而适配器本身在每个 fork 里通过技能安装
完整架构说明见 [docs/architecture.md](docs/architecture.md);三级隔离模型见 [docs/isolation-model.md](docs/isolation-model.md)。
完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)
关键文件:
- `src/index.ts` — 入口:数据库初始化、渠道适配器、投递轮询、sweep
- `src/router.ts` — 入站路由:消息组 → 智能体组 → 会话 → `inbound.db`
- `src/delivery.ts` — 轮询 `outbound.db`,通过适配器投递,处理系统动作
- `src/host-sweep.ts` — 60 秒 sweep:失效检测、到期消息唤醒、循环任务
- `src/session-manager.ts` — 解析会话,打开 `inbound.db` / `outbound.db`
- `src/container-runner.ts` — 为每个智能体组启动容器,OneCLI 凭据注入
- `src/db/` — 中心数据库(用户、角色、智能体组、消息组、接线、迁移)
- `src/channels/` — 渠道适配器基础设施(适配器通过 `/add-<channel>` 技能安装
- `src/providers/` — 主机侧提供者配置(`claude` 内置,其他通过技能安装)
- `container/agent-runner/` — Bun 版 agent-runner:轮询循环、MCP 工具、提供者抽象
- `groups/<folder>/` — 每个智能体组的文件系统(`CLAUDE.md`、技能、容器配置)
- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用
- `src/channels/registry.ts` - 渠道注册表(启动时自注册)
- `src/ipc.ts` - IPC 监听与任务处理
- `src/router.ts` - 消息格式化与出站路由
- `src/group-queue.ts` - 带全局并发限制的群组队列
- `src/container-runner.ts` - 生成流式智能体容器
- `src/task-scheduler.ts` - 运行计划任务
- `src/db.ts` - SQLite 操作(消息、群组、会话、状态
- `groups/*/CLAUDE.md` - 各群组的记忆
## FAQ
**为什么 Docker**
**为什么 Docker**
Docker 提供跨平台支持(macOSLinux、Windows via WSL2)和成熟的生态。在 macOS 上,您可以选择通过 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时。如需更强隔离,[Docker Sandboxes](docs/docker-sandboxes.md) 会把每个容器放到一台微虚拟机里运行
Docker 提供跨平台支持(macOSLinux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时体验
**我可以在 Linux 或 Windows 上运行吗?**
**我可以在 Linux 上运行吗?**
可以。Docker 是默认运行时,在 macOSLinux 以及 Windows(通过 WSL2)上工作。运行 `bash nanoclaw.sh` 就行
可以。Docker 是默认的容器运行时,在 macOSLinux 上都可以使用。只需运行 `/setup`
**这个项目安全吗?**
智能体运行在容器,而不是在应用级权限检查之后。它们只能访问明确挂载的目录。凭据不会进入容器——出站 API 请求通过 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 在代理层注入认证,并支持速率限制和访问策略。您仍然应该审查自己要运行的代码,但代码库小到您真的做到。完整的安全模型见 [安全文档](https://docs.nanoclaw.dev/concepts/security)。
智能体在容器中运行,而不是在应用级别的权限检查之后。它们只能访问明确挂载的目录。您仍然应该审查运行的代码,但这个代码库小到您真的可以做到。完整的安全模型见 [docs/SECURITY.md](docs/SECURITY.md)。
**为什么没有配置文件?**
我们不想让配置泛滥。每用户都应该定制 NanoClaw,让代码精确地做他们想要的事,而不是去配置一个通用系统。如果您喜欢配置文件,可以让 Claude 给您加
我们不希望配置泛滥。每用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用系统。如果您喜欢配置文件,告诉 Claude 让它加上
**我可以使用第三方或开源模型吗?**
可以。推荐做法是 `/add-opencode`(通过 OpenCode 配置接入 OpenRouter、OpenAI、Google、DeepSeek 等)或 `/add-ollama-provider`(通过 Ollama 使用本地开源权重模型)。两者都可以按智能体组单独配置,所以同一套安装里不同的智能体可以运行在不同的后端上。
对于一次性实验,任何 Claude API 兼容的端点也可以通过 `.env` 使用:
可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
这使您能够使用:
- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型
- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型
- 兼容 Anthropic API 格式的自定义模型部署
注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。
**我该如何调试问题?**
问 Claude Code。"为什么计划任务没运行?""最近的日志里有什么?""为什么这条消息没有得到回"这就是 NanoClaw 底层的 AI 原生方式
问 Claude Code。"为什么计划任务没运行?" "最近的日志里有什么?" "为什么这条消息没有得到回" 这就是 AI 原生的方法
**为什么安装对我不成功?**
**为什么我的安装不成功?**
如果某一步失败,`nanoclaw.sh` 会把控制权交给 Claude Code 进行诊断并从中断处继续。如果还是没解决,运行 `claude`,然后 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请对相关的安装步骤或技能提 PR
如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md
**什么样的更改会被接受进代码库**
**什么样的代码更改会被接受?**
进入基础配置的只会是:安全修复、bug 修复、明显的改进。仅此而已。
安全修复、bug 修复,以及对基础配置的明确改进。仅此而已。
其他一切(新能、操作系统兼容、硬件支持、增强)都应作为技能贡献`channels``providers` 分支
其他一切(新能、操作系统兼容、硬件支持、增强功能)都应作为技能贡献。
基础系统保持最小化,每位用户可以定制自己的安装,而不必继承他们不想要的功能。
使得基础系统保持最小化,并让每个用户可以定制他们的安装,而无需继承他们不想要的功能。
## 社区
问题或想法?欢迎[加入 Discord](https://discord.gg/VDdww8qS42)。
任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流
## 更新日志
破坏性变更见 [CHANGELOG.md](CHANGELOG.md),完整发布历史见文档站的 [full release history](https://docs.nanoclaw.dev/changelog)
破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。
## 许可证
+175
View File
@@ -0,0 +1,175 @@
# NanoClaw Refactor — Forward-Looking Reference
Consolidates what's still relevant from `REFACTOR_PLAN.md` and `REFACTOR_EXECUTION.md`: open decisions, remaining work, operational patterns worth keeping. Historical PR timeline and phase framing have been dropped — the work is in the commit history.
---
## Architecture (still authoritative)
### Module tiers
Three categories, distinguished by shipping model and dependency direction:
| Tier | Where it lives | Loaded by default? | Removal cost |
|------|----------------|--------------------|--------------|
| **Core** | `src/**` (outside `src/modules/`, `src/channels/`, `src/providers/`) | always | N/A — can't remove |
| **Default modules** | `src/modules/<name>/` on main | yes — imported by `src/modules/index.ts` | edit core imports (intentional friction) |
| **Optional modules** | `src/modules/<name>/` on main (for now — see open q #7) | yes, via barrel import | delete files + barrel line + revert `MODULE-HOOK` edits |
| **Channel adapters** | `src/channels/<name>.ts` on `channels` branch | no — cherry-pick via `/add-<name>` | delete files + barrel line |
| **Providers** | on `providers` branch | no — cherry-pick via `/add-<provider>` | delete files + barrel line |
Default modules today: `typing`, `mount-security`, `approvals`, `cli`.
Optional modules: `interactive`, `scheduling`, `permissions`, `agent-to-agent`, `self-mod`.
Dependency rule: **core ← default modules ← optional modules**. Optional modules must not depend on each other. Known transitional violation (flagged): `src/db/messaging-groups.ts` auto-wires `agent_destinations` when agent-to-agent is installed.
### The four registries
Full contract in [`docs/module-contract.md`](docs/module-contract.md). Summary:
1. **Delivery action handlers**`delivery.ts`; modules call `registerDeliveryAction(name, fn)`.
2. **Router inbound gate**`router.ts`; single setter (`setSenderResolver` + `setAccessGate`). Default: allow-all.
3. **Response dispatcher**`response-registry.ts`; modules call `registerResponseHandler(fn)`. First to return `true` claims.
4. **Container MCP tool self-registration**`container/agent-runner/src/mcp-tools/server.ts`; modules call `registerTools([...])` at import.
Anything else single-consumer uses either a `sqlite_master`-guarded inline read or a `MODULE-HOOK:<name>:start/end` skill edit.
### Module distribution (pending)
- **`main`** — core + default modules + default channel (`cli`). Ships clean.
- **`channels`** — fully loaded runnable branch with all channel adapters; skills cherry-pick from it.
- **`providers`** — same pattern for agent providers (OpenCode).
- **`modules` branch** — proposed but NOT created yet. See "Remaining work" below.
---
## Remaining work
### Phase 5: merge `v2``main`
Cut-over the refactor. Pre-reqs (already met): green build, green tests, green service boot, clean `channels` / `providers` syncs.
Open logistics:
- Release versioning: bump to `1.3.0` at merge time or cut a `v2-rc` tag first for internal testing? Non-blocking — decide at merge.
- Coordinate with anyone still running the old `main` (v1.2.53) — breaking change for them.
- Announce the new layout + the one shell command that changed (`pnpm run chat` is new default).
### `modules` branch — create, skip, or defer?
The original plan (PR #10) was to fork a `modules` branch and populate it with the 5 optional modules, so future `/add-<module>` skills pull via `git show origin/modules:path`. Three paths:
- **(a) Create it now.** Matches the `channels`/`providers` pattern for consistency. Extra surface to maintain: every core change must be merged into `modules` at phase boundaries (same cadence as channels/providers). Pays off if we ever want to make a module *truly* optional (not shipped on main).
- **(b) Skip it.** Leave all 5 optional modules shipped on main. No `modules` branch, no install skills, no cherry-picking. Simpler but loses the "opt-in" property for users who want a leaner install.
- **(c) Defer.** Ship main without the modules branch; create it later if someone actually wants to slim their install. No-cost option for now.
Recommendation leans toward (c) — we've already paid the architectural cost (tier boundary, dependency rule, registries) without needing the branch today.
### Per-module follow-ups (tracked as open questions below)
Each has a specific landing zone when we get to it:
- #1113 (admin mechanism, providers registry, container-runner audit) — scope a focused cleanup pass.
- #14 (CLAUDE.md review) — single dedicated PR touching every module.
- #15 (A2A / destinations rethink) — requires design, not just cleanup.
- #1718 (self-mod rethink, per-group source) — requires design.
- #19 (system vs user CLAUDE.md) — requires install-skill tooling.
---
## Operational patterns (keep using these)
### Standing checks for every PR
Non-negotiable; a unit test suite alone doesn't catch circular-import TDZ bugs:
1. `pnpm run build` clean.
2. `pnpm test` + `bun test` (in `container/agent-runner/`) all green.
3. **Service actually starts.** `gtimeout 5 node dist/index.js` (or `launchctl kickstart`) must reach `NanoClaw running`. Unit tests import individual files; only `main()` exercises the module-init order.
4. Expected boot log lines present (at least: `Central DB ready`, `Delivery polls started`, `Host sweep started`, `NanoClaw running`, plus any module lifecycle line like `OneCLI approval handler started` or `CLI channel listening`).
### Module architecture rule (TDZ bug, PR #3)
Any registry state a module writes to at import time must live in a file with **no back-edge to `src/index.ts`** — transitively. `src/index.ts` imports `src/modules/index.js` for side effects; if a module calls `registerX()` at top level and `X` lives in `src/index.ts`, the ES module loader hits a TDZ reference on the const declaration. Fix: registry state lives in its own dependency-free file (e.g. `src/response-registry.ts`). Any new registry follows the same pattern.
### Branch sync procedure
After every `v2` (or future `main`) sync into `channels` / `providers` / future `modules`:
1. **File-presence diff.** Enumerate files that existed pre-sync but are missing post-sync:
```
git ls-tree -r <pre-sync> | awk '{print $4}' | sort > /tmp/pre.txt
git ls-tree -r <post-sync> | awk '{print $4}' | sort > /tmp/post.txt
comm -23 /tmp/pre.txt /tmp/post.txt
```
Classify each missing file:
- **Intentional** (core deleted it) → leave deleted.
- **Branch-owned** (channels branch still needs it) → restore from pre-sync HEAD.
This has caught real losses on both `channels` (17 adapter files plus 3 setup scripts after PR #2's channel move) and `providers` (opencode files after PR #2).
2. **Cross-file consistency.** When restoring a file, check whether something *else* that also changed references it (e.g. `setup/index.ts`'s `STEPS` map).
3. **Run the standing checks** against the synced branch (not just v2).
### Prettier drift pattern
The `format:fix` pre-commit hook sometimes reformats peer files *after* the commit completes, leaving cosmetic-only diffs in the working tree. Discard with `git checkout -- <files>`. Do not re-commit the drift — it's trivial whitespace and noise.
---
## Open questions (curated)
### Design / architecture
1. **`NANOCLAW_ADMIN_USER_IDS` as the admin mechanism.** Host queries `user_roles` at container wake, collapses into env var, container compares sender IDs. Conflates identity-at-send with privilege-at-wake and forces the container to care about namespaced user IDs. Revisit during a container-runner audit.
2. **Host-side `src/providers/` registry.** One real consumer (OpenCode). A registry is probably overkill — the install skill could just edit `container-runner.ts` via `MODULE-HOOK`. Fold into the container-runner audit.
3. **Container-runner audit.** `src/container-runner.ts` has accreted wake/spawn/kill, mount assembly, OneCLI credential application, admin-ID env var, idle timers, image rebuild. Some pieces should pull apart or move into modules. Not blocking. Related to #1 and #2.
4. **Revisit destinations + A2A capability holistically.** The destination projection invariant, dual-purpose routing+ACL table, channel vs agent destination shapes, `createMessagingGroupAgent` auto-wire coupling — more machinery than the feature warrants. Phase 3 moved it out of core intact; a redesign is warranted but scoped post-refactor.
5. **Self-mod approach rethink.** Three separate MCP tools + three delivery actions + three approval handlers for what's essentially "mutate container.config.json and rebuild." Also: post-rebuild latency (host sweep waits up to 60s), and agents sometimes send redundant `add_mcp_server` + `request_rebuild` pairs. Consider collapsing into a single "apply this container-config diff" approval primitive.
6. **Per-agent-group source / per-group base image.** Self-mod today layers packages/MCP on a shared base. As groups diverge (different base images, provider configs, runtime toolchains), the shared-base assumption won't scale. Scope post-refactor.
### Distribution / operational
7. **Providers on a consolidated `modules` branch?** Staying separate for now. Revisit if a second optional provider appears.
8. **Per-group module enablement.** Modules are currently project-wide. If one agent group wants approvals and another doesn't, we'd need per-group feature flags. Flag if asked.
9. **Module removal UX.** We do not drop tables on uninstall. Is that the right default? (Alternative: `/remove-<module>` optionally runs a down migration. YAGNI until requested.)
10. **Cross-module ordering for the response dispatcher.** Registration order determines who claims a given `questionId`. IDs are disjoint in practice (`q-…` vs `appr-…`), so first-match-wins is safe. If a third response-consuming module arrives, we may need keyed dispatch.
11. **Versioned module migrations.** Reinstalls are idempotent (migrator skips anything already in `schema_version`). If a module ships a *new* migration in a later version, the install skill must append the new file + barrel entry without touching prior ones. Simplest rule: install skills are additive; content changes to an already-applied migration are a hard error.
12. **Telegram pairing imports from permissions (channels branch).** `src/channels/telegram.ts` reaches into `src/modules/permissions/db/*` for `grantRole`/`hasAnyOwner`/`upsertUser` in the pairing-bootstrap branch. Cross-branch tier violation. Fix: extract those writes into a pairing helper (e.g. `src/channels/telegram-pairing-accept.ts` or `setup/pair-telegram.ts`). Non-blocking.
### Core slotting (files not explicitly discussed)
13. **`state-sqlite.ts`, `webhook-server.ts`, `timezone.ts`.** state-sqlite is likely core (host tracker). Webhook-server likely core (channel infra). Timezone likely core utility. Confirm if any of them prove to be module-shaped during future audits.
14. **Chat SDK bridge location.** `src/channels/chat-sdk-bridge.ts` is channel infra that bridges adapters on the `channels` branch. Stays in `src/channels/` for now.
15. **OneCLI credential injection.** Lives in `container-runner.ts`. Every agent call uses it, no clean optional boundary. Stays core. Related: `onecli-approvals.ts` is bundled inside the `approvals` default module on the assumption OneCLI stays in core. If OneCLI later moves to its own module, `onecli-approvals` follows.
### Documentation
16. **CLAUDE.md content per module.** Every module ships with project.md + agent.md. Need a dedicated review pass: (a) write the missing agent-to-agent snippets, (b) audit other modules for accuracy/tone, (c) confirm `agent.md` files are actually tailored for the agent vs. copy-pastes of `project.md`.
17. **Split system CLAUDE.md from user CLAUDE.md.** Project `CLAUDE.md` and `groups/global/CLAUDE.md` mix system-authored content (module contracts, install-skill appends) with user customizations. Updates currently risk clobbering user intent. Look at a system-owned region (or separate file) that skills rewrite freely plus a user-owned one that's never touched. Related to #16.
---
## Where the canonical references live
- **Module contract** — [`docs/module-contract.md`](docs/module-contract.md)
- **Architecture overview** — [`docs/architecture.md`](docs/architecture.md)
- **DB layout** — [`docs/db.md`](docs/db.md), [`docs/db-central.md`](docs/db-central.md), [`docs/db-session.md`](docs/db-session.md)
- **Agent-runner internals** — [`docs/agent-runner-details.md`](docs/agent-runner-details.md)
- **Channel isolation model** — [`docs/isolation-model.md`](docs/isolation-model.md)
- **Build + runtime split** — [`docs/build-and-runtime.md`](docs/build-and-runtime.md)
- **Top-level** — [`CLAUDE.md`](CLAUDE.md)
This doc (`REFACTOR.md`) is transient — prune when open questions close; retire entirely once the refactor is fully behind us and the operational patterns have been absorbed into `CLAUDE.md` or `docs/`.
-50
View File
@@ -1,50 +0,0 @@
# Releasing NanoClaw
Starting with v2.0.63, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`. Releases are cut manually by a maintainer, so there can be lag between a bump merging and its release being published. The intent is *timeliness*, not strict 1:1 correlation with every bump.
Each release ships:
- A tagged commit on `main` (`vX.Y.Z`).
- A `CHANGELOG.md` entry under `## [<version>] - <YYYY-MM-DD>`.
- A GitHub Release whose body mirrors the CHANGELOG entry plus a contributors section.
## When to cut a release
A release is cut by a maintainer publishing it. The trigger is a `package.json` bump on `main`, but the publish step is manual — there is no fixed schedule, and bumps that land back-to-back may be rolled into a single release (as v2.0.55 through v2.0.63 were). Cutting more frequently is preferable to batching: smaller releases are easier to read, pin, and revert.
## What goes in a release
`CHANGELOG.md` is the canonical record of user-visible change. The release body on GitHub mirrors it. Aim for:
- **Bold lead-ins** per major feature or fix, then a sentence-case prose explanation.
- **`[BREAKING]` prefix** for any change that requires user action. Always include the workaround inline — never link to a separate doc for the fix.
- **Doc links** for major features (relative paths into the repo, e.g. `[setup/lib/install-slug.sh](setup/lib/install-slug.sh)`).
- **Inline commands** for actionable steps, in backticks.
- **Minor items** as single plain bullets at the bottom of the entry, no bold lead-in.
- **No PR numbers** in the user-facing prose. PR references can live in the GitHub Release's `## Contributors` section.
## Publishing the release
1. Bump `package.json` and add a `CHANGELOG.md` entry in the same commit (commit message: `chore: bump version to vX.Y.Z`).
2. Once the bump commit lands on `main`, open a draft GitHub Release:
- **Tag:** `vX.Y.Z`, target `main`.
- **Title:** `vX.Y.Z` (bare version — descriptive content lives in the body, matching the CHANGELOG header pattern).
- **Body:** copy the CHANGELOG entry verbatim. Append a `## Contributors` section listing every PR author who landed work in the release window. Append a `**Full Changelog**: https://github.com/nanocoai/nanoclaw/compare/<prev-tag>...vX.Y.Z` line at the bottom.
3. If anyone in the window opened their first NanoClaw PR, add a `## New Contributors` section above `## Contributors`, with each first-timer's first PR link and an invite to Discord.
4. Publish (not just save draft).
## Rollup releases
If multiple `package.json` bumps land between two GitHub Releases (as happened between v2.0.54 and v2.0.63), the next release is a rollup: its CHANGELOG entry covers everything merged since the last released tag, and the body opens with a one-line "Rollup release covering vX.Y.Z through vX.Y.W." note. After the catchup, return to one release per bump.
## Channels and stability
NanoClaw currently ships a single channel: every published release is a stable release.
- **Latest** — the most recent release on `main`, shown as "Latest release" on the GitHub Releases page. Consumers that want auto-bump follow GitHub's `/releases/latest` pointer.
- **Stable** — currently identical to latest. NanoClaw has no separate stable branch and no pre-release/RC channel.
- **Pinned** — any tagged release. Reproducible and the recommended choice for packagers and forks; published tags are not moved or retracted.
If a pre-release channel is introduced later (e.g. `vX.Y.Z-rc.N`), those releases will be marked "Pre-release" on GitHub so they do not become the `latest` pointer, and this section will be updated to describe the promotion path.
The tag is the source of truth — a GitHub Release's `target_commitish` always points to a tagged commit.
-30
View File
@@ -1,30 +0,0 @@
⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⢹⡆⢸⡆⠀ °
⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀
⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧
o ⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿
⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇
⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o
⢠⣿⡃⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀
° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀
⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀
⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀ O
⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀
⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀
⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀
o ⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀
⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀
 _ _  ___ _ 
| \| |__ _ _ _ ___  / __| |__ ___ __ __
| .` / _` | ' \/ _ \| (__| / _` \ V V /
|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/ 
Small.
Runs on your machine.
Yours to modify.
════════════════════════════════════════
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
#
# ncl — NanoClaw CLI launcher.
#
# Resolves the project root from this script's location, cd's there so the
# host-resolved DATA_DIR matches the running host, and execs the TS entry
# via tsx. Symlink this file into a directory on your PATH (or alias `ncl`
# to its full path) to invoke from anywhere:
#
# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl
# # or
# alias ncl="$(pwd)/bin/ncl"
set -euo pipefail
SCRIPT="${BASH_SOURCE[0]}"
# Resolve symlinks so PROJECT_ROOT points at the real checkout.
while [ -h "$SCRIPT" ]; do
DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
SCRIPT="$(readlink "$SCRIPT")"
[[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT"
done
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
exec pnpm exec tsx src/cli/client.ts "$@"
-21
View File
@@ -1,21 +0,0 @@
You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn.
## Communication
Be concise — every message costs the reader's attention. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result, not a transcript of what you did.
## Workspace
Files you create are saved in `/workspace/agent/`. Use this for notes, research, or anything that should persist across turns in this group.
The file `CLAUDE.local.md` in your workspace is your per-group memory. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured.
## Memory
When the user shares any substantive information with you, it must be stored somewhere you can retrieve it when relevant. If it's information that is pertinent to every single conversation turn it should be put into CLAUDE.local.md. Otherwise, create a system for storing the information depending on its type - e.g. create a file of people that the user mentions so you can keep track or a file of projects. For every file you create, add a concise reference in your CLAUDE.local.md so you'll be able to find it in future conversations.
A core part of your job and the main thing that defines how useful you are to the user is how well you do in creating these systems for organizing information. These are your systems that help you do your job well. Evolve them over time as needed.
## Conversation history
The `conversations/` folder in your workspace holds searchable transcripts of past sessions with this group. Use it to recall prior context when a request references something that happened before. For structured long-lived data, prefer dedicated files (`customers.md`, `preferences.md`, etc.); split any file over ~500 lines into a folder with an index.
+27 -46
View File
@@ -3,12 +3,8 @@
# Runs Claude Agent SDK in isolated Linux VM with browser automation.
#
# Runtime split:
# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host
# - agent-runner (our TypeScript code): Bun
# - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node
#
# Source is never baked in — /app/src is provided by a shared read-only
# bind mount at runtime (see src/container-runner.ts). Source-only changes
# never require an image rebuild.
FROM node:22-slim
@@ -19,9 +15,9 @@ ARG INSTALL_CJK_FONTS=false
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.128
ARG CLAUDE_CODE_VERSION=2.1.112
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG VERCEL_VERSION=latest
ARG BUN_VERSION=1.3.12
# ---- System dependencies -----------------------------------------------------
@@ -70,59 +66,44 @@ RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \
rm -rf /root/.bun
# ---- agent-runner deps -------------------------------------------------------
# Deps are cached independently of CLI versions. Source is NOT baked in —
# it's provided by the shared RO mount at runtime.
# ---- pnpm + global Node CLIs -------------------------------------------------
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# agent-browser has a postinstall build script — pnpm skips these by default.
# Allowlist it via .npmrc so the install doesn't silently produce a broken
# package. Pinned versions so every rebuild is reproducible.
RUN --mount=type=cache,target=/root/.cache/pnpm \
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
pnpm install -g \
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
"agent-browser@${AGENT_BROWSER_VERSION}" \
"vercel@${VERCEL_VERSION}"
# ---- agent-runner ------------------------------------------------------------
WORKDIR /app
# Copy manifest + lockfile first so the install layer caches independently of
# source edits.
COPY agent-runner/package.json agent-runner/bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile
# ---- pnpm + global Node CLIs -------------------------------------------------
# Most stable first, most frequently bumped last. Bumping claude-code
# (the most common change) only invalidates one layer.
#
# only-built-dependencies gates pnpm's supply-chain policy:
# - agent-browser has a postinstall build step.
# - @anthropic-ai/claude-code's postinstall downloads the native Claude
# binary (linux-arm64 variant on our image). Without the allowlist
# the SDK fails at spawn time with "native binary not found".
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped
# honoring `only-built-dependencies[]=` in .npmrc for global installs, which
# silently skips claude-code's native-binary postinstall and agent-browser's
# bin chmod — the agent then crashes at runtime with "native binary not
# installed". Keep this in lockstep with package.json's `packageManager`.
ARG PNPM_VERSION=10.33.0
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
RUN --mount=type=cache,target=/root/.cache/pnpm \
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \
pnpm install -g "vercel@${VERCEL_VERSION}"
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
# ---- ncl CLI wrapper ----------------------------------------------------------
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \
chmod +x /usr/local/bin/ncl
# Source. Bun runs TS directly — no tsc build step. The host remounts this
# path at runtime via `src/container-runner.ts` so source edits on the host
# take effect without rebuilding the image; the baked copy is the fallback.
COPY agent-runner/ ./
# ---- Entrypoint --------------------------------------------------------------
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# ---- Workspace + permissions -------------------------------------------------
RUN mkdir -p /workspace/group /workspace/extra && \
RUN mkdir -p /workspace/group /workspace/global /workspace/extra && \
chown -R node:node /workspace && \
chmod 777 /home/node
chmod 755 /home/node
USER node
WORKDIR /workspace/group
+34 -18
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
@@ -18,23 +18,7 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.138", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.112", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
@@ -42,6 +26,38 @@
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
@@ -1,169 +0,0 @@
#!/usr/bin/env bun
/**
* SDK signal probe: run a prompt, log every signal the Agent SDK emits
* async-iterator events + hook callbacks + CLI stderr with absolute
* and relative timing.
*
* Usage:
* bun run scripts/sdk-signal-probe.ts "<prompt>" # simple string mode
* bun run scripts/sdk-signal-probe.ts --stream "<prompt>" # streaming-input mode
* bun run scripts/sdk-signal-probe.ts --stream "<p>" \
* --push "5000:<text>" --push "15000:<text>" --timeout 60000 # multi-push
*
* Streaming mode (`--stream`) passes an AsyncIterable prompt to `query()`,
* which keeps the CLI subprocess alive past the first result (per SDK
* deep dive). Required for post-result pushes, agent teams, background
* task notifications.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
const args = process.argv.slice(2);
const prompts: string[] = [];
const pushes: Array<{ atMs: number; text: string }> = [];
let streamMode = false;
let timeoutMs: number | undefined;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--stream') streamMode = true;
else if (a === '--push') {
const val = args[++i] ?? '';
const ix = val.indexOf(':');
if (ix === -1) throw new Error(`bad --push (want MS:text): ${val}`);
pushes.push({ atMs: parseInt(val.slice(0, ix), 10), text: val.slice(ix + 1) });
} else if (a === '--timeout') timeoutMs = parseInt(args[++i] ?? '0', 10);
else if (a === '--prompt') prompts.push(args[++i] ?? '');
else prompts.push(a);
}
const prompt = prompts.join(' ');
if (!prompt) {
console.error('usage: sdk-signal-probe.ts [--stream] "<prompt>" [--push MS:<text>]... [--timeout MS]');
process.exit(1);
}
const T0 = Date.now();
let LAST = T0;
function log(source: string, type: string, payload: unknown = {}): void {
const now = Date.now();
const entry = { t_ms: now - T0, d_ms: now - LAST, source, type, payload };
LAST = now;
console.log(JSON.stringify(entry));
}
function hookLogger(eventName: string) {
return async (input: unknown, toolUseID: string | undefined): Promise<any> => {
log('hook', eventName, { toolUseID, input });
// Stuck-tool simulation: if env flag is set and this is a PreToolUse for Bash,
// never resolve — simulates a tool that hangs forever.
if (process.env.PROBE_HANG === 'true' && eventName === 'PreToolUse') {
const toolName = (input as any)?.tool_name ?? (input as any)?.name;
if (toolName === 'Bash') {
log('meta', 'pre_tool_use_hanging', { toolUseID, toolName });
await new Promise(() => {
/* never resolves */
});
}
}
return { continue: true };
};
}
const HOOK_EVENTS = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'Notification',
'UserPromptSubmit',
'SessionStart',
'SessionEnd',
'Stop',
'SubagentStart',
'SubagentStop',
'PreCompact',
'PermissionRequest',
] as const;
const hooks: Record<string, unknown[]> = {};
for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [hookLogger(ev)] }];
// Build prompt — string (single-turn) or AsyncIterable (streaming-input)
let promptInput: any;
if (streamMode) {
const sessionId = `probe-${Date.now()}`;
async function* streamPrompt() {
// Initial user message
yield {
type: 'user' as const,
message: { role: 'user' as const, content: prompt },
parent_tool_use_id: null,
session_id: sessionId,
};
// Schedule subsequent pushes
const startT = Date.now();
const sorted = [...pushes].sort((a, b) => a.atMs - b.atMs);
for (const p of sorted) {
const waitMs = Math.max(0, p.atMs - (Date.now() - startT));
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
log('meta', 'push_message', { atMs: p.atMs, text: p.text });
yield {
type: 'user' as const,
message: { role: 'user' as const, content: p.text },
parent_tool_use_id: null,
session_id: sessionId,
};
}
// Keep stream open for tail events; iterator ends when we return
// (no more work expected). For post-result-idle scenarios, wait here.
await new Promise((r) => setTimeout(r, 5000));
}
promptInput = streamPrompt();
} else {
promptInput = prompt;
}
log('meta', 'probe_start', { prompt, streamMode, pushes, timeoutMs });
const q = query({
prompt: promptInput,
options: {
includePartialMessages: true,
hooks: hooks as any,
stderr: (data: string) => log('stderr', 'chunk', { data }),
settingSources: [],
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
},
});
// Absolute time cap — exit cleanly so the log flushes
if (timeoutMs) {
setTimeout(() => {
log('meta', 'timeout_hit', { timeoutMs });
setTimeout(() => process.exit(0), 250);
}, timeoutMs);
}
try {
for await (const event of q) {
const snapshot: any = { ...event };
try {
const raw = JSON.stringify(snapshot);
if (raw.length > 2000) {
snapshot._truncated_bytes = raw.length;
if (snapshot.message?.content) {
const c = JSON.stringify(snapshot.message.content);
snapshot.message = { ...snapshot.message, content: c.slice(0, 500) + `…<+${c.length - 500}b>` };
}
}
} catch {
/* best-effort */
}
log('event', snapshot.type ?? 'unknown', { subtype: snapshot.subtype, event: snapshot });
}
log('meta', 'iterator_done');
} catch (err: any) {
log('meta', 'iterator_error', { message: err?.message, stack: err?.stack?.split('\n').slice(0, 5) });
}
-254
View File
@@ -1,254 +0,0 @@
#!/usr/bin/env bun
/**
* ncl NanoClaw CLI client (container edition).
*
* Same interface as the host-side `bin/ncl`. Detects that it's inside a
* container (the session DBs exist at /workspace/) and uses a DB transport
* instead of the Unix socket transport.
*
* Writes a cli_request system message to outbound.db, polls inbound.db
* for the response. Self-contained no imports from agent-runner.
*/
import { Database } from 'bun:sqlite';
// ---------------------------------------------------------------------------
// Frame types (mirrors src/cli/frame.ts on the host)
// ---------------------------------------------------------------------------
type RequestFrame = {
id: string;
command: string;
args: Record<string, unknown>;
};
type ResponseFrame =
| { id: string; ok: true; data: unknown }
| { id: string; ok: false; error: { code: string; message: string } };
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const INBOUND_DB = '/workspace/inbound.db';
const OUTBOUND_DB = '/workspace/outbound.db';
// ---------------------------------------------------------------------------
// DB transport
// ---------------------------------------------------------------------------
function generateId(): string {
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Write a cli_request to outbound.db.
*
* Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq),
* preventing seq collisions with concurrent agent-runner writes.
*/
function writeRequest(req: RequestFrame): void {
const db = new Database(OUTBOUND_DB);
db.exec('PRAGMA journal_mode = DELETE');
db.exec('PRAGMA busy_timeout = 5000');
const inDb = new Database(INBOUND_DB, { readonly: true });
inDb.exec('PRAGMA busy_timeout = 5000');
try {
db.exec('BEGIN IMMEDIATE');
const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
const max = Math.max(maxOut, maxIn);
const nextSeq = max % 2 === 0 ? max + 1 : max + 2;
db.prepare(
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
).run({
$id: req.id,
$seq: nextSeq,
$content: JSON.stringify({
action: 'cli_request',
requestId: req.id,
command: req.command,
args: req.args,
}),
});
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
} finally {
inDb.close();
db.close();
}
}
/**
* Poll inbound.db for a cli_response matching our requestId.
* Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility.
*/
function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const inDb = new Database(INBOUND_DB, { readonly: true });
inDb.exec('PRAGMA busy_timeout = 5000');
inDb.exec('PRAGMA mmap_size = 0');
try {
const row = inDb
.prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null;
if (row) {
// Mark as completed via processing_ack so agent-runner skips it
const outDb = new Database(OUTBOUND_DB);
outDb.exec('PRAGMA journal_mode = DELETE');
outDb.exec('PRAGMA busy_timeout = 5000');
outDb
.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
)
.run(row.id);
outDb.close();
const parsed = JSON.parse(row.content);
return parsed.frame as ResponseFrame;
}
} finally {
inDb.close();
}
Bun.sleepSync(500);
}
return null;
}
// ---------------------------------------------------------------------------
// Arg parsing (mirrors host-side client.ts)
// ---------------------------------------------------------------------------
function parseArgv(argv: string[]): {
command: string;
args: Record<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
let json = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--json') {
json = true;
continue;
}
if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
args[key] = true;
} else {
args[key] = next;
i++;
}
continue;
}
positional.push(a);
}
if (positional.length === 0) {
process.stderr.write('ncl: missing command\n');
printUsage();
process.exit(2);
}
// Join all positionals with dashes. The dispatcher trims the last
// segment as a target ID if the full name isn't a registered command.
const command = positional.join('-');
return { command, args, json };
}
function printUsage(): void {
process.stdout.write(
['Usage: ncl <command> [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'),
);
}
// ---------------------------------------------------------------------------
// Formatting (mirrors src/cli/format.ts on the host)
// ---------------------------------------------------------------------------
function formatHuman(resp: ResponseFrame): string {
if (!resp.ok) {
return `error (${resp.error.code}): ${resp.error.message}\n`;
}
const data = resp.data;
if (!Array.isArray(data) || data.length === 0) {
return JSON.stringify(data, null, 2) + '\n';
}
const isFlat = data.every(
(r) =>
typeof r === 'object' &&
r !== null &&
!Array.isArray(r) &&
Object.values(r as Record<string, unknown>).every((v) => typeof v !== 'object' || v === null),
);
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
const keys = Object.keys(data[0] as Record<string, unknown>);
const widths = keys.map((k) =>
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[k] ?? '').length)),
);
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
const rows = data.map((r) =>
keys
.map((k, i) => String((r as Record<string, unknown>)[k] ?? '').padEnd(widths[i]))
.join(' '),
);
return [header, sep, ...rows, ''].join('\n');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const argv = process.argv.slice(2);
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
printUsage();
process.exit(0);
}
const { command, args, json } = parseArgv(argv);
const requestId = generateId();
const req: RequestFrame = { id: requestId, command, args };
writeRequest(req);
const resp = pollResponse(requestId, 30_000);
if (!resp) {
process.stderr.write('ncl: command timed out after 30s\n');
process.exit(2);
}
if (json) {
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
} else {
const output = formatHuman(resp);
if (!resp.ok) {
process.stderr.write(output);
process.exit(1);
}
process.stdout.write(output);
}
@@ -1,34 +0,0 @@
/**
* PreCompact hook script outputs custom compaction instructions to stdout.
*
* Claude Code captures the stdout of PreCompact shell hooks and passes it
* as `customInstructions` to the compaction prompt. This ensures the
* compaction summary preserves message routing context that the agent needs
* to correctly address responses.
*
* Invoked by the PreCompact hook in .claude-shared/settings.json:
* "command": "bun /app/src/compact-instructions.ts"
*/
import { getAllDestinations } from './destinations.js';
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name);
const instructions = [
'Preserve the following in the compaction summary:',
'',
'1. For recent messages, keep the full XML structure including all attributes:',
' - <message from="..." sender="..." time="..."> for chat messages',
' - <task from="..." time="..."> for scheduled tasks',
' - <webhook from="..." source="..." event="..."> for webhooks',
' The message content can be summarized if long, but the XML tags and attributes must remain.',
'',
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. At the END of the compaction summary, include this verbatim reminder:',
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
];
console.log(instructions.join('\n'));
-59
View File
@@ -1,59 +0,0 @@
/**
* Runner config reads /workspace/agent/container.json at startup.
*
* This file is mounted read-only inside the container. The host writes it;
* the runner only reads. All NanoClaw-specific configuration lives here
* instead of environment variables.
*/
import fs from 'fs';
const CONFIG_PATH = '/workspace/agent/container.json';
export interface RunnerConfig {
provider: string;
assistantName: string;
groupName: string;
agentGroupId: string;
maxMessagesPerPrompt: number;
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
}
const DEFAULT_MAX_MESSAGES = 10;
let _config: RunnerConfig | null = null;
/**
* Load config from container.json. Called once at startup.
* Falls back to sensible defaults for any missing field.
*/
export function loadConfig(): RunnerConfig {
if (_config) return _config;
let raw: Record<string, unknown> = {};
try {
raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch {
console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`);
}
_config = {
provider: (raw.provider as string) || 'claude',
assistantName: (raw.assistantName as string) || '',
groupName: (raw.groupName as string) || '',
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
};
return _config;
}
/** Get the loaded config. Throws if loadConfig() hasn't been called. */
export function getConfig(): RunnerConfig {
if (!_config) throw new Error('Config not loaded — call loadConfig() first');
return _config;
}
@@ -1,29 +0,0 @@
/**
* Per-batch context the poll loop publishes for downstream consumers
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
*
* Today the only field is `inReplyTo` the id of the first inbound
* message in the batch the agent is currently processing. MCP tools like
* `send_message` and `send_file` read this and stamp it onto the outbound
* row so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This is module-level state on purpose: the agent-runner is single-process
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
* before invoking the provider and `clearCurrentInReplyTo` after the batch
* completes (or errors out).
*/
let currentInReplyTo: string | null = null;
export function setCurrentInReplyTo(id: string | null): void {
currentInReplyTo = id;
}
export function clearCurrentInReplyTo(): void {
currentInReplyTo = null;
}
export function getCurrentInReplyTo(): string | null {
return currentInReplyTo;
}
+7 -99
View File
@@ -27,46 +27,13 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat';
let _inbound: Database | null = null;
let _outbound: Database | null = null;
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
let _testMode = false;
/**
* Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled.
*
* Use this (not getInboundDb) for readers that need to see host-written rows
* promptly e.g. messages_in polling. Caller must .close() the returned
* connection (try/finally).
*
* Needed for mounts where host writes don't reliably invalidate
* SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple
* Container), NFS.
*
* Cost is microseconds per query, so safe for universal use.
*/
export function openInboundDb(): Database {
// In test mode return a thin wrapper over the in-memory singleton.
// Callers do try/finally { db.close() } — the wrapper no-ops close()
// so the singleton survives for the rest of the test.
if (_testMode && _inbound) {
const db = _inbound;
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
}
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA mmap_size = 0');
return db;
}
/**
* Inbound DB long-lived singleton, OK for tables the host writes once
* at spawn and never again (destinations, session_routing). For
* messages_in polling where the host writes continuously and a stale
* view causes the pollHandle hang use `openInboundDb()` instead.
*/
/** Inbound DB — container opens read-only (host is the sole writer). */
export function getInboundDb(): Database {
if (!_inbound) {
_inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH;
_inbound = new Database(dbPath, { readonly: true });
_inbound.exec('PRAGMA busy_timeout = 5000');
_inbound.exec('PRAGMA mmap_size = 0');
}
return _inbound;
}
@@ -74,7 +41,8 @@ export function getInboundDb(): Database {
/** Outbound DB — container owns this file (sole writer). */
export function getOutboundDb(): Database {
if (!_outbound) {
_outbound = new Database(DEFAULT_OUTBOUND_PATH);
const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH;
_outbound = new Database(dbPath);
_outbound.exec('PRAGMA journal_mode = DELETE');
_outbound.exec('PRAGMA busy_timeout = 5000');
_outbound.exec('PRAGMA foreign_keys = ON');
@@ -96,65 +64,17 @@ export function getOutboundDb(): Database {
if (!cols.has('updated_at')) {
_outbound.exec(`ALTER TABLE session_state ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''`);
}
// container_state: tracks the current tool in flight (if any) so the host
// sweep can widen its stuck tolerance when Bash is running with a user-
// declared long timeout. Forward-compat for older outbound.db files.
_outbound.exec(`
CREATE TABLE IF NOT EXISTS container_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
current_tool TEXT,
tool_declared_timeout_ms INTEGER,
tool_started_at TEXT,
updated_at TEXT NOT NULL
);
`);
}
return _outbound;
}
/**
* Record that a tool is starting. `declaredTimeoutMs` is the tool's own
* timeout hint when one is available (Bash exposes it in the tool_use input);
* omit for tools with no declared timeout.
*/
export function setContainerToolInFlight(tool: string, declaredTimeoutMs: number | null): void {
const now = new Date().toISOString();
getOutboundDb()
.prepare(
`INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at)
VALUES (1, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
current_tool = excluded.current_tool,
tool_declared_timeout_ms = excluded.tool_declared_timeout_ms,
tool_started_at = excluded.tool_started_at,
updated_at = excluded.updated_at`,
)
.run(tool, declaredTimeoutMs, now, now);
}
/** Clear the in-flight tool — called on PostToolUse / PostToolUseFailure. */
export function clearContainerToolInFlight(): void {
const now = new Date().toISOString();
getOutboundDb()
.prepare(
`INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at)
VALUES (1, NULL, NULL, NULL, ?)
ON CONFLICT(id) DO UPDATE SET
current_tool = NULL,
tool_declared_timeout_ms = NULL,
tool_started_at = NULL,
updated_at = excluded.updated_at`,
)
.run(now);
}
/**
* Touch the heartbeat file replaces the old touchProcessing() DB writes.
* The host checks this file's mtime for stale container detection.
* A file touch is cheaper and avoids cross-boundary DB write contention.
*/
export function touchHeartbeat(): void {
const p = _heartbeatPath;
const p = process.env.SESSION_HEARTBEAT_PATH || _heartbeatPath;
const now = new Date();
try {
fs.utimesSync(p, now, now);
@@ -178,7 +98,6 @@ export function clearStaleProcessingAcks(): void {
/** For tests — creates in-memory DBs with the session schemas. */
export function initTestSessionDb(): { inbound: Database; outbound: Database } {
_testMode = true;
_inbound = new Database(':memory:');
_inbound.exec('PRAGMA foreign_keys = ON');
_inbound.exec(`
@@ -190,14 +109,11 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
status TEXT DEFAULT 'pending',
process_after TEXT,
recurrence TEXT,
series_id TEXT,
tries INTEGER DEFAULT 0,
trigger INTEGER NOT NULL DEFAULT 1,
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL,
on_wake INTEGER NOT NULL DEFAULT 0
content TEXT NOT NULL
);
CREATE TABLE delivered (
message_out_id TEXT PRIMARY KEY,
@@ -241,13 +157,6 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE container_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
current_tool TEXT,
tool_declared_timeout_ms INTEGER,
tool_started_at TEXT,
updated_at TEXT NOT NULL
);
`);
return { inbound: _inbound, outbound: _outbound };
@@ -256,7 +165,6 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
export function closeSessionDb(): void {
_inbound?.close();
_inbound = null;
_testMode = false;
_outbound?.close();
_outbound = null;
}
+29 -80
View File
@@ -7,21 +7,7 @@
* The container never writes to inbound.db all status tracking goes through
* processing_ack. The host reads processing_ack to sync message lifecycle.
*/
import { getConfig } from '../config.js';
import { openInboundDb, getOutboundDb } from './connection.js';
// Cache whether inbound.db has the on_wake column (added in v2.0.48).
// The container opens inbound.db read-only, so it can't ALTER —
// gracefully degrade when running against an older session DB.
let _hasOnWake: boolean | null = null;
function hasOnWakeColumn(db: ReturnType<typeof openInboundDb>): boolean {
if (_hasOnWake !== null) return _hasOnWake;
const cols = new Set(
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
);
_hasOnWake = cols.has('on_wake');
return _hasOnWake;
}
import { getInboundDb, getOutboundDb } from './connection.js';
export interface MessageInRow {
id: string;
@@ -32,68 +18,40 @@ export interface MessageInRow {
process_after: string | null;
recurrence: string | null;
tries: number;
/** 1 = wake-eligible (default); 0 = accumulated context only */
trigger: number;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string;
}
// Cap on how many messages reach the agent in one prompt. Read from
// container.json; falls back to 10.
function getMaxMessagesPerPrompt(): number {
try {
return getConfig().maxMessagesPerPrompt;
} catch {
// Config not loaded yet (e.g. test harness) — use default
return 10;
}
}
/**
* Fetch pending messages that are due for processing.
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
* to skip messages already picked up by this or a previous container run.
*
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
* chronological order, regardless of their `trigger` flag: accumulated
* context (trigger=0) rides along with the wake-eligible rows so the agent
* sees the prior context it missed. Host's countDueMessages gates waking on
* trigger=1 separately (see src/db/session-db.ts).
*/
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
const inbound = openInboundDb();
export function getPendingMessages(): MessageInRow[] {
const inbound = getInboundDb();
const outbound = getOutboundDb();
try {
const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : '';
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
${onWakeFilter}
ORDER BY seq DESC
LIMIT ?2`,
)
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
ORDER BY timestamp ASC`,
)
.all() as MessageInRow[];
if (pending.length === 0) return [];
if (pending.length === 0) return [];
// Filter out messages already acknowledged in outbound.db
const ackedIds = new Set(
(outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
(r) => r.message_id,
),
);
// Filter out messages already acknowledged in outbound.db
const ackedIds = new Set(
(outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
(r) => r.message_id,
),
);
// Reverse: we fetched DESC to take the most recent N, but the agent
// should see them in chronological order (oldest first).
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
} finally {
inbound.close();
}
return pending.filter((m) => !ackedIds.has(m.id));
}
/** Mark messages as processing — writes to processing_ack in outbound.db. */
@@ -131,12 +89,7 @@ export function markFailed(id: string): void {
/** Get a message by ID (read from inbound.db). */
export function getMessageIn(id: string): MessageInRow | undefined {
const inbound = openInboundDb();
try {
return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
} finally {
inbound.close();
}
return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
}
/**
@@ -144,23 +97,19 @@ export function getMessageIn(id: string): MessageInRow | undefined {
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
*/
export function findQuestionResponse(questionId: string): MessageInRow | undefined {
const inbound = openInboundDb();
const inbound = getInboundDb();
const outbound = getOutboundDb();
try {
const response = inbound
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
const response = inbound
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
if (!response) return undefined;
if (!response) return undefined;
// Check it hasn't been acked already
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
if (acked) return undefined;
// Check it hasn't been acked already
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
if (acked) return undefined;
return response;
} finally {
inbound.close();
}
return response;
}
@@ -1,100 +0,0 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { getOutboundDb, initTestSessionDb } from './connection.js';
import {
clearContinuation,
getContinuation,
migrateLegacyContinuation,
setContinuation,
} from './session-state.js';
beforeEach(() => {
initTestSessionDb();
});
function seedLegacy(value: string): void {
getOutboundDb()
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.run('sdk_session_id', value, new Date().toISOString());
}
describe('session-state — per-provider continuations', () => {
test('set/get round-trip, case-insensitive provider key', () => {
setContinuation('claude', 'claude-conv-1');
expect(getContinuation('claude')).toBe('claude-conv-1');
expect(getContinuation('Claude')).toBe('claude-conv-1');
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
});
test('providers are isolated — switching reads the right slot', () => {
setContinuation('claude', 'claude-conv-1');
setContinuation('codex', 'codex-thread-xyz');
expect(getContinuation('claude')).toBe('claude-conv-1');
expect(getContinuation('codex')).toBe('codex-thread-xyz');
});
test('clearContinuation only affects the specified provider', () => {
setContinuation('claude', 'keep-me');
setContinuation('codex', 'drop-me');
clearContinuation('codex');
expect(getContinuation('claude')).toBe('keep-me');
expect(getContinuation('codex')).toBeUndefined();
});
test('unknown provider returns undefined', () => {
expect(getContinuation('never-used')).toBeUndefined();
});
});
describe('session-state — legacy migration', () => {
test('adopts legacy value into current provider when current is empty', () => {
seedLegacy('old-session-id');
const adopted = migrateLegacyContinuation('claude');
expect(adopted).toBe('old-session-id');
expect(getContinuation('claude')).toBe('old-session-id');
});
test('always deletes legacy row regardless of migration outcome', () => {
seedLegacy('old-session-id');
setContinuation('claude', 'existing');
migrateLegacyContinuation('claude');
// After migration the legacy key must be gone, whether or not it was adopted.
// A subsequent migration for a different provider must not see it.
const resultAfterSecondCall = migrateLegacyContinuation('codex');
expect(resultAfterSecondCall).toBeUndefined();
});
test('prefers existing current-provider slot over legacy', () => {
seedLegacy('legacy-value');
setContinuation('claude', 'claude-value');
const result = migrateLegacyContinuation('claude');
expect(result).toBe('claude-value');
expect(getContinuation('claude')).toBe('claude-value');
});
test('no legacy row — returns current provider value (possibly undefined)', () => {
expect(migrateLegacyContinuation('claude')).toBeUndefined();
setContinuation('codex', 'codex-value');
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
});
test('migration is idempotent on a second call (legacy already gone)', () => {
seedLegacy('once');
const first = migrateLegacyContinuation('claude');
expect(first).toBe('once');
const second = migrateLegacyContinuation('claude');
expect(second).toBe('once');
});
});
+12 -50
View File
@@ -2,20 +2,12 @@
* Persistent key/value state for the container. Lives in outbound.db
* (container-owned, already scoped per channel/thread).
*
* Primary use: remember each provider's opaque continuation id so the
* agent's conversation resumes across container restarts. Keyed per
* provider because continuations are provider-private a Claude
* conversation id means nothing to Codex and vice versa. Switching
* providers is therefore lossless: each provider's last thread stays
* on file and resumes cleanly if the user flips back.
* Primary use: remember the SDK session ID so the agent's conversation
* resumes across container restarts. Cleared by /clear.
*/
import { getOutboundDb } from './connection.js';
const LEGACY_KEY = 'sdk_session_id';
function continuationKey(providerName: string): string {
return `continuation:${providerName.toLowerCase()}`;
}
const SDK_SESSION_KEY = 'sdk_session_id';
function getValue(key: string): string | undefined {
const row = getOutboundDb()
@@ -26,7 +18,9 @@ function getValue(key: string): string | undefined {
function setValue(key: string, value: string): void {
getOutboundDb()
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.prepare(
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
)
.run(key, value, new Date().toISOString());
}
@@ -34,46 +28,14 @@ function deleteValue(key: string): void {
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
}
/**
* One-time migration of the pre-per-provider continuation row.
*
* Before this was keyed per provider, continuations lived under the
* single key `sdk_session_id`. On container start, if that legacy row
* exists and the current provider has no continuation of its own, adopt
* the legacy value into the current provider's slot (best-guess the
* legacy row was written by whatever provider ran last). The legacy row
* is always deleted so future provider flips never re-read a stale id
* through the wrong lens.
*
* Returns the continuation the caller should use at startup (either the
* current provider's existing value, the adopted legacy value, or
* undefined).
*/
export function migrateLegacyContinuation(providerName: string): string | undefined {
const legacy = getValue(LEGACY_KEY);
const currentKey = continuationKey(providerName);
const current = getValue(currentKey);
if (legacy === undefined) return current;
// Always drop the legacy row so no future provider reads it.
deleteValue(LEGACY_KEY);
// Prefer the current provider's own slot if one already exists.
if (current !== undefined) return current;
setValue(currentKey, legacy);
return legacy;
export function getStoredSessionId(): string | undefined {
return getValue(SDK_SESSION_KEY);
}
export function getContinuation(providerName: string): string | undefined {
return getValue(continuationKey(providerName));
export function setStoredSessionId(sessionId: string): void {
setValue(SDK_SESSION_KEY, sessionId);
}
export function setContinuation(providerName: string, id: string): void {
setValue(continuationKey(providerName), id);
}
export function clearContinuation(providerName: string): void {
deleteValue(continuationKey(providerName));
export function clearStoredSessionId(): void {
deleteValue(SDK_SESSION_KEY);
}
@@ -1,63 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js';
import { buildSystemPromptAddendum } from './destinations.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES (?, ?, 'channel', ?, ?, NULL)`,
)
.run(name, displayName, channelType, platformId);
}
describe('buildSystemPromptAddendum — multi-destination routing guidance', () => {
it('includes default-routing nudge when there are >1 destinations', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('from="name"');
expect(prompt).toContain('`casa`');
expect(prompt).toContain('`whatsapp-mg-17780`');
});
it('describes message wrapping for a single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('`casa`');
});
it('handles the no-destination case without crashing', () => {
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('no configured destinations');
expect(prompt).not.toContain('default to addressing');
});
it('includes default-routing and wrapping instructions for single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('`casa`');
});
});
+23 -36
View File
@@ -72,26 +72,8 @@ export function findByRouting(
return row ? rowToEntry(row) : undefined;
}
/**
* Generate the system-prompt addendum: agent identity + destination map.
*
* Identity is injected here (not in the shared CLAUDE.md) because it's
* per-agent-group and changes when the operator renames an agent, while
* the shared base is identical across all agents.
*/
export function buildSystemPromptAddendum(assistantName?: string): string {
const sections: string[] = [];
if (assistantName) {
sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n'));
}
sections.push(buildDestinationsSection());
return sections.join('\n\n');
}
function buildDestinationsSection(): string {
/** Generate the system-prompt addendum describing destinations and syntax. */
export function buildSystemPromptAddendum(): string {
const all = getAllDestinations();
if (all.length === 0) {
@@ -102,29 +84,34 @@ function buildDestinationsSection(): string {
].join('\n');
}
const lines = ['## Sending messages', ''];
// Single-destination shortcut: the agent just writes its response normally.
if (all.length === 1) {
const d = all[0];
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`Your destination is \`${d.name}\`${label}.`);
} else {
lines.push('You can send messages to the following destinations:', '');
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
}
return [
'## Sending messages',
'',
`Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`,
'',
'To mark something as scratchpad (logged but not sent), wrap it in `<internal>...</internal>`.',
'',
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.',
].join('\n');
}
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
}
lines.push('');
lines.push(
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
);
lines.push('To send a message, wrap it in a `<message to="name">...</message>` block.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
lines.push('');
lines.push(
'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `<message>` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").',
);
lines.push('');
lines.push(
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
);
return lines.join('\n');
}
@@ -1,167 +0,0 @@
/**
* v1-parity tests for formatter behavior.
*
* Port of src/v1/formatting.test.ts (at commit 27c5220, parent of the v1
* deletion commit 86becf8). Covers: context timezone header, reply_to +
* quoted_message rendering, XML escaping, and stripInternalTags.
*
* Timestamp-format assertions use `formatLocalTime()` output format, which
* is host locale-dependent for decorators (month abbr, "," separator) but
* stable for the numeric parts we assert on (hour, minute, year).
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
import { getPendingMessages } from './db/messages-in.js';
import { formatMessages, stripInternalTags } from './formatter.js';
import { TIMEZONE } from './timezone.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function insertMessage(
id: string,
kind: string,
content: object,
opts?: { timestamp?: string },
) {
const timestamp = opts?.timestamp ?? new Date().toISOString();
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES (?, ?, ?, 'pending', ?)`,
)
.run(id, kind, timestamp, JSON.stringify(content));
}
describe('context timezone header', () => {
it('prepends <context timezone="..."/> to formatted output', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' });
const result = formatMessages(getPendingMessages());
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
});
it('includes the header even when the message list is empty', () => {
const result = formatMessages([]);
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
});
it('header comes before the <messages> block', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
const result = formatMessages(getPendingMessages());
const ctxIdx = result.indexOf('<context');
const msgsIdx = result.indexOf('<messages>');
expect(ctxIdx).toBeGreaterThanOrEqual(0);
expect(msgsIdx).toBeGreaterThan(ctxIdx);
});
});
describe('timestamp formatting', () => {
it('renders time via formatLocalTime (user TZ)', () => {
// 2026-06-15T12:00:00Z — timezone-agnostic assertions (year is stable)
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T12:00:00.000Z' });
const result = formatMessages(getPendingMessages());
// formatLocalTime's format in en-US contains the year and a month abbrev
expect(result).toContain('2026');
expect(result).toMatch(/Jun/);
});
it('uses 12-hour AM/PM format', () => {
// 15:30 UTC — some hour will show with AM or PM depending on TZ
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T15:30:00.000Z' });
const result = formatMessages(getPendingMessages());
expect(result).toMatch(/(AM|PM)/);
});
});
describe('reply_to + quoted_message rendering', () => {
it('renders reply_to attribute and quoted_message when all fields present', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'Yes, on my way!',
replyTo: { id: '42', sender: 'Bob', text: 'Are you coming tonight?' },
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('reply_to="42"');
expect(result).toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>');
expect(result).toContain('Yes, on my way!</message>');
});
it('omits reply_to and quoted_message when no reply context', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'plain' });
const result = formatMessages(getPendingMessages());
expect(result).not.toContain('reply_to');
expect(result).not.toContain('quoted_message');
});
it('renders reply_to but omits quoted_message when original content is missing', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'ack',
replyTo: { id: '42', sender: 'Bob' }, // no text
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('reply_to="42"');
expect(result).not.toContain('quoted_message');
});
it('XML-escapes reply context', () => {
insertMessage('m1', 'chat', {
sender: 'Alice',
text: 'reply',
replyTo: { id: '1', sender: 'A & B', text: '<script>alert("xss")</script>' },
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('from="A &amp; B"');
expect(result).toContain('&lt;script&gt;');
expect(result).toContain('&quot;xss&quot;');
});
});
describe('XML escaping', () => {
it('escapes <, >, &, " in sender and body', () => {
insertMessage('m1', 'chat', {
sender: 'A & B <Co>',
text: '<script>alert("xss")</script>',
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('sender="A &amp; B &lt;Co&gt;"');
expect(result).toContain('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
});
describe('stripInternalTags', () => {
it('strips single-line internal tags and trims', () => {
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe('hello world');
});
it('strips multi-line internal tags', () => {
expect(stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world')).toBe(
'hello world',
);
});
it('strips multiple internal tag blocks', () => {
expect(stripInternalTags('<internal>a</internal>hello<internal>b</internal>')).toBe('hello');
});
it('returns empty string when input is only internal tags', () => {
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
});
it('returns input unchanged when there are no internal tags', () => {
expect(stripInternalTags('hello world')).toBe('hello world');
});
it('preserves content that surrounds internal tags', () => {
expect(stripInternalTags('<internal>thinking</internal>The answer is 42')).toBe(
'The answer is 42',
);
});
});
+34 -86
View File
@@ -1,6 +1,5 @@
import { findByRouting } from './destinations.js';
import type { MessageInRow } from './db/messages-in.js';
import { TIMEZONE, formatLocalTime } from './timezone.js';
/**
* Command categories for messages starting with '/'.
@@ -12,7 +11,7 @@ import { TIMEZONE, formatLocalTime } from './timezone.js';
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']);
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config']);
export interface CommandInfo {
category: CommandCategory;
@@ -55,29 +54,6 @@ export function categorizeMessage(msg: MessageInRow): CommandInfo {
return { category: 'passthrough', command, text, senderId };
}
/**
* Narrow check for /clear the only command the runner handles directly.
* All other command gating (filtered, admin) is done by the host router
* before messages reach the container.
*/
export function isClearCommand(msg: MessageInRow): boolean {
const content = parseContent(msg.content);
const text = (content.text || '').trim();
return text.toLowerCase().startsWith('/clear');
}
/**
* True for any chat that needs the outer loop's command path: /clear plus
* admin/passthrough slash commands the SDK can only dispatch when they are
* a query's first input. Used by the follow-up poller to bail out and let
* the outer loop reopen the query.
*/
export function isRunnerCommand(msg: MessageInRow): boolean {
if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') return false;
const cat = categorizeMessage(msg).category;
return cat === 'admin' || cat === 'passthrough';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractSenderId(msg: MessageInRow, content: any): string | null {
const raw: string | null = content?.senderId || content?.author?.userId || null;
@@ -116,19 +92,10 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
/**
* Format a batch of messages_in rows into a prompt string.
*
* Prepends a `<context timezone="<IANA>" />` header so the agent always knows
* what timezone it's in every timestamp it sees in message bodies is the
* user's local time, and every time it produces (schedules, suggests) should
* be interpreted as local time in that same zone. This header is v1 behavior
* (src/v1/router.ts:20-22); dropping it led to misinterpretations where the
* agent scheduled tasks for the wrong hour.
*
* Strips routing fields the agent never sees platform_id, channel_type, thread_id.
*/
export function formatMessages(messages: MessageInRow[]): string {
const header = `<context timezone="${escapeXml(TIMEZONE)}" />\n`;
if (messages.length === 0) return header;
if (messages.length === 0) return '';
// Group by kind
const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk');
@@ -151,7 +118,7 @@ export function formatMessages(messages: MessageInRow[]): string {
parts.push(...systemMessages.map(formatSystemMessage));
}
return header + parts.join('\n\n');
return parts.join('\n\n');
}
function formatChatMessages(messages: MessageInRow[]): string {
@@ -170,74 +137,55 @@ function formatChatMessages(messages: MessageInRow[]): string {
function formatSingleChat(msg: MessageInRow): string {
const content = parseContent(msg.content);
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
const time = formatLocalTime(msg.timestamp, TIMEZONE);
const time = formatTime(msg.timestamp);
const text = content.text || '';
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : '';
const replyPrefix = formatReplyContext(content.replyTo);
const attachmentsSuffix = formatAttachments(content.attachments);
const fromAttr = originAttr(msg);
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
}
/**
* Build a ` from="destination_name"` attribute string from a message's routing
* fields. Shared by all formatters so the agent always knows where a message
* originated critical for explicit addressing.
*/
function originAttr(msg: MessageInRow): string {
// Look up the destination name for the origin (reverse map lookup).
// If not found, fall back to a raw channel:platform_id marker so nothing
// gets silently dropped — this should only happen if the destination was
// removed between when the message was received and when it's being processed.
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
if (fromDest) return ` from="${escapeXml(fromDest.name)}"`;
if (msg.channel_type || msg.platform_id) {
return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`;
}
return '';
const fromAttr = fromDest
? ` from="${escapeXml(fromDest.name)}"`
: msg.channel_type || msg.platform_id
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
: '';
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${time}">${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
}
function formatTaskMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const from = originAttr(msg);
const time = formatLocalTime(msg.timestamp, TIMEZONE);
const parts: string[] = [];
const parts = ['[SCHEDULED TASK]'];
if (content.scriptOutput) {
parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), '');
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
}
parts.push('Instructions:', content.prompt || '');
return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
parts.push('', 'Instructions:', content.prompt || '');
return parts.join('\n');
}
function formatWebhookMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const source = content.source || 'unknown';
const event = content.event || 'unknown';
const from = originAttr(msg);
return `<webhook${from} source="${escapeXml(source)}" event="${escapeXml(event)}">${JSON.stringify(content.payload || content, null, 2)}</webhook>`;
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
}
function formatSystemMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const from = originAttr(msg);
return `<system_response${from} action="${escapeXml(content.action || 'unknown')}" status="${escapeXml(content.status || 'unknown')}">${JSON.stringify(content.result || null)}</system_response>`;
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
}
/**
* Render the quoted original inside the <message> body.
*
* Matches v1 format (src/v1/router.ts:10-18): `<quoted_message from="X">Y</quoted_message>`.
* Requires BOTH sender and text if only id is present the reply_to attribute
* on the parent <message> carries the link without an inline preview.
*
* No truncation here (v1 didn't truncate).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatReplyContext(replyTo: any): string {
if (!replyTo) return '';
const sender = replyTo.sender;
const text = replyTo.text;
if (!sender || !text) return '';
return `\n <quoted_message from="${escapeXml(sender)}">${escapeXml(text)}</quoted_message>\n`;
const sender = replyTo.sender || 'Unknown';
const text = replyTo.text || '';
const preview = text.length > 100 ? text.slice(0, 100) + '' : text;
return `\n<reply-to sender="${escapeXml(sender)}">${escapeXml(preview)}</reply-to>\n`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -265,15 +213,15 @@ function parseContent(json: string): any {
}
}
function formatTime(timestamp: string): string {
try {
const d = new Date(timestamp);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
} catch {
return timestamp;
}
}
function escapeXml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Strip `<internal>...</internal>` blocks from agent output, then trim.
* Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's
* own scratchpad/reasoning before a reply goes out over a channel.
*/
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
+42 -27
View File
@@ -4,8 +4,14 @@
* Runs inside a container. All IO goes through the session DB.
* No stdin, no stdout markers, no IPC files.
*
* Config is read from /workspace/agent/container.json (mounted RO).
* Only TZ and OneCLI networking vars come from env.
* Config:
* - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db)
* - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db)
* - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat)
* - AGENT_PROVIDER: any registered provider name (default: claude). The
* set of registered providers is whatever `providers/index.ts` imports.
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
* - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands
*
* Mount structure:
* /workspace/
@@ -13,19 +19,14 @@
* outbound.db container-owned session DB
* .heartbeat container touches for liveness detection
* outbox/ outbound files
* agent/ agent group folder (CLAUDE.md, container.json, working files)
* container.json per-group config (RO nested mount)
* global/ shared global memory (RO)
* /app/src/ shared agent-runner source (RO)
* /app/skills/ shared skills (RO)
* /home/node/.claude/ Claude SDK state + skill symlinks (RW)
* agent/ agent group folder (CLAUDE.md, skills, working files)
* .claude/ Claude SDK session data
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { loadConfig } from './config.js';
import { buildSystemPromptAddendum } from './destinations.js';
// Providers barrel — each enabled provider self-registers on import.
// Provider skills append imports to providers/index.ts.
@@ -40,18 +41,22 @@ function log(msg: string): void {
const CWD = '/workspace/agent';
async function main(): Promise<void> {
const config = loadConfig();
const providerName = config.provider.toLowerCase() as ProviderName;
const providerName = (process.env.AGENT_PROVIDER || 'claude').toLowerCase() as ProviderName;
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
const adminUserIds = new Set(
(process.env.NANOCLAW_ADMIN_USER_IDS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
log(`Starting v2 agent-runner (provider: ${providerName})`);
// Runtime-generated system-prompt addendum: agent identity (name) plus
// the live destinations map. Everything else (capabilities, per-module
// instructions, per-channel formatting) is loaded by Claude Code from
// /workspace/agent/CLAUDE.md — the composed entry imports the shared
// base (/app/CLAUDE.md) and each enabled module's fragment. Per-group
// memory lives in /workspace/agent/CLAUDE.local.md (auto-loaded).
const instructions = buildSystemPromptAddendum(config.assistantName || undefined);
// Destinations addendum is the only runtime-generated context we inject.
// Global CLAUDE.md is loaded by Claude Code from /workspace/agent/CLAUDE.md
// (which imports /workspace/global/CLAUDE.md via @-syntax) — no need to
// read it manually anymore.
const instructions = buildSystemPromptAddendum();
// Discover additional directories mounted at /workspace/extra/*
const additionalDirectories: string[] = [];
@@ -72,34 +77,44 @@ async function main(): Promise<void> {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.ts');
// Build MCP servers config: nanoclaw built-in + any from container.json
// Build MCP servers config: nanoclaw built-in + any additional from host
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
env: {
SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db',
SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db',
SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat',
},
},
};
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
mcpServers[name] = serverConfig;
log(`Additional MCP server: ${name} (${serverConfig.command})`);
// Merge additional MCP servers from host configuration
if (process.env.NANOCLAW_MCP_SERVERS) {
try {
const additional = JSON.parse(process.env.NANOCLAW_MCP_SERVERS) as Record<string, { command: string; args: string[]; env: Record<string, string> }>;
for (const [name, config] of Object.entries(additional)) {
mcpServers[name] = config;
log(`Additional MCP server: ${name} (${config.command})`);
}
} catch (e) {
log(`Failed to parse NANOCLAW_MCP_SERVERS: ${e}`);
}
}
const provider = createProvider(providerName, {
assistantName: config.assistantName || undefined,
assistantName,
mcpServers,
env: { ...process.env },
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
});
await runPollLoop({
provider,
providerName,
cwd: CWD,
systemContext: { instructions },
adminUserIds,
});
}
@@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { getPendingMessages } from './db/messages-in.js';
import { getContinuation, setContinuation } from './db/session-state.js';
import { MockProvider } from './providers/mock.js';
import { runPollLoop } from './poll-loop.js';
@@ -75,163 +74,6 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('should resolve thread_id per-destination, not from global routing', async () => {
// Seed a second destination
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
)
.run();
// Insert messages from each destination with distinct thread IDs
insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' });
insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' });
// Agent replies to both destinations
const provider = new MockProvider({}, () =>
'<message to="discord-test">reply-d</message><message to="slack-test">reply-s</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
controller.abort();
const out = getUndeliveredMessages();
const discordOut = out.find((m) => m.platform_id === 'chan-1');
const slackOut = out.find((m) => m.platform_id === 'chan-2');
expect(discordOut).toBeDefined();
expect(discordOut!.thread_id).toBe('discord-thread-1');
expect(discordOut!.in_reply_to).toBe('m-discord');
expect(slackOut).toBeDefined();
expect(slackOut!.thread_id).toBe('slack-thread-99');
expect(slackOut!.in_reply_to).toBe('m-slack');
await loopPromise.catch(() => {});
});
it('bare text produces no outbound messages (scratchpad only)', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' });
// Agent responds with bare text — no <message to="..."> wrapping
const provider = new MockProvider({}, () => 'I am thinking about this...');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
// Wait long enough for the poll loop to process
await sleep(1000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(0);
await loopPromise.catch(() => {});
});
it('unknown destination is dropped, valid destination is sent', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
// Only the valid destination should produce output
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('delivered');
expect(out[0].platform_id).toBe('chan-1');
await loopPromise.catch(() => {});
});
it('multiple <message> blocks each produce an outbound message', async () => {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
)
.run();
insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(2);
const discord = out.find((m) => m.platform_id === 'chan-1');
const slack = out.find((m) => m.platform_id === 'chan-2');
expect(discord).toBeDefined();
expect(JSON.parse(discord!.content).text).toBe('for discord');
expect(slack).toBeDefined();
expect(JSON.parse(slack!.content).text).toBe('for slack');
await loopPromise.catch(() => {});
});
it('sends null thread_id when no prior inbound from destination', async () => {
// Seed a second destination that has NO inbound messages
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`,
)
.run();
// Only insert a message from discord — slack-new has never sent anything
insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' });
const provider = new MockProvider({}, () => '<message to="slack-new">hello slack</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('chan-new');
expect(out[0].thread_id).toBeNull();
await loopPromise.catch(() => {});
});
it('resolves most recent thread_id when destination has multiple inbound messages', async () => {
// Two messages from same destination, different threads
insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' });
insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' });
const provider = new MockProvider({}, () => '<message to="discord-test">reply</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].thread_id).toBe('thread-new');
expect(out[0].in_reply_to).toBe('m-new');
await loopPromise.catch(() => {});
});
it('should process messages arriving after loop starts', async () => {
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
const controller = new AbortController();
@@ -249,52 +91,6 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('internal tags between message blocks are stripped from scratchpad', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('answer');
await loopPromise.catch(() => {});
});
it('handles mixed task + chat batch with correct origin metadata', async () => {
// Seed destination for routing lookup
insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
// Task with same routing — simulates a scheduled task in a channel session
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ prompt: 'daily check' }));
const provider = new MockProvider({}, () => '<message to="discord-test">done</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('chan-1');
await loopPromise.catch(() => {});
});
});
// Helper: run poll loop until aborted or timeout
@@ -302,7 +98,6 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
return Promise.race([
runPollLoop({
provider,
providerName: 'mock',
cwd: '/tmp',
}),
new Promise<void>((_, reject) => {
@@ -323,142 +118,3 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('poll loop — provider error recovery', () => {
it('writes error to outbound and continues loop on provider throw', async () => {
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new ThrowingProvider('API rate limit exceeded');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded');
// Input message should be marked completed despite the error
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
describe('poll loop — stale session recovery', () => {
it('clears continuation when provider reports session invalid', async () => {
// Pre-seed a continuation so the local variable in runPollLoop is set.
// Without this, the `if (continuation && isSessionInvalid)` check skips.
setContinuation('mock', 'pre-existing-session');
insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new InvalidSessionProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
// Error was written to outbound
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
// Continuation was cleared (isSessionInvalid returned true)
expect(getContinuation('mock')).toBeUndefined();
await loopPromise.catch(() => {});
});
});
describe('poll loop — /clear command', () => {
it('clears session, writes confirmation, skips query', async () => {
// Seed a continuation so we can verify it gets cleared
setContinuation('mock', 'existing-session-id');
expect(getContinuation('mock')).toBe('existing-session-id');
// Insert a /clear command
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ text: '/clear' }));
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('Session cleared.');
// Continuation was cleared
expect(getContinuation('mock')).toBeUndefined();
// Command message was completed
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that throws on every query, simulating API failures.
*/
class ThrowingProvider {
readonly supportsNativeSlashCommands = false;
private errorMessage: string;
constructor(errorMessage: string) {
this.errorMessage = errorMessage;
}
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const errorMessage = this.errorMessage;
return {
push() {},
end() {},
abort() {},
events: (async function* () {
throw new Error(errorMessage);
})(),
};
}
}
/**
* Provider that throws with an error that triggers isSessionInvalid.
* First emits an init event (setting continuation), then throws.
*/
class InvalidSessionProvider {
readonly supportsNativeSlashCommands = false;
isSessionInvalid(): boolean {
return true;
}
query(_input: { prompt: string; cwd: string }) {
return {
push() {},
end() {},
abort() {},
events: (async function* () {
yield { type: 'init' as const, continuation: 'doomed-session' };
throw new Error('session not found');
})(),
};
}
}
@@ -1,26 +0,0 @@
## Companion and collaborator agents (`create_agent`)
`mcp__nanoclaw__create_agent({ name, instructions })` spins up a new long-lived agent and wires it as a destination — bidirectional, so you can send it tasks and it can message you back.
### How it works
- Creates a new agent with its own container, workspace, and session. Your `instructions` string seeds the agent's `CLAUDE.local.md` — its starting role and personality.
- The agent's `name` becomes a destination on both sides: you address it via `send_message({ to: "<name>", ... })`, and its replies arrive as inbound messages with `from="<name>"`.
- Each agent has its own persistent workspace under `groups/<folder>/` — memory, conversation history, and notes all survive across sessions. This is a full standalone agent, not a stateless sub-query.
- **Fire-and-forget:** the call returns immediately without waiting for the agent to confirm it's ready. Messages you send will queue until it's up.
### When to use
- **Companions** — a long-running presence that accumulates context over time: a `Researcher` tracking an ongoing inquiry, a `Calendar` agent managing scheduling, an assistant that knows your preferences and history.
- **Collaborators** — a parallel specialist that works independently and reports back: a `Builder` handling code edits while you stay in conversation, a `Reviewer` running checks in the background.
The right frame is: does this agent need its own memory and context that builds over time, or does it need to work independently without blocking your turn? Either is a good reason to spawn one.
### When NOT to use
- **One-off lookups or short tasks** — use the SDK `Agent` tool instead. It's stateless, spins up and completes in one shot, and leaves no persistent footprint.
- **Work that finishes before the user's next message** — agents persist indefinitely. Don't create one for something you could do inline.
### Writing good `instructions`
Cover: the agent's role, who it takes tasks from (you, by name), how it should report back (on completion only? with milestones for long work?), and any domain-specific rules. Don't restate NanoClaw base behavior — the shared base is already loaded on the agent's end.
@@ -1,83 +0,0 @@
## Admin CLI (`ncl`)
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
### Usage
```
ncl <resource> <verb> [--flags]
ncl <resource> help
ncl help
```
### Scope
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
### Resources
Run `ncl help` for the full list. Common resources:
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
### When to use
- **Looking up your own config**`ncl groups get` or `ncl groups config get` to see your container config.
- **Restarting your container**`ncl groups restart` (with optional `--rebuild` and `--message`).
- **Checking who's in your group**`ncl members list`.
- **Seeing your destinations**`ncl destinations list`.
- **Answering questions about the system** — query `ncl` rather than guessing.
### Access rules
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
### Approval flow
Write commands require admin approval. Here's what happens:
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
4. Once the admin responds:
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
- **Rejected:** you get a system message saying the request was rejected.
You don't need to poll or retry — the result arrives automatically.
### Examples
```bash
# Read commands (no approval needed)
ncl groups get
ncl groups config get
ncl sessions list
ncl destinations list
ncl members list
# Write commands (approval required)
ncl groups restart
ncl groups restart --rebuild --message "Config updated."
ncl groups config update --model claude-sonnet-4-5-20250514
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
ncl groups config add-package --npm some-package
ncl members add --user telegram:jane
```
### Important
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
### Tips
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.
@@ -1,27 +0,0 @@
## Sending messages
**Every response** must be wrapped in `<message to="name">...</message>` blocks — even if you only have one destination. Bare text outside of `<message>` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names.
### Mid-turn updates (`send_message`)
Use the `mcp__nanoclaw__send_message` tool to send a message while you're still working (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
- **Short turn (≤2 quick tool calls):** Don't narrate. Output any response.
- **Longer turn (multiple tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it, checking the logs now") so the user knows you got the message.
- **Long-running turns (long-running tasks with many stages):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
**Outcomes, not play-by-play.** When the turn is done, the final message should be about the result, not a transcript of what you did.
### Sending files (`send_file`)
Use `mcp__nanoclaw__send_file({ path, text?, filename?, to? })` to deliver a file from your workspace. `path` is absolute or relative to `/workspace/agent/`; `filename` overrides the display name shown in chat (defaults to the file's basename); `text` is an optional accompanying message. Use this for artifacts you produce (charts, PDFs, generated images, reports) rather than dumping contents into chat.
### Reacting to messages (`add_reaction`)
Use `mcp__nanoclaw__add_reaction({ messageId, emoji })` to react to a specific inbound message by its `#N` id — pass `messageId` as an integer (e.g. `22`, not `"22"`). Good for lightweight acknowledgment (`eyes` = seen, `white_check_mark` = done) when a full reply would be noise. `emoji` is the shortcode name (e.g. `thumbs_up`, `heart`), not the raw character.
### Internal thoughts
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent.
@@ -1,50 +0,0 @@
/**
* Tests for the core MCP tools' interaction with the per-batch routing
* context. The agent-runner sets a current `inReplyTo` at the top of each
* batch in poll-loop, and outbound writes from MCP tools (send_message,
* send_file) must pick it up so a2a return-path routing on the host can
* correlate replies back to the originating session.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
import { getUndeliveredMessages } from '../db/messages-out.js';
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
import { sendMessage } from './core.js';
beforeEach(() => {
initTestSessionDb();
// Seed a peer agent destination
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`,
)
.run();
});
afterEach(() => {
clearCurrentInReplyTo();
closeSessionDb();
});
describe('send_message MCP tool — in_reply_to plumbing', () => {
it('stamps current batch in_reply_to on outbound rows', async () => {
setCurrentInReplyTo('inbound-msg-1');
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBe('inbound-msg-1');
});
it('writes null when no batch is active', async () => {
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More