Merge branch 'main' into pr/add-signal-v2
* main: (185 commits) chore: bump version to 2.0.13 chore: bump version to 2.0.12 docs: update token count to 132k tokens · 66% of context window fix(register): wire channels with correct engage fields, skip prefix for native IDs skill(add-gcal-tool): OneCLI-native Google Calendar MCP tool docs(providers): note that container.json provider is what the runner reads Revert src changes; skill applies them at install time chore: bump version to 2.0.11 fix(workflows): label PRs from forks that follow the contributing template chore(format): apply prettier to chat-sdk-bridge.ts chore(setup): minimize verify diff test(setup): cover CLI-only verify status fix(setup): accept CLI-only verify success detect setup auth ping failures fix(agent-route): reject unsafe attachment filenames to prevent path traversal fix(agent-route): forward file attachments between agents refactor(session-state): key continuations per provider to survive provider switches skill(add-gmail-tool): OneCLI-native Gmail MCP tool fix(setup): register step uses engage_mode columns dropped by migration 010 docs: update token count to 130k tokens · 65% of context window ...
This commit is contained in:
@@ -1,41 +1,5 @@
|
||||
{
|
||||
"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*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
243
.claude/skills/add-atomic-chat-tool/SKILL.md
Normal file
243
.claude/skills/add-atomic-chat-tool/SKILL.md
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
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
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 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.
|
||||
229
.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts
Normal file
229
.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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.0–2.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
.claude/skills/add-codex/SKILL.md
Normal file
161
.claude/skills/add-codex/SKILL.md
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,135 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,12 +1,11 @@
|
||||
---
|
||||
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). Uses a 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). Local HTTP bridge — no bot token or external service needed.
|
||||
---
|
||||
|
||||
# Add Emacs Channel
|
||||
|
||||
This skill adds Emacs support to NanoClaw, then walks through interactive setup.
|
||||
Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
|
||||
## What you can do with this
|
||||
|
||||
@@ -15,95 +14,99 @@ Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
- **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")
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
## Install
|
||||
|
||||
### Check if already applied
|
||||
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 `src/channels/emacs.ts` exists:
|
||||
### 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
|
||||
|
||||
```bash
|
||||
test -f src/channels/emacs.ts && echo "already applied" || echo "not applied"
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
If it exists, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure the upstream remote
|
||||
### 2. Copy the adapter and Lisp client
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
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
|
||||
```
|
||||
|
||||
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
|
||||
add it:
|
||||
### 3. Append the self-registration import
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './emacs.js';
|
||||
```
|
||||
|
||||
### 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
|
||||
### 4. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/emacs.test.ts
|
||||
```
|
||||
|
||||
Build must be clean and tests must pass before proceeding.
|
||||
No npm package to install — the adapter uses only Node builtins (`http`).
|
||||
|
||||
## Phase 3: Setup
|
||||
## Enable
|
||||
|
||||
### Configure environment (optional)
|
||||
|
||||
The channel works out of the box with defaults. Add to `.env` only if you need non-defaults:
|
||||
The adapter is gated by `EMACS_ENABLED` so the HTTP port isn't opened on hosts that aren't running Emacs. Add to `.env`:
|
||||
|
||||
```bash
|
||||
EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use
|
||||
EMACS_AUTH_TOKEN=<random> # optional — locks the endpoint to Emacs only
|
||||
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
|
||||
```
|
||||
|
||||
If you change or add values, sync to the container environment:
|
||||
Generate an auth token (recommended even on single-user machines — prevents other local processes from poking the endpoint):
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
|
||||
```
|
||||
|
||||
### Configure Emacs
|
||||
## Wire the channel
|
||||
|
||||
The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed.
|
||||
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.
|
||||
|
||||
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`):
|
||||
|
||||
@@ -117,7 +120,7 @@ AskUserQuestion: Which Emacs distribution are you using?
|
||||
:desc "Send org" "o" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Then reload: `M-x doom/reload`
|
||||
Reload: `M-x doom/reload`
|
||||
|
||||
**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`:
|
||||
|
||||
@@ -129,9 +132,9 @@ Then reload: `M-x doom/reload`
|
||||
(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
|
||||
**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`):
|
||||
**Vanilla Emacs** — add to `~/.emacs.d/init.el`:
|
||||
|
||||
```elisp
|
||||
;; NanoClaw — personal AI assistant channel
|
||||
@@ -141,61 +144,75 @@ Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
(global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Then reload: `M-x eval-buffer` or restart Emacs.
|
||||
Reload: `M-x eval-buffer` or restart Emacs.
|
||||
|
||||
If `EMACS_AUTH_TOKEN` was set, also add (any distribution):
|
||||
Replace `~/src/nanoclaw/emacs/nanoclaw.el` with your actual NanoClaw checkout path.
|
||||
|
||||
If `EMACS_AUTH_TOKEN` is set, also add (any distribution):
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-auth-token "<your-token>")
|
||||
```
|
||||
|
||||
If `EMACS_CHANNEL_PORT` was changed from the default, also add:
|
||||
If you changed `EMACS_CHANNEL_PORT` from the default:
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-port <your-port>)
|
||||
```
|
||||
|
||||
### Restart NanoClaw
|
||||
## Restart NanoClaw
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
## Verify
|
||||
|
||||
### Test the HTTP endpoint
|
||||
### HTTP endpoint
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8766/api/messages?since=0"
|
||||
curl -s http://localhost:8766/api/messages?since=0
|
||||
```
|
||||
|
||||
Expected: `{"messages":[]}`
|
||||
|
||||
If you set `EMACS_AUTH_TOKEN`:
|
||||
Expected: `{"messages":[]}`. With an 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
|
||||
```
|
||||
|
||||
### Test from Emacs
|
||||
### 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 `RET`
|
||||
> 3. A response from Andy should appear within a few seconds
|
||||
> 2. Type a message and press `C-c C-c` to send (RET inserts newlines)
|
||||
> 3. A response 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`
|
||||
|
||||
### Check logs if needed
|
||||
### Log line
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup.
|
||||
|
||||
Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent.
|
||||
## 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -205,66 +222,53 @@ Look for `Emacs channel listening` at startup and `Emacs message received` when
|
||||
Error: listen EADDRINUSE: address already in use :::8766
|
||||
```
|
||||
|
||||
Either a stale NanoClaw process is running, or 8766 is taken by another app.
|
||||
|
||||
Find and kill the stale process:
|
||||
Either a stale NanoClaw is running or another app has the port. Kill stale process or change port:
|
||||
|
||||
```bash
|
||||
lsof -ti :8766 | xargs kill -9
|
||||
# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port)
|
||||
```
|
||||
|
||||
Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config.
|
||||
### 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"
|
||||
```
|
||||
|
||||
### No response from agent
|
||||
|
||||
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`
|
||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
2. Messaging group wired: `sqlite3 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`
|
||||
|
||||
If the group is not registered, it will be created automatically on the next NanoClaw restart.
|
||||
If no messaging group row exists, run the `register` command above.
|
||||
|
||||
### Auth token mismatch (401 Unauthorized)
|
||||
|
||||
Verify the token in Emacs matches `.env`:
|
||||
|
||||
```elisp
|
||||
;; M-x describe-variable RET nanoclaw-auth-token RET
|
||||
M-x describe-variable RET nanoclaw-auth-token RET
|
||||
```
|
||||
|
||||
Must exactly match `EMACS_AUTH_TOKEN` in `.env`.
|
||||
Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too:
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-auth-token nil)
|
||||
```
|
||||
|
||||
### 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 conversion handles:
|
||||
The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax:
|
||||
|
||||
| Markdown | Org-mode |
|
||||
|----------|----------|
|
||||
@@ -274,16 +278,19 @@ output standard markdown — **not** org-mode syntax. The conversion handles:
|
||||
| `` `code` `` | `~code~` |
|
||||
| ` ```lang ` | `#+begin_src lang` |
|
||||
|
||||
If an agent outputs org-mode directly, bold/italic/etc. will be double-converted
|
||||
and render incorrectly.
|
||||
If an agent outputs org-mode directly, markers get double-converted and render incorrectly.
|
||||
|
||||
## Removal
|
||||
|
||||
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
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
|
||||
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)
|
||||
# Remove the NanoClaw block from your Emacs config
|
||||
# Optionally clean up the messaging group:
|
||||
sqlite3 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';"
|
||||
```
|
||||
|
||||
210
.claude/skills/add-gcal-tool/SKILL.md
Normal file
210
.claude/skills/add-gcal-tool/SKILL.md
Normal file
@@ -0,0 +1,210 @@
|
||||
---
|
||||
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 && \
|
||||
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
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}"
|
||||
```
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group, merge into `groups/<folder>/container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.calendar-mcp",
|
||||
"containerPath": ".calendar-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
|
||||
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
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 2–3s 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" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
|
||||
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 nanoclaw`.
|
||||
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
|
||||
|
||||
## 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.
|
||||
229
.claude/skills/add-gmail-tool/SKILL.md
Normal file
229
.claude/skills/add-gmail-tool/SKILL.md
Normal file
@@ -0,0 +1,229 @@
|
||||
---
|
||||
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:
|
||||
|
||||
```bash
|
||||
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
|
||||
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
|
||||
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`.
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
|
||||
|
||||
### 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), edit `groups/<folder>/container.json` to add the mount and MCP server.
|
||||
|
||||
Merge these into the group's `container.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "/home/<user>/.gmail-mcp",
|
||||
"containerPath": ".gmail-mcp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
|
||||
|
||||
**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.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
## 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" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
|
||||
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
|
||||
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 nanoclaw`.
|
||||
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`.
|
||||
|
||||
## 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/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
|
||||
- **Related PRs:** [#1810](https://github.com/qwibitai/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.
|
||||
@@ -1,236 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -47,7 +47,29 @@ import './matrix.js';
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
### 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
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
@@ -55,25 +77,60 @@ pnpm run build
|
||||
|
||||
## Credentials
|
||||
|
||||
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`)
|
||||
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
|
||||
|
||||
### Configure environment
|
||||
### Create a bot account
|
||||
|
||||
Add to `.env`:
|
||||
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":"..."}'
|
||||
```
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL=https://matrix.org
|
||||
MATRIX_ACCESS_TOKEN=your-access-token
|
||||
MATRIX_USER_ID=@botuser:matrix.org
|
||||
MATRIX_BOT_USERNAME=botuser
|
||||
MATRIX_USER_ID=@andybot:matrix.org
|
||||
MATRIX_BOT_USERNAME=Andy
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
### 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
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -85,7 +142,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**: 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**: 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`.
|
||||
- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability)
|
||||
- **typical-use**: Interactive chat — rooms or direct messages
|
||||
- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account).
|
||||
- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts.
|
||||
|
||||
179
.claude/skills/add-ollama-provider/SKILL.md
Normal file
179
.claude/skills/add-ollama-provider/SKILL.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
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: `sqlite3 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=$(sqlite3 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
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
pnpm run build
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 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 10–30s).
|
||||
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.
|
||||
@@ -60,10 +60,10 @@ import './opencode.js';
|
||||
|
||||
### 4. Add the agent-runner dependency
|
||||
|
||||
Pinned. Bump deliberately, not with `bun update`.
|
||||
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.
|
||||
|
||||
```bash
|
||||
cd container/agent-runner && bun add @opencode-ai/sdk@1.4.3 && cd -
|
||||
cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd -
|
||||
```
|
||||
|
||||
### 5. Add `opencode-ai` to the container Dockerfile
|
||||
@@ -73,9 +73,11 @@ 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=latest
|
||||
ARG OPENCODE_VERSION=1.4.17
|
||||
```
|
||||
|
||||
> **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
|
||||
@@ -94,6 +96,25 @@ 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)
|
||||
@@ -102,35 +123,62 @@ 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` (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.
|
||||
- `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`).
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
```bash
|
||||
# Find the agent id and secret id, then:
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
||||
```
|
||||
|
||||
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
|
||||
|
||||
#### 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}"
|
||||
```
|
||||
|
||||
#### 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
|
||||
```
|
||||
|
||||
#### Example: Anthropic via existing proxy env
|
||||
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}"
|
||||
```
|
||||
|
||||
When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container (proxy + placeholder key pattern unchanged).
|
||||
#### 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.
|
||||
|
||||
```env
|
||||
OPENCODE_PROVIDER=anthropic
|
||||
OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
#### Example: only a main model
|
||||
|
||||
```env
|
||||
OPENCODE_PROVIDER=openrouter
|
||||
OPENCODE_MODEL=openrouter/google/gemini-2.5-pro-preview
|
||||
OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
```
|
||||
|
||||
#### OpenCode Zen (`x-api-key`, not Bearer)
|
||||
@@ -142,13 +190,9 @@ 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
|
||||
```
|
||||
|
||||
@@ -162,18 +206,16 @@ 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
|
||||
|
||||
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).
|
||||
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'`.
|
||||
|
||||
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 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.
|
||||
- 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.
|
||||
- **`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
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
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
.claude/skills/add-signal/REMOVE.md
Normal file
13
.claude/skills/add-signal/REMOVE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 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`.)
|
||||
148
.claude/skills/add-signal/SKILL.md
Normal file
148
.claude/skills/add-signal/SKILL.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
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, no npm deps — only Node.js builtins.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
`signal-cli` installed and a Signal account linked:
|
||||
|
||||
- macOS: `brew install signal-cli`
|
||||
- Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases)
|
||||
- Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions)
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Signal adapter and its tests in from the `channels` branch.
|
||||
|
||||
### 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 (`node:net`, `node:child_process`, `node:fs`).
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 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. Signal is direct-addressable — your phone number is the platform ID.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `signal`
|
||||
- **terminology**: Signal has "chats" (1:1 DMs) and "groups."
|
||||
- **how-to-find-id**: DMs use your phone number (e.g. `+15555550123`). Groups use `group:<groupId>` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups`.
|
||||
- **supports-threads**: no
|
||||
- **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 be separate.
|
||||
|
||||
### 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 are 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: `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='signal'"`
|
||||
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
|
||||
### Lost connection mid-session
|
||||
|
||||
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped us. There's no auto-reconnect yet — restart the service to re-establish.
|
||||
5
.claude/skills/add-signal/VERIFY.md
Normal file
5
.claude/skills/add-signal/VERIFY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 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`.
|
||||
@@ -1,384 +0,0 @@
|
||||
---
|
||||
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,148 +0,0 @@
|
||||
---
|
||||
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.
|
||||
49
.claude/skills/add-wechat/REMOVE.md
Normal file
49
.claude/skills/add-wechat/REMOVE.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
170
.claude/skills/add-wechat/SKILL.md
Normal file
170
.claude/skills/add-wechat/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
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:
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # 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.
|
||||
172
.claude/skills/add-wechat/scripts/wire-dm.ts
Normal file
172
.claude/skills/add-wechat/scripts/wire-dm.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
#!/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,137 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -87,18 +87,17 @@ 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. Resolves the session (creates `inbound.db` / `outbound.db`).
|
||||
6. Writes a `kind: 'chat'`, `sender: 'system'` welcome message into `inbound.db`.
|
||||
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.
|
||||
|
||||
Show the script's output to the user.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Do not tail the log or poll in a sleep loop. Ask the user in plain text:
|
||||
|
||||
> 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).
|
||||
> The welcome DM should arrive shortly. 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.
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ 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. 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`
|
||||
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,137 +0,0 @@
|
||||
---
|
||||
name: new-setup
|
||||
description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent.
|
||||
allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm run chat *) Bash(brew install *) Bash(curl -fsSL https://get.docker.com | sh) Bash(sudo usermod -aG docker *) Bash(open -a Docker) Bash(sudo systemctl start docker)
|
||||
---
|
||||
|
||||
# NanoClaw bare-minimum setup
|
||||
|
||||
Purpose of this skill is to take any user — technical or not — from zero to a two-way chat with an agent in the fewest steps possible. Done means a running NanoClaw instance with at least one agent reachable via the CLI channel.
|
||||
|
||||
Only run the steps strictly required for the NanoClaw process to start and respond to the user end-to-end. Everything else is deferred to post-setup skills.
|
||||
|
||||
Before each step, narrate to the user in your own words what's about to happen — one short, friendly sentence, no jargon. Don't read a scripted line; use the step context below to speak naturally.
|
||||
|
||||
Each step is invoked as `pnpm exec tsx setup/index.ts --step <name>` and emits a structured status block Claude parses to decide what to do next.
|
||||
|
||||
Start with a probe: a single upfront scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is pure bash (`setup/probe.sh`) with no external deps so it runs correctly before Node has been installed.
|
||||
|
||||
## Current state
|
||||
|
||||
!`bash setup/probe.sh`
|
||||
|
||||
## Flow
|
||||
|
||||
Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. The probe always returns a real snapshot — there is no "node not installed" fallback; `HOST_DEPS=missing` is how you know Node/pnpm haven't been bootstrapped yet.
|
||||
|
||||
## Ordering and parallelism
|
||||
|
||||
Run steps sequentially by default: invoke the step, wait for its status block, act on the result, move to the next.
|
||||
|
||||
One permitted parallelism:
|
||||
|
||||
- **Step 2 (container image build) and step 3 (OneCLI install)** are independent — they may start together in the background.
|
||||
- **Step 4 (auth) must NOT start until step 3 has completed.** Auth writes the secret into the OneCLI vault; if OneCLI isn't installed and healthy yet, the user gets asked for a credential the system can't store. Do not open an `AskUserQuestion` for step 4 while OneCLI is still installing.
|
||||
- Step 2's image build may continue running past step 4 — the image isn't consumed until step 6 (first CLI agent). Join before step 6.
|
||||
|
||||
### 1. Node bootstrap
|
||||
|
||||
Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place.
|
||||
|
||||
If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail:
|
||||
|
||||
- macOS: `brew install node@22`
|
||||
- Linux / WSL: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`
|
||||
|
||||
Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet.
|
||||
|
||||
Parse the status block:
|
||||
|
||||
- `NODE_OK=false` → Node install didn't take effect (PATH issue, keg-only formula, etc.). Investigate `logs/setup.log`, resolve, re-run.
|
||||
- `DEPS_OK=false` or `NATIVE_OK=false` → Read `logs/setup.log`, fix, re-run.
|
||||
|
||||
> **Loose command:** `bash setup.sh`. Justification: pre-Node bootstrap. Can't call the Node-based dispatcher before Node and `pnpm install` are in place.
|
||||
|
||||
### 2. Docker
|
||||
|
||||
Check probe results and skip if `DOCKER=running` AND `IMAGE_PRESENT=true`.
|
||||
|
||||
**Runtime:**
|
||||
- `DOCKER=not_found` → Docker itself is missing — install it so agent containers have an isolated place to run.
|
||||
- macOS: `brew install --cask docker && open -a Docker`
|
||||
- Linux: `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER` (tell user they may need to log out/in for group membership)
|
||||
- `DOCKER=installed_not_running` → Docker is installed but the daemon is down — start it.
|
||||
- macOS: `open -a Docker`
|
||||
- Linux: `sudo systemctl start docker`
|
||||
|
||||
Wait ~15s after either, then proceed.
|
||||
|
||||
> **Loose commands:** Docker install/start. Justification: platform-specific package-manager invocations. Wrapping them in a `--step` would just move the same branching into TypeScript with no added value.
|
||||
|
||||
**Image (run if `IMAGE_PRESENT=false`):** build the agent container image — takes a few minutes the first time, one-off cost.
|
||||
|
||||
`pnpm exec tsx setup/index.ts --step container -- --runtime docker`
|
||||
|
||||
### 3. OneCLI
|
||||
|
||||
Check probe results and skip if `ONECLI_STATUS=healthy`.
|
||||
|
||||
OneCLI is the local vault that holds API keys and only releases them to agents when they need them.
|
||||
|
||||
`pnpm exec tsx setup/index.ts --step onecli`
|
||||
|
||||
### 4. Anthropic credential
|
||||
|
||||
Check probe results and skip if `ANTHROPIC_SECRET=true`.
|
||||
|
||||
The credential never travels through chat — the user generates it, registers it with OneCLI themselves, and the skill verifies.
|
||||
|
||||
**4a. Pick the source.** `AskUserQuestion`:
|
||||
|
||||
1. **Claude subscription (Pro/Max)** — "Generate a token via `claude setup-token` in another terminal."
|
||||
2. **Anthropic API key** — "Use a pay-per-use key from console.anthropic.com/settings/keys."
|
||||
|
||||
**4b. Wait for the user to obtain the credential.** For subscription, have them run `claude setup-token` in another terminal. For API key, point them to the console URL above. Either way, they keep the token — just confirm when they have it.
|
||||
|
||||
**4c. Pick the registration path.** `AskUserQuestion` — substitute `${ONECLI_URL}` from the probe (or `.env`):
|
||||
|
||||
1. **Dashboard** — "Open ${ONECLI_URL} in a browser; add a secret of type `anthropic`, value = the token, host-pattern `api.anthropic.com`."
|
||||
2. **CLI** — "Run in another terminal: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
|
||||
|
||||
Wait for the user's confirmation. If their reply happens to include a token (starts with `sk-ant-`), register it for them: `pnpm exec tsx setup/index.ts --step auth -- --create --value <TOKEN>`.
|
||||
|
||||
**4d. Verify.**
|
||||
|
||||
`pnpm exec tsx setup/index.ts --step auth -- --check`
|
||||
|
||||
If `ANTHROPIC_OK=false`, the secret isn't there yet — ask them to retry, then re-check.
|
||||
|
||||
### 5. Service
|
||||
|
||||
Check probe results and skip if `SERVICE_STATUS=running`.
|
||||
|
||||
Start the NanoClaw background service — it relays messages between the user and the agent.
|
||||
|
||||
`pnpm exec tsx setup/index.ts --step service`
|
||||
|
||||
### 6. First CLI agent
|
||||
|
||||
If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image.
|
||||
|
||||
Create the first agent and wire it to the CLI channel. Ask the user "What should I call you?" first — default the offered value to `INFERRED_DISPLAY_NAME` from the probe.
|
||||
|
||||
`pnpm exec tsx setup/index.ts --step cli-agent -- --display-name "<name>"`
|
||||
|
||||
### 7. First chat
|
||||
|
||||
Everything's ready — send the first message to the agent.
|
||||
|
||||
`pnpm run chat hi`
|
||||
|
||||
The agent should reply within ~60s (first container spin-up is slowest). If no reply, tail `logs/nanoclaw.log`.
|
||||
|
||||
> **Loose command:** `pnpm run chat hi`. Justification: this is the command the user will keep using after setup. Hiding it behind a `--step` would force them to memorize a second way to do the same thing.
|
||||
|
||||
## If anything fails
|
||||
|
||||
Any step that reports `STATUS: failed` in its status block: read `logs/setup.log`, diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving.
|
||||
@@ -1,349 +1,10 @@
|
||||
---
|
||||
name: setup
|
||||
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.
|
||||
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.
|
||||
---
|
||||
|
||||
# NanoClaw Setup
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
|
||||
```
|
||||
|
||||
## 1. Bootstrap (Node.js + Dependencies)
|
||||
|
||||
Run `bash setup.sh` and 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
|
||||
|
||||
Run `pnpm exec tsx setup/index.ts --step environment` and 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
|
||||
|
||||
Run `pnpm exec tsx setup/index.ts --step timezone` and 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.
|
||||
If they hit an error partway through, it will offer Claude-assisted recovery inline — no need to come back here.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
"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*)"
|
||||
]
|
||||
@@ -43,7 +43,6 @@ 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`
|
||||
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,152 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
Reference in New Issue
Block a user