Full Code of qwibitai/nanoclaw for AI

main 91f17a11b265 cached
134 files
761.8 KB
200.6k tokens
305 symbols
1 requests
Download .txt
Showing preview only (802K chars total). Download the full file or copy to clipboard to get everything.
Repository: qwibitai/nanoclaw
Branch: main
Commit: 91f17a11b265
Files: 134
Total size: 761.8 KB

Directory structure:
gitextract_5jpreexc/

├── .claude/
│   ├── settings.json
│   └── skills/
│       ├── add-compact/
│       │   └── SKILL.md
│       ├── add-discord/
│       │   └── SKILL.md
│       ├── add-gmail/
│       │   └── SKILL.md
│       ├── add-image-vision/
│       │   └── SKILL.md
│       ├── add-ollama-tool/
│       │   └── SKILL.md
│       ├── add-parallel/
│       │   └── SKILL.md
│       ├── add-pdf-reader/
│       │   └── SKILL.md
│       ├── add-reactions/
│       │   └── SKILL.md
│       ├── add-slack/
│       │   └── SKILL.md
│       ├── add-telegram/
│       │   └── SKILL.md
│       ├── add-telegram-swarm/
│       │   └── SKILL.md
│       ├── add-voice-transcription/
│       │   └── SKILL.md
│       ├── add-whatsapp/
│       │   └── SKILL.md
│       ├── convert-to-apple-container/
│       │   └── SKILL.md
│       ├── customize/
│       │   └── SKILL.md
│       ├── debug/
│       │   └── SKILL.md
│       ├── get-qodo-rules/
│       │   ├── SKILL.md
│       │   └── references/
│       │       ├── output-format.md
│       │       ├── pagination.md
│       │       └── repository-scope.md
│       ├── qodo-pr-resolver/
│       │   ├── SKILL.md
│       │   └── resources/
│       │       └── providers.md
│       ├── setup/
│       │   └── SKILL.md
│       ├── update-nanoclaw/
│       │   └── SKILL.md
│       ├── update-skills/
│       │   └── SKILL.md
│       ├── use-local-whisper/
│       │   └── SKILL.md
│       └── x-integration/
│           ├── SKILL.md
│           ├── agent.ts
│           ├── host.ts
│           ├── lib/
│           │   ├── browser.ts
│           │   └── config.ts
│           └── scripts/
│               ├── like.ts
│               ├── post.ts
│               ├── quote.ts
│               ├── reply.ts
│               ├── retweet.ts
│               └── setup.ts
├── .github/
│   ├── CODEOWNERS
│   ├── PULL_REQUEST_TEMPLATE.md
│   └── workflows/
│       ├── bump-version.yml
│       ├── ci.yml
│       ├── merge-forward-skills.yml
│       └── update-tokens.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .mcp.json
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE
├── README.md
├── README_ja.md
├── README_zh.md
├── config-examples/
│   └── mount-allowlist.json
├── container/
│   ├── Dockerfile
│   ├── agent-runner/
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── index.ts
│   │   │   └── ipc-mcp-stdio.ts
│   │   └── tsconfig.json
│   ├── build.sh
│   └── skills/
│       ├── agent-browser/
│       │   └── SKILL.md
│       ├── capabilities/
│       │   └── SKILL.md
│       └── status/
│           └── SKILL.md
├── docs/
│   ├── APPLE-CONTAINER-NETWORKING.md
│   ├── DEBUG_CHECKLIST.md
│   ├── REQUIREMENTS.md
│   ├── SDK_DEEP_DIVE.md
│   ├── SECURITY.md
│   ├── SPEC.md
│   ├── docker-sandboxes.md
│   ├── nanoclaw-architecture-final.md
│   ├── nanorepo-architecture.md
│   └── skills-as-branches.md
├── launchd/
│   └── com.nanoclaw.plist
├── package.json
├── repo-tokens/
│   ├── README.md
│   └── action.yml
├── scripts/
│   └── run-migrations.ts
├── setup/
│   ├── container.ts
│   ├── environment.test.ts
│   ├── environment.ts
│   ├── groups.ts
│   ├── index.ts
│   ├── mounts.ts
│   ├── platform.test.ts
│   ├── platform.ts
│   ├── register.test.ts
│   ├── register.ts
│   ├── service.test.ts
│   ├── service.ts
│   ├── status.ts
│   └── verify.ts
├── setup.sh
├── src/
│   ├── channels/
│   │   ├── index.ts
│   │   ├── registry.test.ts
│   │   └── registry.ts
│   ├── config.ts
│   ├── container-runner.test.ts
│   ├── container-runner.ts
│   ├── container-runtime.test.ts
│   ├── container-runtime.ts
│   ├── credential-proxy.test.ts
│   ├── credential-proxy.ts
│   ├── db.test.ts
│   ├── db.ts
│   ├── env.ts
│   ├── formatting.test.ts
│   ├── group-folder.test.ts
│   ├── group-folder.ts
│   ├── group-queue.test.ts
│   ├── group-queue.ts
│   ├── index.ts
│   ├── ipc-auth.test.ts
│   ├── ipc.ts
│   ├── logger.ts
│   ├── mount-security.ts
│   ├── remote-control.test.ts
│   ├── remote-control.ts
│   ├── router.ts
│   ├── routing.test.ts
│   ├── sender-allowlist.test.ts
│   ├── sender-allowlist.ts
│   ├── task-scheduler.test.ts
│   ├── task-scheduler.ts
│   ├── timezone.test.ts
│   ├── timezone.ts
│   └── types.ts
├── tsconfig.json
├── vitest.config.ts
└── vitest.skills.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .claude/settings.json
================================================
{}


================================================
FILE: .claude/skills/add-compact/SKILL.md
================================================
---
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
npm test
npm 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: `npm 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
npm run build
npm 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.


================================================
FILE: .claude/skills/add-discord/SKILL.md
================================================
---
name: add-discord
description: Add Discord bot channel integration to NanoClaw.
---

# Add Discord Channel

This skill adds Discord support to NanoClaw, then walks through interactive setup.

## Phase 1: Pre-flight

### Check if already applied

Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.

### Ask the user

Use `AskUserQuestion` to collect configuration:

AskUserQuestion: Do you have a Discord bot token, or do you need to create one?

If they have one, collect it now. If not, we'll create one in Phase 3.

## Phase 2: Apply Code Changes

### Ensure channel remote

```bash
git remote -v
```

If `discord` is missing, add it:

```bash
git remote add discord https://github.com/qwibitai/nanoclaw-discord.git
```

### Merge the skill branch

```bash
git fetch discord main
git merge discord/main || {
  git checkout --theirs package-lock.json
  git add package-lock.json
  git merge --continue
}
```

This merges in:
- `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`)
- `src/channels/discord.test.ts` (unit tests with discord.js mock)
- `import './discord.js'` appended to the channel barrel file `src/channels/index.ts`
- `discord.js` npm dependency in `package.json`
- `DISCORD_BOT_TOKEN` 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
npm install
npm run build
npx vitest run src/channels/discord.test.ts
```

All tests must pass (including the new Discord tests) and build must be clean before proceeding.

## Phase 3: Setup

### Create Discord Bot (if needed)

If the user doesn't have a bot token, tell them:

> I need you to create a Discord bot:
>
> 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
> 2. Click **New Application** and give it a name (e.g., "Andy Assistant")
> 3. Go to the **Bot** tab on the left sidebar
> 4. Click **Reset Token** to generate a new bot token — copy it immediately (you can only see it once)
> 5. Under **Privileged Gateway Intents**, enable:
>    - **Message Content Intent** (required to read message text)
>    - **Server Members Intent** (optional, for member display names)
> 6. Go to **OAuth2** > **URL Generator**:
>    - Scopes: select `bot`
>    - Bot Permissions: select `Send Messages`, `Read Message History`, `View Channels`
>    - Copy the generated URL and open it in your browser to invite the bot to your server

Wait for the user to provide the token.

### Configure environment

Add to `.env`:

```bash
DISCORD_BOT_TOKEN=<their-token>
```

Channels auto-enable when their credentials are present — no extra configuration needed.

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
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```

## Phase 4: Registration

### Get Channel ID

Tell the user:

> To get the channel ID for registration:
>
> 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode**
> 2. Right-click the text channel you want the bot to respond in
> 3. Click **Copy Channel ID**
>
> The channel ID will be a long number like `1234567890123456`.

Wait for the user to provide the channel ID (format: `dc:1234567890123456`).

### Register the channel

The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.

For a main channel (responds to all messages):

```bash
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main
```

For additional channels (trigger-only):

```bash
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel discord
```

## Phase 5: Verify

### Test the connection

Tell the user:

> Send a message in your registered Discord channel:
> - For main channel: Any message works
> - For non-main: @mention the bot in Discord
>
> The bot should respond within a few seconds.

### Check logs if needed

```bash
tail -f logs/nanoclaw.log
```

## Troubleshooting

### Bot not responding

1. Check `DISCORD_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'dc:%'"`
3. For non-main channels: message must include trigger pattern (@mention the bot)
4. Service is running: `launchctl list | grep nanoclaw`
5. Verify the bot has been invited to the server (check OAuth2 URL was used)

### Bot only responds to @mentions

This is the default behavior for non-main channels (`requiresTrigger: true`). To change:
- Update the registered group's `requiresTrigger` to `false`
- Or register the channel as the main channel

### Message Content Intent not enabled

If the bot connects but can't read messages, ensure:
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Select your application > **Bot** tab
3. Under **Privileged Gateway Intents**, enable **Message Content Intent**
4. Restart NanoClaw

### Getting Channel ID

If you can't copy the channel ID:
- Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode
- Right-click the channel name in the server sidebar > Copy Channel ID

## After Setup

The Discord bot supports:
- Text messages in registered channels
- Attachment descriptions (images, videos, files shown as placeholders)
- Reply context (shows who the user is replying to)
- @mention translation (Discord `<@botId>` → NanoClaw trigger format)
- Message splitting for responses over 2000 characters
- Typing indicators while the agent processes


================================================
FILE: .claude/skills/add-gmail/SKILL.md
================================================
---
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 package-lock.json
  git add package-lock.json
  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
npm install
npm run build
npx 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, skip to "Build and restart" below.

### GCP Project Setup

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
npx -y @gongrzhe/server-gmail-autoauth-mcp auth
```

If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @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
npm 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
npx -y @gongrzhe/server-gmail-autoauth-mcp
```

### OAuth token expired

Re-authorize:

```bash
rm ~/.gmail-mcp/credentials.json
npx -y @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 .. && npm 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: `npm 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 .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)


================================================
FILE: .claude/skills/add-image-vision/SKILL.md
================================================
---
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 package-lock.json
  git add package-lock.json
  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
npm install
npm run build
npx 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 `npm 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.


================================================
FILE: .claude/skills/add-ollama-tool/SKILL.md
================================================
---
name: add-ollama-tool
description: Add Ollama MCP server so the container agent can call local models for cheaper/faster tasks like summarization, translation, or general queries.
---

# Add Ollama Integration

This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models.

Tools added:
- `ollama_list_models` — lists installed Ollama models
- `ollama_generate` — sends a prompt to a specified model and returns the response

## Phase 1: Pre-flight

### Check if already applied

Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).

### Check prerequisites

Verify Ollama is installed and running on the host:

```bash
ollama list
```

If Ollama is not installed, direct the user to https://ollama.com/download.

If no models are installed, suggest pulling one:

> You need at least one model. I recommend:
>
> ```bash
> ollama pull gemma3:1b    # Small, fast (1GB)
> ollama pull llama3.2     # Good general purpose (2GB)
> ollama pull qwen3-coder:30b  # Best for code tasks (18GB)
> ```

## Phase 2: Apply Code Changes

### Ensure upstream remote

```bash
git remote -v
```

If `upstream` is missing, add it:

```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```

### Merge the skill branch

```bash
git fetch upstream skill/ollama-tool
git merge upstream/skill/ollama-tool
```

This merges in:
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
- `scripts/ollama-watch.sh` (macOS notification watcher)
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
- `OLLAMA_HOST` in `.env.example`

If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.

### Copy to per-group agent-runner

Existing groups have a cached copy of the agent-runner source. Copy the new files:

```bash
for dir in data/sessions/*/agent-runner-src; do
  cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
  cp container/agent-runner/src/index.ts "$dir/"
done
```

### Validate code changes

```bash
npm run build
./container/build.sh
```

Build must be clean before proceeding.

## Phase 3: Configure

### Set Ollama host (optional)

By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`:

```bash
OLLAMA_HOST=http://your-ollama-host:11434
```

### Restart the service

```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw  # macOS
# Linux: systemctl --user restart nanoclaw
```

## Phase 4: Verify

### Test via WhatsApp

Tell the user:

> Send a message like: "use ollama to tell me the capital of France"
>
> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response.

### Monitor activity (optional)

Run the watcher script for macOS notifications when Ollama is used:

```bash
./scripts/ollama-watch.sh
```

### Check logs if needed

```bash
tail -f logs/nanoclaw.log | grep -i ollama
```

Look for:
- `Agent output: ... Ollama ...` — agent used Ollama successfully
- `[OLLAMA] >>> Generating` — generation started (if log surfacing works)
- `[OLLAMA] <<< Done` — generation completed

## Troubleshooting

### Agent says "Ollama is not installed"

The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
2. The per-group source wasn't updated — re-copy files (see Phase 2)
3. The container wasn't rebuilt — run `./container/build.sh`

### "Failed to connect to Ollama"

1. Verify Ollama is running: `ollama list`
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
3. If using a custom host, check `OLLAMA_HOST` in `.env`

### Agent doesn't use Ollama tools

The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."


================================================
FILE: .claude/skills/add-parallel/SKILL.md
================================================
# Add Parallel AI Integration

Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.

## What This Adds

- **Quick Search** - Fast web lookups using Parallel Search API (free to use)
- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission)
- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking)

## Prerequisites

User must have:
1. Parallel AI API key from https://platform.parallel.ai
2. NanoClaw already set up and running
3. Docker installed and running

## Implementation Steps

Run all steps automatically. Only pause for user input when explicitly needed.

### 1. Get Parallel AI API Key

Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?`

**If they have one:**
Collect it now.

**If they need one:**
Tell them:
> 1. Go to https://platform.parallel.ai
> 2. Sign up or log in
> 3. Navigate to API Keys section
> 4. Create a new API key
> 5. Copy the key and paste it here

Wait for the API key.

### 2. Add API Key to Environment

Add `PARALLEL_API_KEY` to `.env`:

```bash
# Check if .env exists, create if not
if [ ! -f .env ]; then
    touch .env
fi

# Add PARALLEL_API_KEY if not already present
if ! grep -q "PARALLEL_API_KEY=" .env; then
    echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env
    echo "✓ Added PARALLEL_API_KEY to .env"
else
    # Update existing key
    sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env
    echo "✓ Updated PARALLEL_API_KEY in .env"
fi
```

Verify:
```bash
grep "PARALLEL_API_KEY" .env | head -c 50
```

### 3. Update Container Runner

Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`:

Find the line:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
```

Replace with:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
```

### 4. Configure MCP Servers in Agent Runner

Update `container/agent-runner/src/index.ts`:

Find the section where `mcpServers` is configured (around line 237-252):
```typescript
const mcpServers: Record<string, any> = {
  nanoclaw: ipcMcp
};
```

Add Parallel AI MCP servers after the nanoclaw server:
```typescript
const mcpServers: Record<string, any> = {
  nanoclaw: ipcMcp
};

// Add Parallel AI MCP servers if API key is available
const parallelApiKey = process.env.PARALLEL_API_KEY;
if (parallelApiKey) {
  mcpServers['parallel-search'] = {
    type: 'http',  // REQUIRED: Must specify type for HTTP MCP servers
    url: 'https://search-mcp.parallel.ai/mcp',
    headers: {
      'Authorization': `Bearer ${parallelApiKey}`
    }
  };
  mcpServers['parallel-task'] = {
    type: 'http',  // REQUIRED: Must specify type for HTTP MCP servers  
    url: 'https://task-mcp.parallel.ai/mcp',
    headers: {
      'Authorization': `Bearer ${parallelApiKey}`
    }
  };
  log('Parallel AI MCP servers configured');
} else {
  log('PARALLEL_API_KEY not set, skipping Parallel AI integration');
}
```

Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248):
```typescript
allowedTools: [
  'Bash',
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
  'WebSearch', 'WebFetch',
  'mcp__nanoclaw__*',
  'mcp__parallel-search__*',
  'mcp__parallel-task__*'
],
```

### 5. Add Usage Instructions to CLAUDE.md

Add Parallel AI usage instructions to `groups/main/CLAUDE.md`:

Find the "## What You Can Do" section and add after the existing bullet points:
```markdown
- Use Parallel AI for web research and deep learning tasks
```

Then add a new section after "## What You Can Do":
```markdown
## Web Research Tools

You have access to two Parallel AI research tools:

### Quick Web Search (`mcp__parallel-search__search`)
**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts.

**Examples:**
- "Who invented the transistor?"
- "What's the latest news about quantum computing?"
- "When was the UN founded?"
- "What are the top programming languages in 2026?"

**Speed:** Fast (2-5 seconds)
**Cost:** Low
**Permission:** Not needed - use whenever it helps answer the question

### Deep Research (`mcp__parallel-task__create_task_run`)
**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research.

**Examples:**
- "Explain the development of quantum mechanics from 1900-1930"
- "Compare the literary styles of Hemingway and Faulkner"
- "Research the evolution of jazz from bebop to fusion"
- "Analyze the causes of the French Revolution"

**Speed:** Slower (1-20 minutes depending on depth)
**Cost:** Higher (varies by processor tier)
**Permission:** ALWAYS use `AskUserQuestion` before using this tool

**How to ask permission:**
```
AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed?
```

**After permission - DO NOT BLOCK! Use scheduler instead:**

1. Create the task using `mcp__parallel-task__create_task_run`
2. Get the `run_id` from the response
3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`:
   ```
   Prompt: "Check Parallel AI task run [run_id] and send results when ready.

   1. Use the Parallel Task MCP to check the task status
   2. If status is 'completed', extract the results
   3. Send results to user with mcp__nanoclaw__send_message
   4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done

   If status is still 'running' or 'pending', do nothing (task will run again in 30s).
   If status is 'failed', send error message and complete the task."

   Schedule: interval every 30 seconds
   Context mode: isolated
   ```
4. Send acknowledgment with tracking link
5. Exit immediately - scheduler handles the rest

### Choosing Between Them

**Use Search when:**
- Question needs a quick fact or recent information
- Simple definition or clarification
- Verifying specific details
- Current events or news

**Use Deep Research (with permission) when:**
- User wants to learn about a complex topic
- Question requires analysis or comparison
- Historical context or evolution of concepts
- Structured, comprehensive understanding needed
- User explicitly asks to "research" or "explain in depth"

**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis.
```

### 6. Rebuild Container

Build the container with updated agent runner:

```bash
./container/build.sh
```

Verify the build:
```bash
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```

### 7. Restart Service

Rebuild the main app and restart:

```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw  # macOS
# Linux: systemctl --user restart nanoclaw
```

Wait 3 seconds for service to start, then verify:
```bash
sleep 3
launchctl list | grep nanoclaw  # macOS
# Linux: systemctl --user status nanoclaw
```

### 8. Test Integration

Tell the user to test:
> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?`
>
> The assistant should use Parallel Search API to find current information.
>
> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?`
>
> The assistant should ask for permission before using the Task API.

Check logs to verify MCP servers loaded:
```bash
tail -20 logs/nanoclaw.log
```

Look for: `Parallel AI MCP servers configured`

## Troubleshooting

**Container hangs or times out:**
- Check that `type: 'http'` is specified in MCP server config
- Verify API key is correct in .env
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`

**MCP servers not loading:**
- Ensure PARALLEL_API_KEY is in .env
- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars
- Check agent-runner logs for "Parallel AI MCP servers configured" message

**Task polling not working:**
- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"`
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
- Ensure task prompt includes proper Parallel MCP tool names

## Uninstalling

To remove Parallel AI integration:

1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env`
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && npm run build`
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)


================================================
FILE: .claude/skills/add-pdf-reader/SKILL.md
================================================
---
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 package-lock.json
  git add package-lock.json
  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
npm run build
npx 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.


================================================
FILE: .claude/skills/add-reactions/SKILL.md
================================================
---
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 package-lock.json
  git add package-lock.json
  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
npx tsx scripts/migrate-reactions.ts
```

### Validate code changes

```bash
npm test
npm run build
```

All tests must pass and build must be clean before proceeding.

## Phase 3: Verify

### Build and restart

```bash
npm 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


================================================
FILE: .claude/skills/add-slack/SKILL.md
================================================
---
name: add-slack
description: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket Mode (no public URL needed).
---

# Add Slack Channel

This skill adds Slack support to NanoClaw, then walks through interactive setup.

## Phase 1: Pre-flight

### Check if already applied

Check if `src/channels/slack.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.

### Ask the user

**Do they already have a Slack app configured?** If yes, collect the Bot Token and App Token now. If no, we'll create one in Phase 3.

## Phase 2: Apply Code Changes

### Ensure channel remote

```bash
git remote -v
```

If `slack` is missing, add it:

```bash
git remote add slack https://github.com/qwibitai/nanoclaw-slack.git
```

### Merge the skill branch

```bash
git fetch slack main
git merge slack/main || {
  git checkout --theirs package-lock.json
  git add package-lock.json
  git merge --continue
}
```

This merges in:
- `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`)
- `src/channels/slack.test.ts` (46 unit tests)
- `import './slack.js'` appended to the channel barrel file `src/channels/index.ts`
- `@slack/bolt` npm dependency in `package.json`
- `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` 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
npm install
npm run build
npx vitest run src/channels/slack.test.ts
```

All tests must pass (including the new Slack tests) and build must be clean before proceeding.

## Phase 3: Setup

### Create Slack App (if needed)

If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table.

Quick summary of what's needed:
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
2. Enable Socket Mode and generate an App-Level Token (`xapp-...`)
3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im`
4. Add OAuth scopes: `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`
5. Install to workspace and copy the Bot Token (`xoxb-...`)

Wait for the user to provide both tokens.

### Configure environment

Add to `.env`:

```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_APP_TOKEN=xapp-your-app-token
```

Channels auto-enable when their credentials are present — no extra configuration needed.

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
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```

## Phase 4: Registration

### Get Channel ID

Tell the user:

> 1. Add the bot to a Slack channel (right-click channel → **View channel details** → **Integrations** → **Add apps**)
> 2. In that channel, the channel ID is in the URL when you open it in a browser: `https://app.slack.com/client/T.../C0123456789` — the `C...` part is the channel ID
> 3. Alternatively, right-click the channel name → **Copy link** — the channel ID is the last path segment
>
> The JID format for NanoClaw is: `slack:C0123456789`

Wait for the user to provide the channel ID.

### Register the channel

The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.

For a main channel (responds to all messages):

```bash
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main
```

For additional channels (trigger-only):

```bash
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel slack
```

## Phase 5: Verify

### Test the connection

Tell the user:

> Send a message in your registered Slack channel:
> - For main channel: Any message works
> - For non-main: `@<assistant-name> hello` (using the configured trigger word)
>
> The bot should respond within a few seconds.

### Check logs if needed

```bash
tail -f logs/nanoclaw.log
```

## Troubleshooting

### Bot not responding

1. Check `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are set in `.env` AND synced to `data/env/env`
2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'slack:%'"`
3. For non-main channels: message must include trigger pattern
4. Service is running: `launchctl list | grep nanoclaw`

### Bot connected but not receiving messages

1. Verify Socket Mode is enabled in the Slack app settings
2. Verify the bot is subscribed to the correct events (`message.channels`, `message.groups`, `message.im`)
3. Verify the bot has been added to the channel
4. Check that the bot has the required OAuth scopes

### Bot not seeing messages in channels

By default, bots only see messages in channels they've been explicitly added to. Make sure to:
1. Add the bot to each channel you want it to monitor
2. Check the bot has `channels:history` and/or `groups:history` scopes

### "missing_scope" errors

If the bot logs `missing_scope` errors:
1. Go to **OAuth & Permissions** in your Slack app settings
2. Add the missing scope listed in the error message
3. **Reinstall the app** to your workspace — scope changes require reinstallation
4. Copy the new Bot Token (it changes on reinstall) and update `.env`
5. Sync: `mkdir -p data/env && cp .env data/env/env`
6. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`

### Getting channel ID

If the channel ID is hard to find:
- In Slack desktop: right-click channel → **Copy link** → extract the `C...` ID from the URL
- In Slack web: the URL shows `https://app.slack.com/client/TXXXXXXX/C0123456789`
- Via API: `curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'`

## After Setup

The Slack channel supports:
- **Public channels** — Bot must be added to the channel
- **Private channels** — Bot must be invited to the channel
- **Direct messages** — Users can DM the bot directly
- **Multi-channel** — Can run alongside WhatsApp or other channels (auto-enabled by credentials)

## Known Limitations

- **Threads are flattened** — Threaded replies are delivered to the agent as regular channel messages. The agent sees them but has no awareness they originated in a thread. Responses always go to the channel, not back into the thread. Users in a thread will need to check the main channel for the bot's reply. Full thread-aware routing (respond in-thread) requires pipeline-wide changes: database schema, `NewMessage` type, `Channel.sendMessage` interface, and routing logic.
- **No typing indicator** — Slack's Bot API does not expose a typing indicator endpoint. The `setTyping()` method is a no-op. Users won't see "bot is typing..." while the agent works.
- **Message splitting is naive** — Long messages are split at a fixed 4000-character boundary, which may break mid-word or mid-sentence. A smarter split (on paragraph or sentence boundaries) would improve readability.
- **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent.
- **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup.
- **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section.


================================================
FILE: .claude/skills/add-telegram/SKILL.md
================================================
---
name: add-telegram
description: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only).
---

# Add Telegram Channel

This skill adds Telegram support to NanoClaw, then walks through interactive setup.

## Phase 1: Pre-flight

### Check if already applied

Check if `src/channels/telegram.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.

### Ask the user

Use `AskUserQuestion` to collect configuration:

AskUserQuestion: Do you have a Telegram bot token, or do you need to create one?

If they have one, collect it now. If not, we'll create one in Phase 3.

## Phase 2: Apply Code Changes

### Ensure channel remote

```bash
git remote -v
```

If `telegram` is missing, add it:

```bash
git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git
```

### Merge the skill branch

```bash
git fetch telegram main
git merge telegram/main || {
  git checkout --theirs package-lock.json
  git add package-lock.json
  git merge --continue
}
```

This merges in:
- `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`)
- `src/channels/telegram.test.ts` (unit tests with grammy mock)
- `import './telegram.js'` appended to the channel barrel file `src/channels/index.ts`
- `grammy` npm dependency in `package.json`
- `TELEGRAM_BOT_TOKEN` 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
npm install
npm run build
npx vitest run src/channels/telegram.test.ts
```

All tests must pass (including the new Telegram tests) and build must be clean before proceeding.

## Phase 3: Setup

### Create Telegram Bot (if needed)

If the user doesn't have a bot token, tell them:

> I need you to create a Telegram bot:
>
> 1. Open Telegram and search for `@BotFather`
> 2. Send `/newbot` and follow prompts:
>    - Bot name: Something friendly (e.g., "Andy Assistant")
>    - Bot username: Must end with "bot" (e.g., "andy_ai_bot")
> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)

Wait for the user to provide the token.

### Configure environment

Add to `.env`:

```bash
TELEGRAM_BOT_TOKEN=<their-token>
```

Channels auto-enable when their credentials are present — no extra configuration needed.

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.

### Disable Group Privacy (for group chats)

Tell the user:

> **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
>
> 1. Open Telegram and search for `@BotFather`
> 2. Send `/mybots` and select your bot
> 3. Go to **Bot Settings** > **Group Privacy** > **Turn off**
>
> This is optional if you only want trigger-based responses via @mentioning the bot.

### Build and restart

```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw  # macOS
# Linux: systemctl --user restart nanoclaw
```

## Phase 4: Registration

### Get Chat ID

Tell the user:

> 1. Open your bot in Telegram (search for its username)
> 2. Send `/chatid` — it will reply with the chat ID
> 3. For groups: add the bot to the group first, then send `/chatid` in the group

Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234567890`).

### Register the chat

The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.

For a main chat (responds to all messages):

```bash
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main
```

For additional chats (trigger-only):

```bash
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_<group-name>" --trigger "@${ASSISTANT_NAME}" --channel telegram
```

## Phase 5: Verify

### Test the connection

Tell the user:

> Send a message to your registered Telegram chat:
> - For main chat: Any message works
> - For non-main: `@Andy hello` or @mention the bot
>
> The bot should respond within a few seconds.

### Check logs if needed

```bash
tail -f logs/nanoclaw.log
```

## Troubleshooting

### Bot not responding

Check:
1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env`
2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`)
3. For non-main chats: message includes trigger pattern
4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)

### Bot only responds to @mentions in groups

Group Privacy is enabled (default). Fix:
1. `@BotFather` > `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off**
2. Remove and re-add the bot to the group (required for the change to take effect)

### Getting chat ID

If `/chatid` doesn't work:
- Verify token: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"`
- Check bot is started: `tail -f logs/nanoclaw.log`

## After Setup

If running `npm run dev` while the service is active:
```bash
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user stop nanoclaw
# npm run dev
# systemctl --user start nanoclaw
```

## Agent Swarms (Teams)

After completing the Telegram setup, use `AskUserQuestion`:

AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.

If they say yes, invoke the `/add-telegram-swarm` skill.

## Removal

To remove Telegram integration:

1. Delete `src/channels/telegram.ts` and `src/channels/telegram.test.ts`
2. Remove `import './telegram.js'` from `src/channels/index.ts`
3. Remove `TELEGRAM_BOT_TOKEN` from `.env`
4. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
5. Uninstall: `npm uninstall grammy`
6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)


================================================
FILE: .claude/skills/add-telegram-swarm/SKILL.md
================================================
---
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
npm 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: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)


================================================
FILE: .claude/skills/add-voice-transcription/SKILL.md
================================================
---
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 package-lock.json
  git add package-lock.json
  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
npm install --legacy-peer-deps
npm run build
npx 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
npm 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.


================================================
FILE: .claude/skills/add-whatsapp/SKILL.md
================================================
---
name: add-whatsapp
description: Add WhatsApp as a channel. Can replace other channels entirely or run alongside them. Uses QR code or pairing code for authentication.
---

# Add WhatsApp Channel

This skill adds WhatsApp support to NanoClaw. It installs the WhatsApp channel code, dependencies, and guides through authentication, registration, and configuration.

## Phase 1: Pre-flight

### Check current state

Check if WhatsApp is already configured. If `store/auth/` exists with credential files, skip to Phase 4 (Registration) or Phase 5 (Verify).

```bash
ls store/auth/creds.json 2>/dev/null && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
```

### Detect environment

Check whether the environment is headless (no display server):

```bash
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
```

### Ask the user

Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:**

If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp?
- **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number)
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)

Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
- **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code
- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)

If they chose pairing code:

AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890)

## Phase 2: Apply Code Changes

Check if `src/channels/whatsapp.ts` already exists. If it does, skip to Phase 3 (Authentication).

### Ensure channel 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 main
git merge whatsapp/main || {
  git checkout --theirs package-lock.json
  git add package-lock.json
  git merge --continue
}
```

This merges in:
- `src/channels/whatsapp.ts` (WhatsAppChannel class with self-registration via `registerChannel`)
- `src/channels/whatsapp.test.ts` (41 unit tests)
- `src/whatsapp-auth.ts` (standalone WhatsApp authentication script)
- `setup/whatsapp-auth.ts` (WhatsApp auth setup step)
- `import './whatsapp.js'` appended to the channel barrel file `src/channels/index.ts`
- `'whatsapp-auth'` step added to `setup/index.ts`
- `@whiskeysockets/baileys`, `qrcode`, `qrcode-terminal` npm dependencies in `package.json`
- `ASSISTANT_HAS_OWN_NUMBER` 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
npm install
npm run build
npx vitest run src/channels/whatsapp.test.ts
```

All tests must pass and build must be clean before proceeding.

## Phase 3: Authentication

### Clean previous auth state (if re-authenticating)

```bash
rm -rf store/auth/
```

### Run WhatsApp authentication

For QR code in browser (recommended):

```bash
npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
```

(Bash timeout: 150000ms)

Tell the user:

> A browser window will open with a QR code.
>
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Scan the QR code in the browser
> 3. The page will show "Authenticated!" when done

For QR code in terminal:

```bash
npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal
```

Tell the user to run `npm run auth` in another terminal, then:

> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Scan the QR code displayed in the terminal

For pairing code:

Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately.

Run the auth process in the background and poll `store/pairing-code.txt` for the code:

```bash
rm -f store/pairing-code.txt && npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number> > /tmp/wa-auth.log 2>&1 &
```

Then immediately poll for the code (do NOT wait for the background command to finish):

```bash
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
```

Display the code to the user the moment it appears. Tell them:

> **Enter this code now** — it expires in ~60 seconds.
>
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Tap **Link with phone number instead**
> 3. Enter the code immediately

After the user enters the code, poll for authentication to complete:

```bash
for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'AUTH_STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
```

**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.

### Verify authentication succeeded

```bash
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
```

### Configure environment

Channels auto-enable when their credentials are present — WhatsApp activates when `store/auth/creds.json` exists.

Sync to container environment:

```bash
mkdir -p data/env && cp .env data/env/env
```

## Phase 4: Registration

### Configure trigger and channel type

Get the bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"`

AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number (separate device)?
- **Shared number** - Your personal WhatsApp number (recommended: use self-chat or a solo group)
- **Dedicated number** - A separate phone/SIM for the assistant

AskUserQuestion: What trigger word should activate the assistant?
- **@Andy** - Default trigger
- **@Claw** - Short and easy
- **@Claude** - Match the AI name

AskUserQuestion: What should the assistant call itself?
- **Andy** - Default name
- **Claw** - Short and easy
- **Claude** - Match the AI name

AskUserQuestion: Where do you want to chat with the assistant?

**Shared number options:**
- **Self-chat** (Recommended) - Chat in your own "Message Yourself" conversation
- **Solo group** - A group with just you and the linked device
- **Existing group** - An existing WhatsApp group

**Dedicated number options:**
- **DM with bot** (Recommended) - Direct message the bot's number
- **Solo group** - A group with just you and the bot
- **Existing group** - An existing WhatsApp group

### Get the JID

**Self-chat:** JID = your phone number with `@s.whatsapp.net`. Extract from auth credentials:

```bash
node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"
```

**DM with bot:** Ask for the bot's phone number. JID = `NUMBER@s.whatsapp.net`

**Group (solo, existing):** Run group sync and list available groups:

```bash
npx tsx setup/index.ts --step groups
npx tsx setup/index.ts --step groups --list
```

The output shows `JID|GroupName` pairs. Present candidates as AskUserQuestion (names only, not JIDs).

### Register the chat

```bash
npx tsx setup/index.ts --step register \
  --jid "<jid>" \
  --name "<chat-name>" \
  --trigger "@<trigger>" \
  --folder "whatsapp_main" \
  --channel whatsapp \
  --assistant-name "<name>" \
  --is-main \
  --no-trigger-required  # Only for main/self-chat
```

For additional groups (trigger-required):

```bash
npx tsx setup/index.ts --step register \
  --jid "<group-jid>" \
  --name "<group-name>" \
  --trigger "@<trigger>" \
  --folder "whatsapp_<group-name>" \
  --channel whatsapp
```

## Phase 5: Verify

### Build and restart

```bash
npm run build
```

Restart the service:

```bash
# macOS (launchd)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw

# Linux (systemd)
systemctl --user restart nanoclaw

# Linux (nohup fallback)
bash start-nanoclaw.sh
```

### Test the connection

Tell the user:

> Send a message to your registered WhatsApp chat:
> - For self-chat / main: Any message works
> - For groups: Use the trigger word (e.g., "@Andy hello")
>
> The assistant should respond within a few seconds.

### Check logs if needed

```bash
tail -f logs/nanoclaw.log
```

## Troubleshooting

### QR code expired

QR codes expire after ~60 seconds. Re-run the auth command:

```bash
rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts
```

### Pairing code not working

Codes expire in ~60 seconds. To retry:

```bash
rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone <phone>
```

Enter the code **immediately** when it appears. Also ensure:
1. Phone number includes country code without `+` (e.g., `1234567890`)
2. Phone has internet access
3. WhatsApp is updated to the latest version

If pairing code keeps failing, switch to QR-browser auth instead:

```bash
rm -rf store/auth/ && npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
```

### "conflict" disconnection

This happens when two instances connect with the same credentials. Ensure only one NanoClaw process is running:

```bash
pkill -f "node dist/index.js"
# Then restart
```

### Bot not responding

Check:
1. Auth credentials exist: `ls store/auth/creds.json`
3. Chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE '%whatsapp%' OR jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"`
4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
5. Logs: `tail -50 logs/nanoclaw.log`

### Group names not showing

Run group metadata sync:

```bash
npx tsx setup/index.ts --step groups
```

This fetches all group names from WhatsApp. Runs automatically every 24 hours.

## After Setup

If running `npm run dev` while the service is active:

```bash
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist

# Linux:
# systemctl --user stop nanoclaw
# npm run dev
# systemctl --user start nanoclaw
```

## Removal

To remove WhatsApp integration:

1. Delete auth credentials: `rm -rf store/auth/`
2. Remove WhatsApp registrations: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"`
3. Sync env: `mkdir -p data/env && cp .env data/env/env`
4. Rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)


================================================
FILE: .claude/skills/convert-to-apple-container/SKILL.md
================================================
---
name: convert-to-apple-container
description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container".
---

# Convert to Apple Container

This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification.

**What this changes:**
- Container runtime binary: `docker` → `container`
- Mount syntax: `-v path:path:ro` → `--mount type=bind,source=...,target=...,readonly`
- Startup check: `docker info` → `container system status` (with auto-start)
- Orphan detection: `docker ps --filter` → `container ls --format json`
- Build script default: `docker` → `container`
- Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay)
- Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv`

**What stays the same:**
- Mount security/allowlist validation
- All exported interfaces and IPC protocol
- Non-main container behavior (still uses `--user` flag)
- All other functionality

## Prerequisites

Verify Apple Container is installed:

```bash
container --version && echo "Apple Container ready" || echo "Install Apple Container first"
```

If not installed:
- Download from https://github.com/apple/container/releases
- Install the `.pkg` file
- Verify: `container --version`

Apple Container requires macOS. It does not work on Linux.

## Phase 1: Pre-flight

### Check if already applied

```bash
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
```

If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3.

## Phase 2: Apply Code Changes

### Ensure upstream remote

```bash
git remote -v
```

If `upstream` is missing, add it:

```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```

### Merge the skill branch

```bash
git fetch upstream skill/apple-container
git merge upstream/skill/apple-container
```

This merges in:
- `src/container-runtime.ts` — Apple Container implementation (replaces Docker)
- `src/container-runtime.test.ts` — Apple Container-specific tests
- `src/container-runner.ts` — .env shadow mount fix and privilege dropping
- `container/Dockerfile` — entrypoint that shadows .env via `mount --bind`
- `container/build.sh` — default runtime set to `container`

If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.

### Validate code changes

```bash
npm test
npm run build
```

All tests must pass and build must be clean before proceeding.

## Phase 3: Verify

### Ensure Apple Container runtime is running

```bash
container system status || container system start
```

### Build the container image

```bash
./container/build.sh
```

### Test basic execution

```bash
echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
```

### Test readonly mounts

```bash
mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt
container run --rm --entrypoint /bin/bash \
  --mount type=bind,source=/tmp/test-ro,target=/test,readonly \
  nanoclaw-agent:latest \
  -c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'"
rm -rf /tmp/test-ro
```

Expected: Read succeeds, write fails with "Read-only file system".

### Test read-write mounts

```bash
mkdir -p /tmp/test-rw
container run --rm --entrypoint /bin/bash \
  -v /tmp/test-rw:/test \
  nanoclaw-agent:latest \
  -c "echo 'test write' > /test/new.txt && cat /test/new.txt"
cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw
```

Expected: Both operations succeed.

### Full integration test

```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```

Send a message via WhatsApp and verify the agent responds.

## Troubleshooting

**Apple Container not found:**
- Download from https://github.com/apple/container/releases
- Install the `.pkg` file
- Verify: `container --version`

**Runtime won't start:**
```bash
container system start
container system status
```

**Image build fails:**
```bash
# Clean rebuild — Apple Container caches aggressively
container builder stop && container builder rm && container builder start
./container/build.sh
```

**Container can't write to mounted directories:**
Check directory permissions on the host. The container runs as uid 1000.

## Summary of Changed Files

| File | Type of Change |
|------|----------------|
| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API |
| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior |
| `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop |
| `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop |
| `container/build.sh` | Default runtime: `docker` → `container` |


================================================
FILE: .claude/skills/customize/SKILL.md
================================================
---
name: customize
description: Add new capabilities or modify NanoClaw behavior. Use when user wants to add channels (Telegram, Slack, email input), change triggers, add integrations, modify the router, or make any other customizations. This is an interactive skill that asks questions to understand what the user wants.
---

# NanoClaw Customization

This skill helps users add capabilities or modify behavior. Use AskUserQuestion to understand what they want before making changes.

## Workflow

1. **Understand the request** - Ask clarifying questions
3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually.
4. **Implement** - Make changes directly to the code
5. **Test guidance** - Tell user how to verify

## Key Files

| File | Purpose |
|------|---------|
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
| `src/ipc.ts` | IPC watcher and task processing |
| `src/router.ts` | Message formatting and outbound routing |
| `src/types.ts` | TypeScript interfaces (includes Channel) |
| `src/config.ts` | Assistant name, trigger pattern, directories |
| `src/db.ts` | Database initialization and queries |
| `src/whatsapp-auth.ts` | Standalone WhatsApp authentication script |
| `groups/CLAUDE.md` | Global memory/persona |

## Common Customization Patterns

### Adding a New Input Channel (e.g., Telegram, Slack, Email)

Questions to ask:
- Which channel? (Telegram, Slack, Discord, email, SMS, etc.)
- Same trigger word or different?
- Same memory hierarchy or separate?
- Should messages from this channel go to existing groups or new ones?

Implementation pattern:
1. Create `src/channels/{name}.ts` implementing the `Channel` interface from `src/types.ts` (see `src/channels/whatsapp.ts` for reference)
2. Add the channel instance to `main()` in `src/index.ts` and wire callbacks (`onMessage`, `onChatMetadata`)
3. Messages are stored via the `onMessage` callback; routing is automatic via `ownsJid()`

### Adding a New MCP Integration

Questions to ask:
- What service? (Calendar, Notion, database, etc.)
- What operations needed? (read, write, both)
- Which groups should have access?

Implementation:
1. Add MCP server config to the container settings (see `src/container-runner.ts` for how MCP servers are mounted)
2. Document available tools in `groups/CLAUDE.md`

### Changing Assistant Behavior

Questions to ask:
- What aspect? (name, trigger, persona, response style)
- Apply to all groups or specific ones?

Simple changes → edit `src/config.ts`
Persona changes → edit `groups/CLAUDE.md`
Per-group behavior → edit specific group's `CLAUDE.md`

### Adding New Commands

Questions to ask:
- What should the command do?
- Available in all groups or main only?
- Does it need new MCP tools?

Implementation:
1. Commands are handled by the agent naturally — add instructions to `groups/CLAUDE.md` or the group's `CLAUDE.md`
2. For trigger-level routing changes, modify `processGroupMessages()` in `src/index.ts`

### Changing Deployment

Questions to ask:
- Target platform? (Linux server, Docker, different Mac)
- Service manager? (systemd, Docker, supervisord)

Implementation:
1. Create appropriate service files
2. Update paths in config
3. Provide setup instructions

## After Changes

Always tell the user:
```bash
# Rebuild and restart
npm run build
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart nanoclaw
```

## Example Interaction

User: "Add Telegram as an input channel"

1. Ask: "Should Telegram use the same @Andy trigger, or a different one?"
2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?"
3. Create `src/channels/telegram.ts` implementing the `Channel` interface (see `src/channels/whatsapp.ts`)
4. Add the channel to `main()` in `src/index.ts`
5. Tell user how to authenticate and test


================================================
FILE: .claude/skills/debug/SKILL.md
================================================
---
name: debug
description: Debug container agent issues. Use when things aren't working, container fails, authentication problems, or to understand how the container system works. Covers logs, environment variables, mounts, and common issues.
---

# NanoClaw Container Debugging

This guide covers debugging the containerized agent execution system.

## Architecture Overview

```
Host (macOS)                          Container (Linux VM)
─────────────────────────────────────────────────────────────
src/container-runner.ts               container/agent-runner/
    │                                      │
    │ spawns container                      │ runs Claude Agent SDK
    │ with volume mounts                   │ with MCP servers
    │                                      │
    ├── data/env/env ──────────────> /workspace/env-dir/env
    ├── groups/{folder} ───────────> /workspace/group
    ├── data/ipc/{folder} ────────> /workspace/ipc
    ├── data/sessions/{folder}/.claude/ ──> /home/node/.claude/ (isolated per-group)
    └── (main only) project root ──> /workspace/project
```

**Important:** The container runs as user `node` with `HOME=/home/node`. Session files must be mounted to `/home/node/.claude/` (not `/root/.claude/`) for session resumption to work.

## Log Locations

| Log | Location | Content |
|-----|----------|---------|
| **Main app logs** | `logs/nanoclaw.log` | Host-side WhatsApp, routing, container spawning |
| **Main app errors** | `logs/nanoclaw.error.log` | Host-side errors |
| **Container run logs** | `groups/{folder}/logs/container-*.log` | Per-run: input, mounts, stderr, stdout |
| **Claude sessions** | `~/.claude/projects/` | Claude Code session history |

## Enabling Debug Logging

Set `LOG_LEVEL=debug` for verbose output:

```bash
# For development
LOG_LEVEL=debug npm run dev

# For launchd service (macOS), add to plist EnvironmentVariables:
<key>LOG_LEVEL</key>
<string>debug</string>
# For systemd service (Linux), add to unit [Service] section:
# Environment=LOG_LEVEL=debug
```

Debug level shows:
- Full mount configurations
- Container command arguments
- Real-time container stderr

## Common Issues

### 1. "Claude Code process exited with code 1"

**Check the container log file** in `groups/{folder}/logs/container-*.log`

Common causes:

#### Missing Authentication
```
Invalid API key · Please run /login
```
**Fix:** Ensure `.env` file exists with either OAuth token or API key:
```bash
cat .env  # Should show one of:
# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...  (subscription)
# ANTHROPIC_API_KEY=sk-ant-api03-...        (pay-per-use)
```

#### Root User Restriction
```
--dangerously-skip-permissions cannot be used with root/sudo privileges
```
**Fix:** Container must run as non-root user. Check Dockerfile has `USER node`.

### 2. Environment Variables Not Passing

**Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin).

**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed.

To verify env vars are reaching the container:
```bash
echo '{}' | docker run -i \
  -v $(pwd)/data/env:/workspace/env-dir:ro \
  --entrypoint /bin/bash nanoclaw-agent:latest \
  -c 'export $(cat /workspace/env-dir/env | xargs); echo "OAuth: ${#CLAUDE_CODE_OAUTH_TOKEN} chars, API: ${#ANTHROPIC_API_KEY} chars"'
```

### 3. Mount Issues

**Container mount notes:**
- Docker supports both `-v` and `--mount` syntax
- Use `:ro` suffix for readonly mounts:
  ```bash
  # Readonly
  -v /path:/container/path:ro

  # Read-write
  -v /path:/container/path
  ```

To check what's mounted inside a container:
```bash
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/'
```

Expected structure:
```
/workspace/
├── env-dir/env           # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY)
├── group/                # Current group folder (cwd)
├── project/              # Project root (main channel only)
├── global/               # Global CLAUDE.md (non-main only)
├── ipc/                  # Inter-process communication
│   ├── messages/         # Outgoing WhatsApp messages
│   ├── tasks/            # Scheduled task commands
│   ├── current_tasks.json    # Read-only: scheduled tasks visible to this group
│   └── available_groups.json # Read-only: WhatsApp groups for activation (main only)
└── extra/                # Additional custom mounts
```

### 4. Permission Issues

The container runs as user `node` (uid 1000). Check ownership:
```bash
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
  whoami
  ls -la /workspace/
  ls -la /app/
'
```

All of `/workspace/` and `/app/` should be owned by `node`.

### 5. Session Not Resuming / "Claude Code process exited with code 1"

If sessions aren't being resumed (new session ID every time), or Claude Code exits with code 1 when resuming:

**Root cause:** The SDK looks for sessions at `$HOME/.claude/projects/`. Inside the container, `HOME=/home/node`, so it looks at `/home/node/.claude/projects/`.

**Check the mount path:**
```bash
# In container-runner.ts, verify mount is to /home/node/.claude/, NOT /root/.claude/
grep -A3 "Claude sessions" src/container-runner.ts
```

**Verify sessions are accessible:**
```bash
docker run --rm --entrypoint /bin/bash \
  -v ~/.claude:/home/node/.claude \
  nanoclaw-agent:latest -c '
echo "HOME=$HOME"
ls -la $HOME/.claude/projects/ 2>&1 | head -5
'
```

**Fix:** Ensure `container-runner.ts` mounts to `/home/node/.claude/`:
```typescript
mounts.push({
  hostPath: claudeDir,
  containerPath: '/home/node/.claude',  // NOT /root/.claude
  readonly: false
});
```

### 6. MCP Server Failures

If an MCP server fails to start, the agent may exit. Check the container logs for MCP initialization errors.

## Manual Container Testing

### Test the full agent flow:
```bash
# Set up env file
mkdir -p data/env groups/test
cp .env data/env/env

# Run test query
echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMain":false}' | \
  docker run -i \
  -v $(pwd)/data/env:/workspace/env-dir:ro \
  -v $(pwd)/groups/test:/workspace/group \
  -v $(pwd)/data/ipc:/workspace/ipc \
  nanoclaw-agent:latest
```

### Test Claude Code directly:
```bash
docker run --rm --entrypoint /bin/bash \
  -v $(pwd)/data/env:/workspace/env-dir:ro \
  nanoclaw-agent:latest -c '
  export $(cat /workspace/env-dir/env | xargs)
  claude -p "Say hello" --dangerously-skip-permissions --allowedTools ""
'
```

### Interactive shell in container:
```bash
docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest
```

## SDK Options Reference

The agent-runner uses these Claude Agent SDK options:

```typescript
query({
  prompt: input.prompt,
  options: {
    cwd: '/workspace/group',
    allowedTools: ['Bash', 'Read', 'Write', ...],
    permissionMode: 'bypassPermissions',
    allowDangerouslySkipPermissions: true,  // Required with bypassPermissions
    settingSources: ['project'],
    mcpServers: { ... }
  }
})
```

**Important:** `allowDangerouslySkipPermissions: true` is required when using `permissionMode: 'bypassPermissions'`. Without it, Claude Code exits with code 1.

## Rebuilding After Changes

```bash
# Rebuild main app
npm run build

# Rebuild container (use --no-cache for clean rebuild)
./container/build.sh

# Or force full rebuild
docker builder prune -af
./container/build.sh
```

## Checking Container Image

```bash
# List images
docker images

# Check what's in the image
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
  echo "=== Node version ==="
  node --version

  echo "=== Claude Code version ==="
  claude --version

  echo "=== Installed packages ==="
  ls /app/node_modules/
'
```

## Session Persistence

Claude sessions are stored per-group in `data/sessions/{group}/.claude/` for security isolation. Each group has its own session directory, preventing cross-group access to conversation history.

**Critical:** The mount path must match the container user's HOME directory:
- Container user: `node`
- Container HOME: `/home/node`
- Mount target: `/home/node/.claude/` (NOT `/root/.claude/`)

To clear sessions:

```bash
# Clear all sessions for all groups
rm -rf data/sessions/

# Clear sessions for a specific group
rm -rf data/sessions/{groupFolder}/.claude/

# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
```

To verify session resumption is working, check the logs for the same session ID across messages:
```bash
grep "Session initialized" logs/nanoclaw.log | tail -5
# Should show the SAME session ID for consecutive messages in the same group
```

## IPC Debugging

The container communicates back to the host via files in `/workspace/ipc/`:

```bash
# Check pending messages
ls -la data/ipc/messages/

# Check pending task operations
ls -la data/ipc/tasks/

# Read a specific IPC file
cat data/ipc/messages/*.json

# Check available groups (main channel only)
cat data/ipc/main/available_groups.json

# Check current tasks snapshot
cat data/ipc/{groupFolder}/current_tasks.json
```

**IPC file types:**
- `messages/*.json` - Agent writes: outgoing WhatsApp messages
- `tasks/*.json` - Agent writes: task operations (schedule, pause, resume, cancel, refresh_groups)
- `current_tasks.json` - Host writes: read-only snapshot of scheduled tasks
- `available_groups.json` - Host writes: read-only list of WhatsApp groups (main only)

## Quick Diagnostic Script

Run this to check common issues:

```bash
echo "=== Checking NanoClaw Container Setup ==="

echo -e "\n1. Authentication configured?"
[ -f .env ] && (grep -q "CLAUDE_CODE_OAUTH_TOKEN=sk-" .env || grep -q "ANTHROPIC_API_KEY=sk-" .env) && echo "OK" || echo "MISSING - add CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY to .env"

echo -e "\n2. Env file copied for container?"
[ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run"

echo -e "\n3. Container runtime running?"
docker info &>/dev/null && echo "OK" || echo "NOT RUNNING - start Docker Desktop (macOS) or sudo systemctl start docker (Linux)"

echo -e "\n4. Container image exists?"
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh"

echo -e "\n5. Session mount path correct?"
grep -q "/home/node/.claude" src/container-runner.ts 2>/dev/null && echo "OK" || echo "WRONG - should mount to /home/node/.claude/, not /root/.claude/"

echo -e "\n6. Groups directory?"
ls -la groups/ 2>/dev/null || echo "MISSING - run setup"

echo -e "\n7. Recent container logs?"
ls -t groups/*/logs/container-*.log 2>/dev/null | head -3 || echo "No container logs yet"

echo -e "\n8. Session continuity working?"
SESSIONS=$(grep "Session initialized" logs/nanoclaw.log 2>/dev/null | tail -5 | awk '{print $NF}' | sort -u | wc -l)
[ "$SESSIONS" -le 2 ] && echo "OK (recent sessions reusing IDs)" || echo "CHECK - multiple different session IDs, may indicate resumption issues"
```


================================================
FILE: .claude/skills/get-qodo-rules/SKILL.md
================================================
---
name: get-qodo-rules
description: "Loads org- and repo-level coding rules from Qodo before code tasks begin, ensuring all generation and modification follows team standards. Use before any code generation or modification task when rules are not already loaded. Invoke when user asks to write, edit, refactor, or review code, or when starting implementation planning."
version: 2.0.0
allowed-tools: ["Bash"]
triggers:
  - "get.?qodo.?rules"
  - "get.?rules"
  - "load.?qodo.?rules"
  - "load.?rules"
  - "fetch.?qodo.?rules"
  - "fetch.?rules"
  - "qodo.?rules"
  - "coding.?rules"
  - "code.?rules"
  - "before.?cod"
  - "start.?coding"
  - "write.?code"
  - "implement"
  - "create.*code"
  - "build.*feature"
  - "add.*feature"
  - "fix.*bug"
  - "refactor"
  - "modify.*code"
  - "update.*code"
---

# Get Qodo Rules Skill

## Description

Fetches repository-specific coding rules from the Qodo platform API before code generation or modification tasks. Rules include security requirements, coding standards, quality guidelines, and team conventions that must be applied during code generation.
**Use** before any code generation or modification task when rules are not already loaded. Invoke when user asks to write, edit, refactor, or review code, or when starting implementation planning.
**Skip** if "Qodo Rules Loaded" already appears in conversation context

---

## Workflow

### Step 1: Check if Rules Already Loaded

If rules are already loaded (look for "Qodo Rules Loaded" in recent messages), skip to step 6.

### Step 2: Verify working in a git repository

- Check that the current directory is inside a git repository. If not, inform the user that a git repository is required and exit gracefully.
- Extract the repository scope from the git `origin` remote URL. If no remote is found, exit silently. If the URL cannot be parsed, inform the user and exit gracefully.
- Detect module-level scope: if inside a `modules/*` subdirectory, use it as the query scope; otherwise use repository-wide scope.

See [repository scope detection](references/repository-scope.md) for details.

### Step 3: Verify Qodo Configuration

Check that the required Qodo configuration is present. The default location is `~/.qodo/config.json`.

- **API key**: Read from `~/.qodo/config.json` (`API_KEY` field). If not found, inform the user that an API key is required and provide setup instructions, then exit gracefully.
- **Environment name**: Read from `~/.qodo/config.json` (`ENVIRONMENT_NAME` field), with `QODO_ENVIRONMENT_NAME` environment variable taking precedence. If not found, inform the user that an API key is required and provide setup instructions, then exit gracefully.

### Step 4: Fetch Rules with Pagination

- Fetch all pages from the API (50 rules per page) until no more results are returned.
- On each page, handle HTTP errors and exit gracefully with a user-friendly message.
- Accumulate all rules across pages into a single list.
- Stop after 100 pages maximum (safety limit).
- If no rules are found after all pages, inform the user and exit gracefully.

See [pagination details](references/pagination.md) for the full algorithm and error handling.

### Step 5: Format and Output Rules

- Print the "📋 Qodo Rules Loaded" header with repository scope, scope context, and total rule count.
- Group rules by severity and print each non-empty group: ERROR, WARNING, RECOMMENDATION.
- Each rule is formatted as: `- **{name}** ({category}): {description}`
- End output with `---`.

See [output format details](references/output-format.md) for the exact format.

### Step 6: Apply Rules by Severity

| Severity | Enforcement | When Skipped |
|---|---|---|
| **ERROR** | Must comply, non-negotiable. Add comment documenting compliance (e.g., `# Following Qodo rule: No Hardcoded Credentials`) | Explain to user and ask for guidance |
| **WARNING** | Should comply by default | Briefly explain why in response |
| **RECOMMENDATION** | Consider when appropriate | No action needed |

### Step 7: Report

After code generation, inform the user about rule application:
- **ERROR rules applied**: List which rules were followed
- **WARNING rules skipped**: Explain why
- **No rules applicable**: Inform: "No Qodo rules were applicable to this code change"
- **RECOMMENDATION rules**: Mention only if they influenced a design decision

---

## How Scope Levels Work

Determines scope from git remote and working directory (see [Step 2](#step-2-verify-working-in-a-git-repository)):

**Scope Hierarchy**:
- **Universal** (`/`) - applies everywhere
- **Org Level** (`/org/`) - applies to organization
- **Repo Level** (`/org/repo/`) - applies to repository
- **Path Level** (`/org/repo/path/`) - applies to specific paths

---

## Configuration

See `~/.qodo/config.json` for API key setup. Set `QODO_ENVIRONMENT_NAME` env var or `ENVIRONMENT_NAME` in config to select environment.

---

## Common Mistakes

- **Re-running when rules are loaded** - Check for "Qodo Rules Loaded" in context first
- **Missing compliance comments on ERROR rules** - ERROR rules require a comment documenting compliance
- **Forgetting to report when no rules apply** - Always inform the user when no rules were applicable, so they know the rules system is active
- **Not in git repo** - Inform the user that a git repository is required and exit gracefully; do not attempt code generation
- **No API key** - Inform the user with setup instructions; set `QODO_API_KEY` or create `~/.qodo/config.json`
- **No rules found** - Inform the user; set up rules at app.qodo.ai


================================================
FILE: .claude/skills/get-qodo-rules/references/output-format.md
================================================
# Formatting and Outputting Rules

## Output Structure

Print the following header:

```
# 📋 Qodo Rules Loaded

Scope: `{QUERY_SCOPE}`
Rules loaded: **{TOTAL_RULES}** (universal, org level, repo level, and path level rules)

These rules must be applied during code generation based on severity:
```

## Grouping by Severity

Group rules into three sections and print each non-empty section:

**ERROR** (`severity == "error"`):
```
## ❌ ERROR Rules (Must Comply) - {count}

- **{name}** ({category}): {description}
```

**WARNING** (`severity == "warning"`):
```
## ⚠️  WARNING Rules (Should Comply) - {count}

- **{name}** ({category}): {description}
```

**RECOMMENDATION** (`severity == "recommendation"`):
```
## 💡 RECOMMENDATION Rules (Consider) - {count}

- **{name}** ({category}): {description}
```

End output with `---`.


================================================
FILE: .claude/skills/get-qodo-rules/references/pagination.md
================================================
# Fetching Rules with Pagination

The API returns rules in pages of 50. All pages must be fetched to ensure no rules are missed.

## Algorithm

1. Start with `page=1`, `page_size=50`, accumulate results in an empty list
2. Request: `GET {API_URL}/rules?scopes={ENCODED_SCOPE}&state=active&page={PAGE}&page_size=50`
   - Header: `Authorization: Bearer {API_KEY}`
3. On non-200 response, handle the error and exit gracefully:
   - `401` — invalid/expired API key
   - `403` — access forbidden
   - `404` — endpoint not found (check `QODO_ENVIRONMENT_NAME`)
   - `429` — rate limit exceeded
   - `5xx` — API temporarily unavailable
   - connection error — check internet connection
4. Parse `rules` array from JSON response body
5. Append page rules to accumulated list
6. If rules returned on this page < 50 → last page, stop
7. Otherwise increment page and repeat from step 2
8. Safety limit: stop after 100 pages (5000 rules max)

## API URL

Construct `{API_URL}` from `ENVIRONMENT_NAME` (read from `~/.qodo/config.json`):

| `ENVIRONMENT_NAME` | `{API_URL}` |
|---|---|
| set (e.g. `staging`) | `https://qodo-platform.staging.qodo.ai/rules/v1` |

## After Fetching

If total rules == 0, inform the user no rules are configured for the repository scope and exit gracefully.


================================================
FILE: .claude/skills/get-qodo-rules/references/repository-scope.md
================================================
# Repository Scope Detection

## Extracting Repository Scope from Git Remote URL

Parse the `origin` remote URL to derive the scope path. Both URL formats are supported:

- SSH: `git@github.com:org/repo.git` → `/org/repo/`
- HTTPS: `https://github.com/org/repo.git` → `/org/repo/`

If no remote is found, exit silently. If the URL cannot be parsed, inform the user and exit gracefully.

## Module-Level Scope Detection

If the current working directory is inside a `modules/*` subdirectory relative to the repository root, use it as the query scope:

- `modules/rules/src/service.py` → query scope: `/org/repo/modules/rules/`
- repository root or any other path → query scope: `/org/repo/`

## Scope Hierarchy

The API returns all rules matching the query scope via prefix matching:

| Query scope | Rules returned |
|---|---|
| `/org/repo/modules/rules/` | universal + org + repo + path-level rules |
| `/org/repo/` | universal + org + repo-level rules |


================================================
FILE: .claude/skills/qodo-pr-resolver/SKILL.md
================================================
---
name: qodo-pr-resolver
description: Review and resolve PR issues with Qodo - get AI-powered code review issues and fix them interactively (GitHub, GitLab, Bitbucket, Azure DevOps)
version: 0.3.0
triggers:
  - qodo.?pr.?resolver
  - pr.?resolver
  - resolve.?pr
  - qodo.?fix
  - fix.?qodo
  - qodo.?review
  - review.?qodo
  - qodo.?issues?
  - show.?qodo
  - get.?qodo
  - qodo.?resolve
---

# Qodo PR Resolver

Fetch Qodo review issues for your current branch's PR/MR, fix them interactively or in batch, and reply to each inline comment with the decision. Supports GitHub, GitLab, Bitbucket, and Azure DevOps.

## Prerequisites

### Required Tools:
- **Git** - For branch operations
- **Git Provider CLI** - One of: `gh` (GitHub), `glab` (GitLab), `bb` (Bitbucket), or `az` (Azure DevOps)

**Installation and authentication details:** See [providers.md](./resources/providers.md) for provider-specific setup instructions.

### Required Context:
- Must be in a git repository
- Repository must be hosted on a supported git provider (GitHub, GitLab, Bitbucket, or Azure DevOps)
- Current branch must have an open PR/MR
- PR/MR must have been reviewed by Qodo (pr-agent-pro bot, qodo-merge[bot], etc.)

### Quick Check:
```bash
git --version                                    # Check git installed
git remote get-url origin                        # Identify git provider
```

See [providers.md](./resources/providers.md) for provider-specific verification commands.

## Understanding Qodo Reviews

Qodo (formerly Codium AI) is an AI-powered code review tool that analyzes PRs/MRs with compliance checks, bug detection, and code quality suggestions.

### Bot Identifiers
Look for comments from: **`pr-agent-pro`**, **`pr-agent-pro-staging`**, **`qodo-merge[bot]`**, **`qodo-ai[bot]`**

### Review Comment Types
1. **PR Compliance Guide** 🔍 - Security/ticket/custom compliance with 🟢/🟡/🔴/⚪ indicators
2. **PR Code Suggestions** ✨ - Categorized improvements with importance ratings
3. **Code Review by Qodo** - Structured issues with 🐞/📘/📎 sections and agent prompts (most detailed)

## Instructions

When the user asks for a code review, to see Qodo issues, or fix Qodo comments:

### Step 0: Check code push status

Check for uncommitted changes, unpushed commits, and get the current branch.

#### Scenario A: Uncommitted changes exist

- Inform: "⚠️ You have uncommitted changes. These won't be included in the Qodo review."
- Ask: "Would you like to commit and push them first?"
- If yes: Wait for user action, then proceed to Step 1
- If no: Warn "Proceeding with review of pushed code only" and continue to Step 1

#### Scenario B: Unpushed commits exist

(no uncommitted changes)

- Inform: "⚠️ You have N unpushed commits. Qodo hasn't reviewed them yet."
- Ask: "Would you like to push them now?"
- If yes: Execute `git push`, inform "Pushed! Qodo will review shortly. Please wait ~5 minutes then run this skill again."
- Exit skill (don't proceed - Qodo needs time to review)
- If no: Warn "Proceeding with existing PR review" and continue to Step 1

#### Scenario C: Everything pushed

(both uncommitted changes and unpushed commits are empty)

- Proceed to Step 1

### Step 1: Detect git provider

Detect git provider from the remote URL (`git remote get-url origin`).

See [providers.md](./resources/providers.md) for provider detection patterns.

### Step 2: Find the open PR/MR

Find the open PR/MR for this branch using the provider's CLI.

See [providers.md § Find Open PR/MR](./resources/providers.md#find-open-prmr) for provider-specific commands.

### Step 3: Get Qodo review comments

Get the Qodo review comments using the provider's CLI.

Qodo typically posts both a **summary comment** (PR-level, containing all issues) and **inline review comments** (one per issue, attached to specific lines of code). You must fetch both.

See [providers.md § Fetch Review Comments](./resources/providers.md#fetch-review-comments) for provider-specific commands.

Look for comments where the author is "qodo-merge[bot]", "pr-agent-pro", "pr-agent-pro-staging" or similar Qodo bot name.

#### Step 3a: Check if review is still in progress

- If any comment contains "Come back again in a few minutes" or "An AI review agent is analysing this pull request", the review is still running
- In this case, inform the user: "⏳ Qodo review is still in progress. Please wait a few minutes and try again."
- Exit early - don't try to parse incomplete reviews

#### Step 3b: Deduplicate issues

Deduplicate issues across summary and inline comments:

- Qodo posts each issue in two places: once in the **summary comment** (PR-level) and once as an **inline review comment** (attached to the specific code line). These will share the same issue title.
- Qodo may also post multiple summary comments (Compliance Guide, Code Suggestions, Code Review, etc.) where issues can overlap with slightly different wording.
- Deduplicate by matching on **issue title** (primary key - the same title means the same issue):
  - If an issue appears in both the summary comment and as an inline comment, merge them into a single issue
  - Prefer the **inline comment** for file location (it has the exact line context)
  - Prefer the **summary comment** for severity, type, and agent prompt (it is more detailed)
  - **IMPORTANT:** Preserve each issue's **inline review comment ID** — you will need it later (Step 8) to reply directly to that comment with the decision
- Also deduplicate across multiple summary comments by location (file path + line numbers) as a secondary key
- If the same issue appears in multiple places, combine the agent prompts

### Step 4: Parse and display the issues

- Extract the review body/comments from Qodo's review
- Parse out individual issues/suggestions
- **IMPORTANT: Preserve Qodo's exact issue titles verbatim** — do not rename, paraphrase, or summarize them. Use the title exactly as Qodo wrote it.
- **IMPORTANT: Preserve Qodo's original ordering** — display issues in the same order Qodo listed them. Qodo already orders by severity.
- Extract location, issue description, and suggested fix
- Extract the agent prompt from Qodo's suggestion (the description of what needs to be fixed)

#### Severity mapping

Derive severity from Qodo's action level and position:

1. **Action level determines severity range:**
   - **"Action required"** issues → Can only be 🔴 CRITICAL or 🟠 HIGH
   - **"Review recommended"** / **"Remediation recommended"** issues → Can only be 🟡 MEDIUM or ⚪ LOW
   - **"Other"** / **"Advisory comments"** issues → Always ⚪ LOW (lowest priority)

2. **Qodo's position within each action level determines the specific severity:**
   - Group issues by action level ("Action required" vs "Review recommended" vs "Other")
   - Within "Action required" and "Review recommended" groups: earlier positions → higher severity, later positions → lower severity
   - Split point: roughly first half of each group gets the higher severity, second half gets the lower
   - All "Other" issues are treated as ⚪ LOW regardless of position

**Example:** 7 "Action required" issues would be split as:
- Issues 1-3: 🔴 CRITICAL
- Issues 4-7: 🟠 HIGH
- Result: No MEDIUM or LOW issues (because there are no "Review recommended" or "Other" issues)

**Example:** 5 "Action required" + 3 "Review recommended" + 2 "Other" issues would be split as:
- Issues 1-2 or 1-3: 🔴 CRITICAL (first ~half of "Action required")
- Issues 3-5 or 4-5: 🟠 HIGH (second ~half of "Action required")
- Issues 6-7: 🟡 MEDIUM (first ~half of "Review recommended")
- Issue 8: ⚪ LOW (second ~half of "Review recommended")
- Issues 9-10: ⚪ LOW (all "Other" issues)

**Action guidelines:**
- 🔴 CRITICAL / 🟠 HIGH ("Action required"): Always "Fix"
- 🟡 MEDIUM ("Review recommended"): Usually "Fix", can "Defer" if low impact
- ⚪ LOW ("Review recommended" or "Other"): Can be "Defer" unless quick to fix; "Other" issues are lowest priority

#### Output format

Display as a markdown table in Qodo's exact original ordering (do NOT reorder by severity - Qodo's order IS the severity ranking):

```
Qodo Issues for PR #123: [PR Title]

| # | Severity | Issue Title | Issue Details | Type | Action |
|---|----------|-------------|---------------|------|--------|
| 1 | 🔴 CRITICAL | Insecure authentication check | • **Location:** src/auth/service.py:42<br><br>• **Issue:** Authorization logic is inverted | 🐞 Bug ⛨ Security | Fix |
| 2 | 🔴 CRITICAL | Missing input validation | • **Location:** src/api/handlers.py:156<br><br>• **Issue:** User input not sanitized before database query | 📘 Rule violation ⛯ Reliability | Fix |
| 3 | 🟠 HIGH | Database query not awaited | • **Location:** src/db/repository.py:89<br><br>• **Issue:** Async call missing await keyword | 🐞 Bug ✓ Correctness | Fix |
```

### Step 5: Ask user for fix preference

After displaying the table, ask the user how they want to proceed using AskUserQuestion:

**Options:**
- 🔍 "Review each issue" - Review and approve/defer each issue individually (recommended for careful review)
- ⚡ "Auto-fix all" - Automatically apply all fixes marked as "Fix" without individual approval (faster, but less control)
- ❌ "Cancel" - Exit without making changes

**Based on the user's choice:**
- If "Review each issue": Proceed to Step 6 (manual review)
- If "Auto-fix all": Skip to Step 7 (auto-fix mode - apply all "Fix" issues automatically using Qodo's agent prompts)
- If "Cancel": Exit the skill

### Step 6: Review and fix issues (manual mode)

If "Review each issue" was selected:

- For each issue marked as "Fix" (starting with CRITICAL):
  - Read the relevant file(s) to understand the current code
  - Implement the fix by **executing the Qodo agent prompt as a direct instruction**. The agent prompt is the fix specification — follow it literally, do not reinterpret or improvise a different solution. Only deviate if the prompt is clearly outdated relative to the current code (e.g. references lines that no longer exist).
  - Calculate the proposed fix in memory (DO NOT use Edit or Write tool yet)
  - **Present the fix and ask for approval in a SINGLE step:**
    1. Show a brief header with issue title and location
    2. **Show Qodo's agent prompt in full** so the user can verify the fix matches it
    3. Display current code snippet
    4. Display proposed change as markdown diff
    5. Immediately use AskUserQuestion with these options:
       - ✅ "Apply fix" - Apply the proposed change
       - ⏭️ "Defer" - Skip this issue (will prompt for reason)
       - 🔧 "Modify" - User wants to adjust the fix first
  - **WAIT for user's choice via AskUserQuestion**
  - **If "Apply fix" selected:**
    - Apply change using Edit tool (or Write if creating new file)
    - Reply to the Qodo inline comment with the decision (see Step 8 for inline reply commands)
    - Git commit the fix: `git add <modified-files> && git commit -m "fix: <issue title>"`
    - Confirm: "✅ Fix applied, commented, and committed!"
    - Mark issue as completed
  - **If "Defer" selected:**
    - Ask for deferral reason using AskUserQuestion
    - Reply to the Qodo inline comment with the deferral (see Step 8 for inline reply commands)
    - Record reason and move to next issue
  - **If "Modify" selected:**
    - Inform user they can make changes manually
    - Move to next issue
- Continue until all "Fix" issues are addressed or the user decides to stop

#### Important notes

**Single-step approval with AskUserQuestion:**
- NO native Edit UI (no persistent permissions possible)
- Each fix requires explicit approval via custom question
- Clearer options, no risk of accidental auto-approval

**CRITICAL:** Single validation only - do NOT show the diff separately and then ask. Combine the diff display and the question into ONE message. The user should see: brief context → current code → proposed diff → AskUserQuestion, all at once.

**Example:** Show location, Qodo's guidance, current code, proposed diff, then AskUserQuestion with options (✅ Apply fix / ⏭️ Defer / 🔧 Modify). Wait for user choice, apply via Edit tool if approved.

### Step 7: Auto-fix mode

If "Auto-fix all" was selected:

- For each issue marked as "Fix" (starting with CRITICAL):
  - Read the relevant file(s) to understand the current code
  - Implement the fix by **executing the Qodo agent prompt as a direct instruction**. The agent prompt is the fix specification — follow it literally, do not reinterpret or improvise a different solution. Only deviate if the prompt is clearly outdated relative to the current code (e.g. references lines that no longer exist).
  - Apply the fix using Edit tool
  - Reply to the Qodo inline comment with the decision (see Step 8 for inline reply commands)
  - Git commit the fix: `git add <modified-files> && git commit -m "fix: <issue title>"`
  - Report each fix with the agent prompt that was followed:
    > ✅ **Fixed: [Issue Title]** at `[Location]`
    > **Agent prompt:** [the Qodo agent prompt used]
  - Mark issue as completed
- After all auto-fixes are applied, display summary:
  - List of all issues that were fixed
  - List of any issues that were skipped (with reasons)

### Step 8: Post summary to PR/MR

**REQUIRED:** After all issues have been reviewed (fixed or deferred), ALWAYS post a comment summarizing the actions taken, even if all issues were deferred.

See [providers.md § Post Summary Comment](./resources/providers.md#post-summary-comment) for provider-specific commands and summary format.

**After posting the summary, resolve the Qodo review comment:**

Find the Qodo "Code Review by Qodo" comment and mark it as resolved or react to acknowledge it.

See [providers.md § Resolve Qodo Review Comment](./resources/providers.md#resolve-qodo-review-comment) for provider-specific commands.

If resolve fails (comment not found, API error), continue — the summary comment is the important part.

### Step 9: Push to remote

If any fixes were applied (commits were created in Steps 6/7), ask the user if they want to push:
- If yes: `git push`
- If no: Inform them they can push later with `git push`

**Important:** If all issues were deferred, there are no commits to push — skip this step.

### Special cases

#### Unsupported git provider

If the remote URL doesn't match GitHub, GitLab, Bitbucket, or Azure DevOps, inform the user and exit.

See [providers.md § Error Handling](./resources/providers.md#error-handling) for details.

#### No PR/MR exists

- Inform: "No PR/MR found for branch `<branch-name>`"
- Ask: "Would you like me to create a PR/MR?"
- If yes: Use appropriate CLI to create PR/MR (see [providers.md § Create PR/MR](./resources/providers.md#create-prmr-special-case)), then inform "PR created! Qodo will review it shortly. Run this skill again in ~5 minutes."
- If no: Exit skill

**IMPORTANT:** Do NOT proceed without a PR/MR

#### No Qodo review yet

- Check if PR/MR has comments from Qodo bots (pr-agent-pro, qodo-merge[bot], etc.)
- If no Qodo comments found: Inform "Qodo hasn't reviewed this PR/MR yet. Please wait a few minutes for Qodo to analyze it."
- Exit skill (do NOT attempt manual review)

**IMPORTANT:** This skill only works with Qodo reviews, not manual reviews

#### Review in progress

If "Come back again in a few minutes" message is found, inform user to wait and try again, then exit.

#### Missing CLI tool

If the detected provider's CLI is not installed, provide installation instructions and exit.

See [providers.md § Error Handling](./resources/providers.md#error-handling) for provider-specific installation commands.

#### Inline reply commands

Used per-issue in Steps 6 and 7 to reply to Qodo's inline comments:

Use the inline comment ID preserved during deduplication (Step 3b) to reply directly to Qodo's comment.

See [providers.md § Reply to Inline Comments](./resources/providers.md#reply-to-inline-comments) for provider-specific commands and reply format.

Keep replies short (one line). If a reply fails, log it and continue.


================================================
FILE: .claude/skills/qodo-pr-resolver/resources/providers.md
================================================
# Git Provider Commands Reference

This document contains all provider-specific CLI commands and API interactions for the Qodo PR Resolver skill. Reference this file when implementing provider-specific operations.

## Supported Providers

- GitHub (via `gh` CLI)
- GitLab (via `glab` CLI)
- Bitbucket (via `bb` CLI)
- Azure DevOps (via `az` CLI with DevOps extension)

## Provider Detection

Detect the git provider from the remote URL:

```bash
git remote get-url origin
```

Match against:
- `github.com` → GitHub
- `gitlab.com` → GitLab
- `bitbucket.org` → Bitbucket
- `dev.azure.com` → Azure DevOps

## Prerequisites by Provider

### GitHub

**CLI:** `gh`
- **Install:** `brew install gh` or [cli.github.com](https://cli.github.com/)
- **Authenticate:** `gh auth login`
- **Verify:**
  ```bash
  gh --version && gh auth status
  ```

### GitLab

**CLI:** `glab`
- **Install:** `brew install glab` or [glab.readthedocs.io](https://glab.readthedocs.io/)
- **Authenticate:** `glab auth login`
- **Verify:**
  ```bash
  glab --version && glab auth status
  ```

### Bitbucket

**CLI:** `bb` or API access
- **Install:** See [bitbucket.org/product/cli](https://bitbucket.org/product/cli)
- **Verify:**
  ```bash
  bb --version
  ```

### Azure DevOps

**CLI:** `az` with DevOps extension
- **Install:** `brew install azure-cli` or [docs.microsoft.com/cli/azure](https://docs.microsoft.com/cli/azure)
- **Install extension:** `az extension add --name azure-devops`
- **Authenticate:** `az login` then `az devops configure --defaults organization=https://dev.azure.com/yourorg project=yourproject`
- **Verify:**
  ```bash
  az --version && az devops
  ```

## Find Open PR/MR

Get the PR/MR number for the current branch:

### GitHub

```bash
gh pr list --head <branch-name> --state open --json number,title
```

### GitLab

```bash
glab mr list --source-branch <branch-name> --state opened
```

### Bitbucket

```bash
bb pr list --source-branch <branch-name> --state OPEN
```

### Azure DevOps

```bash
az repos pr list --source-branch <branch-name> --status active --output json
```

## Fetch Review Comments

Qodo posts both **summary comments** (PR-level) and **inline review comments** (per-line). Fetch both.

### GitHub

```bash
# PR-level comments (includes the summary comment with all issues)
gh pr view <pr-number> --json comments

# Inline review comments (per-line comments on specific code)
gh api repos/{owner}/{repo}/pulls/<pr-number>/comments
```

### GitLab

```bash
# All MR notes including inline comments
glab mr view <mr-iid> --comments
```

### Bitbucket

```bash
# All PR comments including inline comments
bb pr view <pr-id> --comments
```

### Azure DevOps

```bash
# PR-level threads (includes summary comments)
az repos pr show --id <pr-id> --output json

# All PR threads including inline comments
az repos pr policy list --id <pr-id> --output json
az repos pr thread list --id <pr-id> --output json
```

## Reply to Inline Comments

Use the inline comment ID preserved during deduplication to reply directly to Qodo's comments.

### GitHub

```bash
gh api repos/{owner}/{repo}/pulls/<pr-number>/comments/<inline-comment-id>/replies \
  -X POST \
  -f body='<reply-body>'
```

**Reply format:**
- **Fixed:** `✅ **Fixed** — <brief description of what was changed>`
- **Deferred:** `⏭️ **Deferred** — <reason for deferring>`

### GitLab

```bash
glab api "/projects/:id/merge_requests/<mr-iid>/discussions/<discussion-id>/notes" \
  -X POST \
  -f body='<reply-body>'
```

### Bitbucket

```bash
bb api "/2.0/repositories/{workspace}/{repo}/pullrequests/<pr-id>/comments" \
  -X POST \
  -f 'content.raw=<reply-body>' \
  -f 'parent.id=<inline-comment-id>'
```

### Azure DevOps

```bash
az repos pr thread comment add \
  --id <pr-id> \
  --thread-id <thread-id> \
  --content '<reply-body>'
```

## Post Summary Comment

After reviewing all issues, post a summary comment to the PR/MR.

### GitHub

```bash
gh pr comment <pr-number> --body '<comment-body>'
```

### GitLab

```bash
glab mr comment <mr-iid> --message '<comment-body>'
```

### Bitbucket

```bash
bb pr comment <pr-id> '<comment-body>'
```

### Azure DevOps

```bash
az repos pr thread create \
  --id <pr-id> \
  --comment-content '<comment-body>'
```

**Summary format:**

```markdown
## Qodo Fix Summary

Reviewed and addressed Qodo review issues:

### ✅ Fixed Issues
- **Issue Title** (Severity) - Brief description of what was fixed

### ⏭️ Deferred Issues
- **Issue Title** (Severity) - Reason for deferring

---
*Generated by Qodo PR Resolver skill*
```

## Resolve Qodo Review Comment

After posting the summary, resolve the main Qodo review comment.

**Steps:**
1. Fetch all PR/MR comments
2. Find the Qodo bot comment containing "Code Review by Qodo"
3. Resolve or react to the comment

### GitHub

```bash
# 1. Fetch comments to find the comment ID
gh pr view <pr-number> --json comments

# 2. React with thumbs up to acknowledge
gh api "repos/{owner}/{repo}/issues/comments/<comment-id>/reactions" \
  -X POST \
  -f content='+1'
```

### GitLab

```bash
# 1. Fetch discussions to find the discussion ID
glab api "/projects/:id/merge_requests/<mr-iid>/discussions"

# 2. Resolve the discussion
glab api "/projects/:id/merge_requests/<mr-iid>/discussions/<discussion-id>" \
  -X PUT \
  -f resolved=true
```

### Bitbucket

```bash
# Fetch comments via bb api, find the comment ID, then update to resolved status
bb api "/2.0/repositories/{workspace}/{repo}/pullrequests/<pr-id>/comments/<comment-id>" \
  -X PUT \
  -f 'resolved=true'
```

### Azure DevOps

```bash
# Mark the thread as resolved
az repos pr thread update \
  --id <pr-id> \
  --thread-id <thread-id> \
  --status resolved
```

## Create PR/MR (Special Case)

If no PR/MR exists for the current branch, offer to create one.

### GitHub

```bash
gh pr create --title '<title>' --body '<body>'
```

### GitLab

```bash
glab mr create --title '<title>' --description '<body>'
```

### Bitbucket

```bash
bb pr create --title '<title>' --description '<body>'
```

### Azure DevOps

```bash
az repos pr create \
  --title '<title>' \
  --description '<body>' \
  --source-branch <branch-name> \
  --target-branch main
```

## Error Handling

### Missing CLI Tool

If the detected provider's CLI is not installed:
1. Inform the user: "❌ Missing required CLI tool: `<cli-name>`"
2. Provide installation instructions from the Prerequisites section
3. Exit the skill

### Unsupported Provider

If the remote URL doesn't match any supported provider:
1. Inform: "❌ Unsupported git provider detected: `<url>`"
2. List supported providers: GitHub, GitLab, Bitbucket, Azure DevOps
3. Exit the skill

### API Failures

If inline reply or summary posting fails:
- Log the error
- Continue with remaining operations
- The workflow should not abort due to comment posting failures


================================================
FILE: .claude/skills/setup/SKILL.md
================================================
---
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.
---

# NanoClaw Setup

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. Ask the user for permission when needed, then do the work.

**UX Note:** Use `AskUserQuestion` for all user-facing questions.

## 0. Git & Fork Setup

Check the git remote configuration to ensure the user has a fork and upstream is configured.

Run:
- `git remote -v`

**Case A — `origin` points to `qwibitai/nanoclaw` (user cloned directly):**

The user cloned instead of forking. AskUserQuestion: "You cloned NanoClaw directly. We recommend forking so you can push your customizations. Would you like to set up a fork?"
- Fork now (recommended) — walk them through it
- Continue without fork — they'll only have local changes

If fork: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask them 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
```
Verify with `git remote -v`.

If continue without fork: add upstream so they can still pull updates:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```

**Case B — `origin` points to user's fork, no `upstream` remote:**

Add upstream:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```

**Case C — both `origin` (user's fork) and `upstream` (qwibitai) exist:**

Already configured. Continue.

**Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`.

## 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 `npx 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 APPLE_CONTAINER and DOCKER values for step 3

## 3. Container Runtime

### 3a. Choose runtime

Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1.

- PLATFORM=linux → Docker (only option)
- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 3c.
- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker

### 3a-docker. Install Docker

- DOCKER=running → continue to 4b
- 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. Apple Container conversion gate (if needed)

**If the chosen runtime is Apple Container**, you MUST check whether the source code has already been converted from Docker to Apple Container. Do NOT skip this step. Run:

```bash
grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "ALREADY_CONVERTED" || echo "NEEDS_CONVERSION"
```

**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step.

**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c.

**If the chosen runtime is Docker**, no conversion is needed. Continue to 3c.

### 3c. Build and test

Run `npx tsx setup/index.ts --step container -- --runtime <chosen>` 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` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). 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. Claude Authentication (No Script)

If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure?

AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key?

**Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=<token>` to `.env`. Do NOT collect the token in chat.

**API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`.

## 5. Set Up Channels

AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
- WhatsApp (authenticates via QR code or pairing code)
- Telegram (authenticates via bot token from @BotFather)
- Slack (authenticates via Slack app with Socket Mode)
- Discord (authenticates via Discord bot token)

**Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct.

For each selected channel, invoke its skill:

- **WhatsApp:** Invoke `/add-whatsapp`
- **Telegram:** Invoke `/add-telegram`
- **Slack:** Invoke `/add-slack`
- **Discord:** Invoke `/add-discord`

Each skill will:
1. Install the channel code (via `git merge` of the skill branch)
2. Collect credentials/tokens and write to `.env`
3. Authenticate (WhatsApp QR/pairing, or verify token-based connection)
4. Register the chat with the correct JID format
5. Build and verify

**After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages:

```bash
npm install && npm run build
```

If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6.

## 6. Mount Allowlist

AskUserQuestion: Agent access to external directories?

**No:** `npx tsx setup/index.ts --step mounts -- --empty`
**Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'`

## 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 `npx 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.

## 8. Verify

Run `npx tsx setup/index.ts --step verify` and parse the status block.

**If STATUS=failed, fix each:**
- SERVICE=stopped → `npm 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
- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`

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), missing `.env` (step 4), missing channel credentials (re-invoke channel skill).

**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), 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: `npx 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`


================================================
FILE: .claude/skills/update-nanoclaw/SKILL.md
================================================
---
name: update-nanoclaw
description: Efficiently bring upstream NanoClaw updates into a customized install, with preview, selective cherry-pick, and low token usage.
---

# About

Your NanoClaw fork drifts from upstream as you customize it. This skill pulls upstream changes into your install without losing your modifications.

Run `/update-nanoclaw` in Claude Code.

## How it works

**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).

**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.

**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories:
- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill
- **Source** (`src/`): may conflict if you modified the same files
- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed

**Update paths** (you pick one):
- `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass.
- `cherry-pick`: `git cherry-pick <hashes>`. Pull in only the commits you want.
- `rebase`: `git rebase upstream/<branch>`. Linear history, but conflicts resolve per-commit.
- `abort`: just view the changelog, change nothing.

**Conflict preview**: before merging, runs a dry-run (`git merge --no-commit --no-ff`) to show which files would conflict. You can still abort at this point.

**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact.

**Validation**: runs `npm run build` and `npm test`.

**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.

## Rollback

The backup tag is printed at the end of each run:
```
git reset --hard pre-update-<hash>-<timestamp>
```

Backup branch `backup/pre-update-<hash>-<timestamp>` also exists.

## Token usage

Only opens files with actual conflicts. Uses `git log`, `git diff`, and `git status` for everything else. Does not scan or refactor unrelated code.

---

# Goal
Help a user with a customized NanoClaw install safely incorporate upstream changes without a fresh reinstall and without blowing tokens.

# Operating principles
- Never proceed with a dirty working tree.
- Always create a rollback point (backup branch + tag) before touching anything.
- Prefer git-native operations (fetch, merge, cherry-pick). Do not manually rewrite files except conflict markers.
- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.

# Step 0: Preflight (stop early if unsafe)
Run:
- `git status --porcelain`
If output is non-empty:
- Tell the user to commit or stash first, then stop.

Confirm remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Add it: `git remote add upstream <user-provided-url>`
- Then: `git fetch upstream --prune`

Determine the upstream branch name:
- `git branch -r | grep upstream/`
- If `upstream/main` exists, use `main`.
- If only `upstream/master` exists, use `master`.
- Otherwise, ask the user which branch to use.
- Store this as UPSTREAM_BRANCH for all subsequent commands. Every command below that references `upstream/main` should use `upstream/$UPSTREAM_BRANCH` instead.

Fetch:
- `git fetch upstream --prune`

# Step 1: Create a safety net
Capture current state:
- `HASH=$(git rev-parse --short HEAD)`
- `TIMESTAMP=$(date +%Y%m%d-%H%M%S)`

Create backup branch and tag (using timestamp to avoid collisions on retry):
- `git branch backup/pre-update-$HASH-$TIMESTAMP`
- `git tag pre-update-$HASH-$TIMESTAMP`

Save the tag name for later reference in the summary and rollback instructions.

# Step 2: Preview what upstream changed (no edits yet)
Compute common base:
- `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`

Show upstream commits since BASE:
- `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`

Show local commits since BASE (custom drift):
- `git log --oneline $BASE..HEAD`

Show file-level impact from upstream:
- `git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH`

Bucket the upstream changed files:
- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
- **Source** (`src/`): may conflict if user modified the same files
- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed
- **Other**: docs, tests, misc

Present these buckets to the user and ask them to choose one path using AskUserQuestion:
- A) **Full update**: merge all upstream changes
- B) **Selective update**: cherry-pick specific upstream commits
- C) **Abort**: they only wanted the preview
- D) **Rebase mode**: advanced, linear history (warn: resolves conflicts per-commit)

If Abort: stop here.

# Step 3: Conflict preview (before committing anything)
If Full update or Rebase:
- Dry-run merge to preview conflicts. Run these as a single chained command so the abort always executes:
  ```
  git merge --no-commit --no-ff upstream/$UPSTREAM_BRANCH; git diff --name-only --diff-filter=U; git merge --abort
  ```
- If conflicts were listed: show them and ask user if they want to proceed.
- If no conflicts: tell user it is clean and proceed.

# Step 4A: Full update (MERGE, default)
Run:
- `git merge upstream/$UPSTREAM_BRANCH --no-edit`

If conflicts occur:
- Run `git status` and identify conflicted files.
- For each conflicted file:
  - Open the file.
  - Resolve only conflict markers.
  - Preserve intentional local customizations.
  - Incorporate upstream fixes/improvements.
  - Do not refactor surrounding code.
  - `git add <file>`
- When all resolved:
  - If merge did not auto-commit: `git commit --no-edit`

# Step 4B: Selective update (CHERRY-PICK)
If user chose Selective:
- Recompute BASE if needed: `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`
- Show commit list again: `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`
- Ask user which commit hashes they want.
- Apply: `git cherry-pick <hash1> <hash2> ...`

If conflicts during cherry-pick:
- Resolve only conflict markers, then:
  - `git add <file>`
  - `git cherry-pick --continue`
If user wants to stop:
  - `git cherry-pick --abort`

# Step 4C: Rebase (only if user explicitly chose option D)
Run:
- `git rebase upstream/$UPSTREAM_BRANCH`

If conflicts:
- Resolve conflict markers only, then:
  - `git add <file>`
  - `git rebase --continue`
If it gets messy (more than 3 rounds of conflicts):
  - `git rebase --abort`
  - Recommend merge instead.

# Step 5: Validation
Run:
- `npm run build`
- `npm test` (do not fail the flow if tests are not configured)

If build fails:
- Show the error.
- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
- Do not refactor unrelated code.
- If unclear, ask the user before making changes.

# Step 6: Breaking changes check
After validation succeeds, check if the update introduced any breaking changes.

Determine which CHANGELOG entries are new by diffing against the backup tag:
- `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md`

Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is:
```
[BREAKING] <description>. Run `/<skill-name>` to <action>.
```

If no `[BREAKING]` lines are found:
- Skip this step silently. Proceed to Step 7 (skill updates check).

If one or more `[BREAKING]` lines are found:
- Display a warning header to the user: "This update includes breaking changes that may require action:"
- For each breaking change, display the full description.
- Collect all skill names referenced in the breaking change entries (the `/<skill-name>` part).
- Use AskUserQuestion to ask the user which migration skills they want to run now. Options:
  - One option per referenced skill (e.g., "Run /add-whatsapp to re-add WhatsApp channel")
  - "Skip — I'll handle these manually"
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).

# Step 7: Check for skill updates
After the summary, check if skills are distributed as branches in this repo:
- `git branch -r --list 'upstream/skill/*'`

If any `upstream/skill/*` branches exist:
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
  - Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
  - Option 2: "No, skip" (description: "You can run /update-skills later any time")
- If user selects yes, invoke `/update-skills` using the Skill tool.
- After the skill completes (or if user selected no), proceed to Step 8.

# Step 8: Summary + rollback instructions
Show:
- Backup tag: the tag name created in Step 1
- New HEAD: `git rev-parse --short HEAD`
- Upstream HEAD: `git rev-parse --short upstream/$UPSTREAM_BRANCH`
- Conflicts resolved (list files, if any)
- Breaking changes applied (list skills run, if any)
- Remaining local diff vs upstream: `git diff --name-only upstream/$UPSTREAM_BRANCH..HEAD`

Tell the user:
- To rollback: `git reset --hard <backup-tag-from-step-1>`
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
- Restart the service to apply changes:
  - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
  - If running manually: restart `npm run dev`


================================================
FILE: .claude/skills/update-skills/SKILL.md
================================================
---
name: update-skills
description: Check for and apply updates to installed skill branches from upstream.
---

# About

Skills are distributed as git branches (`skill/*`). When you install a skill, you merge its branch into your repo. This skill checks upstream for newer commits on those skill branches and helps you update.

Run `/update-skills` in Claude Code.

## How it works

**Preflight**: checks for clean working tree and upstream remote.

**Detection**: fetches upstream, lists all `upstream/skill/*` branches, determines which ones you've previously merged (via merge-base), and checks if any have new commits.

**Selection**: presents a list of skills with available updates. You pick which to update.

**Update**: merges each selected skill branch, resolves conflicts if any, then validates with build + test.

---

# Goal
Help users update their installed skill branches from upstream without losing local customizations.

# Operating principles
- Never proceed with a dirty working tree.
- Only offer updates for skills the user has already merged (installed).
- Use git-native operations. Do not manually rewrite files except conflict markers.
- Keep token usage low: rely on `git` commands, only open files with actual conflicts.

# Step 0: Preflight

Run:
- `git status --porcelain`

If output is non-empty:
- Tell the user to commit or stash first, then stop.

Check remotes:
- `git remote -v`

If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- `git remote add upstream <url>`

Fetch:
- `git fetch upstream --prune`

# Step 1: Detect installed skills with available updates

List all upstream skill branches:
- `git branch -r --list 'upstream/skill/*'`

For each `upstream/skill/<name>`:
1. Check if the user has merged this skill branch before:
   - `git merge-base --is-ancestor upstream/skill/<name>~1 HEAD` — if this succeeds (exit 0) for any ancestor commit of the skill branch, the user has merged it at some point. A simpler check: `git log --oneline --merges --grep="skill/<name>" HEAD` to see if there's a merge commit referencing this branch.
   - Alternative: `MERGE_BASE=$(git merge-base HEAD upstream/skill/<name>)` — if the merge base is NOT the initial commit and the merge base includes commits unique to the skill branch, it has been merged.
   - Simplest reliable check: compare `git merge-base HEAD upstream/skill/<name>` with `git merge-base HEAD upstream/main`. If the skill merge-base is strictly ahead of (or different from) the main merge-base, the user has merged this skill.
2. Check if there are new commits on the skill branch not yet in HEAD:
   - `git log --oneline HEAD..upstream/skill/<name>`
   - If this produces output, there are updates available.

Build three lists:
- **Updates available**: skills that are merged AND have new commits
- **Up to date**: skills that are merged and have no new commits
- **Not installed**: skills that have never been merged

# Step 2: Present results

If no skills have updates available:
- Tell the user all installed skills are up to date. List them.
- If there are uninstalled skills, mention them briefly (e.g., "3 other skills available in upstream that you haven't installed").
- Stop here.

If updates are available:
- Show the list of skills with updates, including the number of new commits for each:
  ```
  skill/<name>: 3 new commits
  skill/<other>: 1 new commit
  ```
- Also show skills that are up to date (for context).
- Use AskUserQuestion with `multiSelect: true` to let the user pick which skills to update.
  - One option per skill with updates, labeled with the skill name and commit count.
  - Add an option: "Skip — don't update any skills now"
- If user selects Skip, stop here.

# Step 3: Apply updates

For each selected skill (process one at a time):

1. Tell the user which skill is being updated.
2. Run: `git merge upstream/skill/<name> --no-edit`
3. If the merge is clean, move to the next skill.
4. If conflicts occur:
   - Run `git status` to identify conflicted files.
   - For each conflicted file:
     - Open the file.
     - Resolve only conflict markers.
     - Preserve intentional local customizations.
     - `git add <file>`
   - Complete the merge: `git commit --no-edit`

If a merge fails badly (e.g., cannot resolve conflicts):
- `git merge --abort`
- Tell the user this skill could not be auto-updated and they should resolve it manually.
- Continue with the remaining skills.

# Step 4: Validation

After all selected skills are merged:
- `npm run build`
- `npm test` (do not fail the flow if tests are not configured)

If build fails:
- Show the error.
- Only fix issues clearly caused by the merge (missing imports, type mismatches).
- Do not refactor unrelated code.
- If unclear, ask the user.

# Step 5: Summary

Show:
- Skills updated (list)
- Skills skipped or failed (if any)
- New HEAD: `git rev-parse --short HEAD`
- Any conflicts that were resolved (list files)

If the service is running, remind the user to restart it to pick up changes.


================================================
FILE: .claude/skills/use-local-whisper/SKILL.md
================================================
---
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 package-lock.json
  git add package-lock.json
  git merge --continue
}
```

This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.

### Validate

```bash
npm 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
npm 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`.


================================================
FILE: .claude/skills/x-integration/SKILL.md
================================================
---
name: x-integration
description: X (Twitter) integration for NanoClaw. Post tweets, like, reply, retweet, and quote. Use for setup, testing, or troubleshooting X functionality. Triggers on "setup x", "x integration", "twitter", "post tweet", "tweet".
---

# X (Twitter) Integration

Browser automation for X interactions via WhatsApp.

> **Compatibility:** NanoClaw v1.0.0. Directory structure may change in future versions.

## Features

| Action | Tool | Description |
|--------|------|-------------|
| Post | `x_post` | Publish new tweets |
| Like | `x_like` | Like any tweet |
| Reply | `x_reply` | Reply to tweets |
| Retweet | `x_retweet` | Retweet without comment |
| Quote | `x_quote` | Quote tweet with comment |

## Prerequisites

Before using this skill, ensure:

1. **NanoClaw is installed and running** - WhatsApp connected, service active
2. **Dependencies installed**:
   ```bash
   npm ls playwright dotenv-cli || npm install playwright dotenv-cli
   ```
3. **CHROME_PATH configured** in `.env` (if Chrome is not at default location):
   ```bash
   # Find your Chrome path
   mdfind "kMDItemCFBundleIdentifier == 'com.google.Chrome'" 2>/dev/null | head -1
   # Add to .env
   CHROME_PATH=/path/to/Google Chrome.app/Contents/MacOS/Google Chrome
   ```

## Quick Start

```bash
# 1. Setup authentication (interactive)
npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
# Verify: data/x-auth.json should exist after successful login

# 2. Rebuild container to include skill
./container/build.sh
# Verify: Output shows "COPY .claude/skills/x-integration/agent.ts"

# 3. Rebuild host and restart service
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw  # macOS
# Linux: systemctl --user restart nanoclaw
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
```

## Configuration

### Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `CHROME_PATH` | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` | Chrome executable path |
| `NANOCLAW_ROOT` | `process.cwd()` | Project root directory |
| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) |

Set in `.env` file (loaded via `dotenv-cli` at runtime):

```bash
# .env
CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
```

### Configuration File

Edit `lib/config.ts` to modify defaults:

```typescript
export const config = {
    // Browser viewport
    viewport: { width: 1280, height: 800 },

    // Timeouts (milliseconds)
    timeouts: {
        navigation: 30000,    // Page navigation
        elementWait: 5000,    // Wait for element
        afterClick: 1000,     // Delay after click
        afterFill: 1000,      // Delay after form fill
        afterSubmit: 3000,    // Delay after submit
        pageLoad: 3000,       // Initial page load
    },

    // Tweet limits
    limits: {
        tweetMaxLength: 280,
    },
};
```

### Data Directories

Paths relative to project root:

| Path | Purpose | Git |
|------|---------|-----|
| `data/x-browser-profile/` | Chrome profile with X session | Ignored |
| `data/x-auth.json` | Auth state marker | Ignored |
| `logs/nanoclaw.log` | Service logs (contains X operation logs) | Ignored |

## Architecture

```
┌─────────────────────────────────────────────────────────────┐
│  Container (Linux VM)                                       │
│  └── agent.ts → MCP tool definitions (x_post, etc.)    │
│      └── Writes IPC request to /workspace/ipc/tasks/       │
└──────────────────────┬──────────────────────────────────────┘
                       │ IPC (file system)
                       ▼
┌─────────────────────────────────────────────────────────────┐
│  Host (macOS)                                               │
│  └── src/ipc.ts → processTaskIpc()                         │
│      └── host.ts → handleXIpc()                         │
│          └── spawn subprocess → scripts/*.ts               │
│              └── Playwright → Chrome → X Website           │
└─────────────────────────────────────────────────────────────┘
```

### Why This Design?

- **API is expensive** - X official API requires paid subscription ($100+/month) for posting
- **Bot browsers get blocked** - X detects and bans headless browsers and common automation fingerprints
- **Must use user's real browser** - Reuses the user's actual Chrome on Host with real browser fingerprint to avoid detection
- **One-time authorization** - User logs in manually once, session persists in Chrome profile for future use

### File Structure

```
.claude/skills/x-integration/
├── SKILL.md          # This documentation
├── host.ts           # Host-side IPC handler
├── agent.ts          # Container-side MCP tool definitions
├── lib/
│   ├── config.ts     # Centralized configuration
│   └── browser.ts    # Playwright utilities
└── scripts/
    ├── setup.ts      # Interactive login
    ├── post.ts       # Post tweet
    ├── like.ts       # Like tweet
    ├── reply.ts      # Reply to tweet
    ├── retweet.ts    # Retweet
    └── quote.ts      # Quote tweet
```

### Integration Points

To integrate this skill into NanoClaw, make the following modifications:

---

**1. Host side: `src/ipc.ts`**

Add import after other local imports:
```typescript
import { handleXIpc } from '../.claude/skills/x-integration/host.js';
```

Modify `processTaskIpc` function's switch statement default case:
```typescript
// Find:
default:
logger.warn({ type: data.type }, 'Unknown IPC task type');

// Replace with:
default:
const handled = await handleXIpc(data, sourceGroup, isMain, DATA_DIR);
if (!handled) {
    logger.warn({ type: data.type }, 'Unknown IPC task type');
}
```

---

**2. Container side: `container/agent-runner/src/ipc-mcp.ts`**

Add import after `cron-parser` import:
```typescript
// @ts-ignore - Copied during Docker build from .claude/skills/x-integration/
import { createXTools } from './skills/x-integration/agent.js';
```

Add to the end of tools array (before the closing `]`):
```typescript
    ...createXTools({ groupFolder, isMain })
```

---

**3. Build script: `container/build.sh`**

Change build context from `container/` to project root (required to access `.claude/skills/`):
```bash
# Find:
docker build -t "${IMAGE_NAME}:${TAG}" .

# Replace with:
cd "$SCRIPT_DIR/.."
docker build -t "${IMAGE_NAME}:${TAG}" -f container/Dockerfile .
```

---

**4. Dockerfile: `container/Dockerfile`**

First, update the build context paths (required to access `.claude/skills/` from project root):
```dockerfile
# Find:
COPY agent-runner/package*.json ./
...
COPY agent-runner/ ./

# Replace with:
COPY container/agent-runner/package*.json ./
...
COPY container/agent-runner/ ./
```

Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN npm run build`:
```dockerfile
# Copy skill MCP tools
COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/
```

## Setup

All paths below are relative to project root (`NANOCLAW_ROOT`).

### 1. Check Chrome Path

```bash
# Check if Chrome exists at configured path
cat .env | grep CHROME_PATH
ls -la "$(grep CHROME_PATH .env | cut -d= -f2)" 2>/dev/null || \
echo "Chrome not found - update CHROME_PATH in .env"
```

### 2. Run Authentication

```bash
npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
```

This opens Chrome for manual X login. Session saved to `data/x-browser-profile/`.

**Verify success:**
```bash
cat data/x-auth.json  # Should show {"authenticated": true, ...}
```

### 3. Rebuild Container

```bash
./container/build.sh
```

**Verify success:**
```bash
./container/build.sh 2>&1 | grep -i "agent.ts"  # Should show COPY line
```

### 4. Restart Service

```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw  # macOS
# Linux: systemctl --user restart nanoclaw
```

**Verify success:**
```bash
launchctl list | grep nanoclaw  # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status nanoclaw
```

## Usage via WhatsApp

Replace `@Assistant` with your configured trigger name (`ASSISTANT_NAME` in `.env`):

```
@Assistant post a tweet: Hello world!

@Assistant like this tweet https://x.com/user/status/123

@Assistant reply to https://x.com/user/status/123 with: Great post!

@Assistant retweet https://x.com/user/status/123

@Assistant quote https://x.com/user/status/123 with comment: Interesting
```

**Note:** Only the main group can use X tools. Other groups will receive an error.

## Testing

Scripts require environment variables from `.env`. Use `dotenv-cli` to load them:

### Check Authentication Status

```bash
# Check if auth file exists and is valid
cat data/x-auth.json 2>/dev/null && echo "Auth configured" || echo "Auth not configured"

# Check if browser profile exists
ls -la data/x-browser-profile/ 2>/dev/null | head -5
```

### Re-authenticate (if expired)

```bash
npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
```

### Test Post (will actually post)

```bash
echo '{"content":"Test tweet - please ignore"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/post.ts
```

### Test Like

```bash
echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/like.ts
```

Or export `CHROME_PATH` manually before running:

```bash
export CHROME_PATH="/path/to/chrome"
echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts
```

## Troubleshooting

### Authentication Expired

```bash
npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts
launchctl kickstart -k gui/$(id -u)/com.nanoclaw  # macOS
# Linux: systemctl --user restart nanoclaw
```

### Browser Lock Files

If Chrome fails to launch:

```bash
rm -f data/x-browser-profile/SingletonLock
rm -f data/x-browser-profile/SingletonSocket
rm -f data/x-browser-profile/SingletonCookie
```

### Check Logs

```bash
# Host logs (relative to project root)
grep -i "x_post\|x_like\|x_reply\|handleXIpc" logs/nanoclaw.log | tail -20

# Script errors
grep -i "error\|failed" logs/nanoclaw.log | tail -20
```

### Script Timeout

Default timeout is 2 minutes (120s). Increase in `host.ts`:

```typescript
const timer = setTimeout(() => {
  proc.kill('SIGTERM');
  resolve({ success: false, message: 'Script timed out (120s)' });
}, 120000);  // ← Increase this value
```

### X UI Selector Changes

If X updates their UI, selectors in scripts may break. Current selectors:

| Element | Selector |
|---------|----------|
| Tweet input | `[data-testid="tweetTextarea_0"]` |
| Post button | `[data-testid="tweetButtonInline"]` |
| Reply button | `[data-testid="reply"]` |
| Like | `[data-testid="like"]` |
| Unlike | `[data-testid="unlike"]` |
| Retweet | `[data-testid="retweet"]` |
| Unretweet | `[data-testid="unretweet"]` |
| Confirm retweet | `[data-testid="retweetConfirm"]` |
| Modal dialog | `[role="dialog"][aria-modal="true"]` |
| Modal submit | `[data-testid="tweetButton"]` |

### Container Build Issues

If MCP tools not found in container:

```bash
# Verify build copies skill
./container/build.sh 2>&1 | grep -i skill

# Check container has the file
docker run nanoclaw-agent ls -la /app/src/skills/
```

## Security

- `data/x-browser-profile/` - Contains X session cookies (in `.gitignore`)
- `data/x-auth.json` - Auth state marker (in `.gitignore`)
- Only main group can use X tools (enforced in `agent.ts` and `host.ts`)
- Scripts run as subprocesses with limited environment

================================================
FILE: .claude/skills/x-integration/agent.ts
================================================
/**
 * X Integration - MCP Tool Definitions (Agent/Container Side)
 *
 * These tools run inside the container and communicate with the host via IPC.
 * The host-side implementation is in host.ts.
 *
 * Note: This file is compiled in the container, not on the host.
 * The @ts-ignore is needed because the SDK is only available in the container.
 */

// @ts-ignore - SDK available in container environment only
import { tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';

// IPC directories (inside container)
const IPC_DIR = '/workspace/ipc';
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
const RESULTS_DIR = path.join(IPC_DIR, 'x_results');

function writeIpcFile(dir: string, data: object): string {
  fs.mkdirSync(dir, { recursive: true });
  const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
  const filepath = path.join(dir, filename);
  const tempPath = `${filepath}.tmp`;
  fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
  fs.renameSync(tempPath, filepath);
  return filename;
}

async function waitForResult(requestId: string, maxWait = 60000): Promise<{ success: boolean; message: string }> {
  const resultFile = path.join(RESULTS_DIR, `${requestId}.json`);
  const pollInterval = 1000;
  let elapsed = 0;

  while (elapsed < maxWait) {
    if (fs.existsSync(resultFile)) {
      try {
        const result = JSON.parse(fs.readFileSync(resultFile, 'utf-8'));
        fs.unlinkSync(resultFile);
        return result;
      } catch (err) {
        return { success: false, message: `Failed to read result: ${err}` };
      }
    }
    await new Promise(resolve => setTimeout(resolve, pollInterval));
    elapsed += pollInterval;
  }

  return { success: false, message: 'Request timed out' };
}

export interface SkillToolsContext {
  groupFolder: string;
  isMain: boolean;
}

/**
 * Create X integration MCP tools
 */
export function createXTools(ctx: SkillToolsContext) {
  const { groupFolder, isMain } = ctx;

  return [
    tool(
      'x_post',
      `Post a tweet to X (Twitter). Main group only.

The host machine will execute the browser automation to post the tweet.
Make sure the content is appropriate and within X's character limit (280 chars for text).`,
      {
        content: z.string().max(280).describe('The tweet content to post (max 280 characters)')
      },
      async (args: { content: string }) => {
        if (!isMain) {
          return {
            content: [{ type: 'text', text: 'Only the main group can post tweets.' }],
            isError: true
          };
        }

        if (args.content.length > 280) {
          return {
            content: [{ type: 'text', text: `Tweet exceeds 280 character limit (current: ${args.content.length})` }],
            isError: true
          };
        }

        const requestId = `xpost-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        writeIpcFile(TASKS_DIR, {
          type: 'x_post',
          requestId,
          content: args.content,
          groupFolder,
          timestamp: new Date().toISOString()
        });

        const result = await waitForResult(requestId);
        return {
          content: [{ type: 'text', text: result.message }],
          isError: !result.success
        };
      }
    ),

    tool(
      'x_like',
      `Like a tweet on X (Twitter). Main group only.

Provide the tweet URL or tweet ID to like.`,
      {
        tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID')
      },
      async (args: { tweet_url: string }) => {
        if (!isMain) {
          return {
            content: [{ type: 'text', text: 'Only the main group can interact with X.' }],
            isError: true
          };
        }

        const requestId = `xlike-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        writeIpcFile(TASKS_DIR, {
          type: 'x_like',
          requestId,
          tweetUrl: args.tweet_url,
          groupFolder,
          timestamp: new Date().toISOString()
        });

        const result = await waitForResult(requestId);
        return {
          content: [{ type: 'text', text: result.message }],
          isError: !result.success
        };
      }
    ),

    tool(
      'x_reply',
      `Reply to a tweet on X (Twitter). Main group only.

Provide the tweet URL and your reply content.`,
      {
        tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'),
        content: z.string().max(280).describe('The reply content (max 280 characters)')
      },
      async (args: { tweet_url: string; content: string }) => {
        if (!isMain) {
          return {
            content: [{ type: 'text', text: 'Only the main group can interact with X.' }],
            isError: true
          };
        }

        const requestId = `xreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        writeIpcFile(TASKS_DIR, {
          type: 'x_reply',
          requestId,
          tweetUrl: args.tweet_url,
          content: args.content,
          groupFolder,
          timestamp: new Date().toISOString()
        });

        const result = await waitForResult(requestId);
        return {
          content: [{ type: 'text', text: result.message }],
          isError: !result.success
        };
      }
    ),

    tool(
      'x_retweet',
      `Retweet a tweet on X (Twitter). Main group only.

Provide the tweet URL to retweet.`,
      {
        tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID')
      },
      async (args: { tweet_url: string }) => {
        if (!isMain) {
          return {
            content: [{ type: 'text', text: 'Only the main group can interact with X.' }],
            isError: true
          };
        }

        const requestId = `xretweet-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        writeIpcFile(TASKS_DIR, {
          type: 'x_retweet',
          requestId,
          tweetUrl: args.tweet_url,
          groupFolder,
          timestamp: new Date().toISOString()
        });

        const result = await waitForResult(requestId);
        return {
          content: [{ type: 'text', text: result.message }],
          isError: !result.success
        };
      }
    ),

    tool(
      'x_quote',
      `Quote tweet on X (Twitter). Main group only.

Retweet with your own comment added.`,
      {
        tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'),
        comment: z.string().max(280).describe('Your comment for the quote tweet (max 280 characters)')
      },
      async (args: { tweet_url: string; comment: string }) => {
        if (!isMain) {
          return {
            content: [{ type: 'text', text: 'Only the main group can interact with X.' }],
            isError: true
          };
        }

        const requestId = `xquote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
        writeIpcFile(TASKS_DIR, {
          type: 'x_quote',
          requestId,
          tweetUrl: args.tweet_url,
          comment: args.comment,
          groupFolder,
          timestamp: new Date().toISOString()
        });

        const result = await waitForResult(requestId);
        return {
          content: [{ type: 'text', text: result.message }],
          isError: !result.success
        };
      }
    )
  ];
}


================================================
FILE: .claude/skills/x-integration/host.ts
================================================
/**
 * X Integration IPC Handler
 *
 * Handles all x_* IPC messages from container agents.
 * This is the entry point for X integration in the host process.
 */

import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: { target: 'pino-pretty', options: { colorize: true } }
});

interface SkillResult {
  success: boolean;
  message: string;
  data?: unknown;
}

// Run a skill script as subprocess
async function runScript(script: string, args: object): Promise<SkillResult> {
  const scriptPath = path.join(process.cwd(), '.claude', 'skills', 'x-integration', 'scripts', `${script}.ts`);

  return new Promise((resolve) => {
    const proc = spawn('npx', ['tsx', scriptPath], {
      cwd: process.cwd(),
      env: { ...process.env, NANOCLAW_ROOT: process.cwd() },
      stdio: ['pipe', 'pipe', 'pipe']
    });

    let stdout = '';
    proc.stdout.on('data', (data) => { stdout += data.toString(); });
    proc.stdin.write(JSON.stringify(args));
    proc.stdin.end();

    const timer = setTimeout(() => {
      proc.kill('SIGTERM');
      resolve({ success: false, message: 'Script timed out (120s)' });
    }, 120000);

    proc.on('close', (code) => {
      clearTimeout(timer);
      if (code !== 0) {
        resolve({ success: false, message: `Script exited with code: ${code}` });
        return;
      }
      try {
        const lines = stdout.trim().split('\n');
        resolve(JSON.parse(lines[lines.length - 1]));
      } catch {
        resolve({ success: false, message: `Failed to parse output: ${stdout.slice(0, 200)}` });
      }
    });

    proc.on('error', (err) => {
      clearTimeout(timer);
      resolve({ success: false, message: `Failed to spawn: ${err.message}` });
    });
  });
}

// Write result to IPC results directory
function writeResult(dataDir: string, sourceGroup: string, requestId: string, result: SkillResult): void {
  const resultsDir = path.join(dataDir, 'ipc', sourceGroup, 'x_results');
  fs.mkdirSync(resultsDir, { recursive: true });
  fs.writeFileSync(path.join(resultsDir, `${requestId}.json`), JSON.stringify(result));
}

/**
 * Handle X integration IPC messages
 *
 * @returns true if message was handled, false if not an X message
 */
export async function handleXIpc(
  data: Record<string, unknown>,
  sourceGroup: string,
  isMain: boolean,
  dataDir: string
): Promise<boolean> {
  const type = data.type as string;

  // Only handle x_* types
  if (!type?.startsWith('x_')) {
    return false;
  }

  // Only main group can use X integration
  if (!isMain) {
    logger.warn({ sourceGroup, type }, 'X integration blocked: not main group');
    return true;
  }

  const requestId = data.requestId as string;
  if (!requestId) {
    logger.warn({ type }, 'X integration blocked: missing requestId');
    return true;
  }

  logger.info({ type, requestId }, 'Processing X request');

  let result: SkillResult;

  switch (type) {
    case 'x_post':
      if (!data.content) {
        result = { success: false, message: 'Missing content' };
        break;
      }
      result = await runScript('post', { content: data.content });
      break;

    case 'x_like':
      if (!data.tweetUrl) {
        result = { success: false, message: 'Missing tweetUrl' };
        break;
      }
      result = await runScript('like', { tweetUrl: data.tweetUrl });
      break;

    case 'x_reply':
      if (!data.tweetUrl || !data.content) {
        result = { success: false, message: 'Missing tweetUrl or content' };
        break;
      }
      result = await runScript('reply', { tweetUrl: data.tweetUrl, content: data.content });
      break;

    case 'x_retweet':
      if (!data.tweetUrl) {
        result = { success: false, message: 'Missing tweetUrl' };
        break;
      }
      result = await runScript('retweet', { tweetUrl: data.tweetUrl });
      break;

    case 'x_quote':
      if (!data.tweetUrl || !data.comment) {
        result = { success: false, message: 'Missing tweetUrl or comment' };
        break;
      }
      result = await runScript('quote', { tweetUrl: data.tweetUrl, comment: data.comment });
      break;

    default:
      return false;
  }

  writeResult(dataDir, sourceGroup, requestId, result);
  if (result.success) {
    logger.info({ type, requestId }, 'X request completed');
  } else {
    logger.error({ type, requestId, message: result.message }, 'X request failed');
  }
  return true;
}


================================================
FILE: .claude/skills/x-integration/lib/browser.ts
================================================
/**
 * X Integration - Shared utilities
 * Used by all X scripts
 */

import { chromium, BrowserContext, Page } from 'playwright';
import fs from 'fs';
import path from 'path';
import { config } from './config.js';

export { config };

export interface ScriptResult {
  success: boolean;
  message: string;
  data?: unknown;
}

/**
 * Read input from stdin
 */
export async function readInput<T>(): Promise<T> {
  return new Promise((resolve, reject) => {
    let data = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', chunk => { data += chunk; });
    process.stdin.on('end', () => {
      try {
        resolve(JSON.parse(data));
      } catch (err) {
        reject(new Error(`Invalid JSON input: ${err}`));
      }
    });
    process.stdin.on('error', reject);
  });
}

/**
 * Write result to stdout
 */
export function writeResult(result: ScriptResult): void {
  console.log(JSON.stringify(result));
}

/**
 * Clean up browser lock files
 */
export function cleanupLockFiles(): void {
  for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
    const lockPath = path.join(config.browserDataDir, lockFile);
    if (fs.existsSync(lockPath)) {
      try { fs.unlinkSync(lockPath); } catch {}
    }
  }
}

/**
 * Validate tweet/reply content
 */
export function validateContent(content: string | undefined, type = 'Tweet'): ScriptResult | null {
  if (!content || content.length === 0) {
    return { success: false, message: `${type} content cannot be empty` };
  }
  if (content.length > config.limits.tweetMaxLength) {
    return { success: false, message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})` };
  }
  return null; // Valid
}

/**
 * Get browser context with persistent profile
 */
export async function getBrowserContext(): Promise<BrowserContext> {
  if (!fs.existsSync(config.authPath)) {
    throw new Error('X authentication not configured. Run /x-integration to complete login.');
  }

  cleanupLockFiles();

  const context = await chromium.launchPersistentContext(config.browserDataDir, {
    executablePath: config.chromePath,
    headless: false,
    viewport: config.viewport,
    args: config.chromeArgs,
    ignoreDefaultArgs: config.ch
Download .txt
gitextract_5jpreexc/

├── .claude/
│   ├── settings.json
│   └── skills/
│       ├── add-compact/
│       │   └── SKILL.md
│       ├── add-discord/
│       │   └── SKILL.md
│       ├── add-gmail/
│       │   └── SKILL.md
│       ├── add-image-vision/
│       │   └── SKILL.md
│       ├── add-ollama-tool/
│       │   └── SKILL.md
│       ├── add-parallel/
│       │   └── SKILL.md
│       ├── add-pdf-reader/
│       │   └── SKILL.md
│       ├── add-reactions/
│       │   └── SKILL.md
│       ├── add-slack/
│       │   └── SKILL.md
│       ├── add-telegram/
│       │   └── SKILL.md
│       ├── add-telegram-swarm/
│       │   └── SKILL.md
│       ├── add-voice-transcription/
│       │   └── SKILL.md
│       ├── add-whatsapp/
│       │   └── SKILL.md
│       ├── convert-to-apple-container/
│       │   └── SKILL.md
│       ├── customize/
│       │   └── SKILL.md
│       ├── debug/
│       │   └── SKILL.md
│       ├── get-qodo-rules/
│       │   ├── SKILL.md
│       │   └── references/
│       │       ├── output-format.md
│       │       ├── pagination.md
│       │       └── repository-scope.md
│       ├── qodo-pr-resolver/
│       │   ├── SKILL.md
│       │   └── resources/
│       │       └── providers.md
│       ├── setup/
│       │   └── SKILL.md
│       ├── update-nanoclaw/
│       │   └── SKILL.md
│       ├── update-skills/
│       │   └── SKILL.md
│       ├── use-local-whisper/
│       │   └── SKILL.md
│       └── x-integration/
│           ├── SKILL.md
│           ├── agent.ts
│           ├── host.ts
│           ├── lib/
│           │   ├── browser.ts
│           │   └── config.ts
│           └── scripts/
│               ├── like.ts
│               ├── post.ts
│               ├── quote.ts
│               ├── reply.ts
│               ├── retweet.ts
│               └── setup.ts
├── .github/
│   ├── CODEOWNERS
│   ├── PULL_REQUEST_TEMPLATE.md
│   └── workflows/
│       ├── bump-version.yml
│       ├── ci.yml
│       ├── merge-forward-skills.yml
│       └── update-tokens.yml
├── .gitignore
├── .husky/
│   └── pre-commit
├── .mcp.json
├── .nvmrc
├── .prettierrc
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE
├── README.md
├── README_ja.md
├── README_zh.md
├── config-examples/
│   └── mount-allowlist.json
├── container/
│   ├── Dockerfile
│   ├── agent-runner/
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── index.ts
│   │   │   └── ipc-mcp-stdio.ts
│   │   └── tsconfig.json
│   ├── build.sh
│   └── skills/
│       ├── agent-browser/
│       │   └── SKILL.md
│       ├── capabilities/
│       │   └── SKILL.md
│       └── status/
│           └── SKILL.md
├── docs/
│   ├── APPLE-CONTAINER-NETWORKING.md
│   ├── DEBUG_CHECKLIST.md
│   ├── REQUIREMENTS.md
│   ├── SDK_DEEP_DIVE.md
│   ├── SECURITY.md
│   ├── SPEC.md
│   ├── docker-sandboxes.md
│   ├── nanoclaw-architecture-final.md
│   ├── nanorepo-architecture.md
│   └── skills-as-branches.md
├── launchd/
│   └── com.nanoclaw.plist
├── package.json
├── repo-tokens/
│   ├── README.md
│   └── action.yml
├── scripts/
│   └── run-migrations.ts
├── setup/
│   ├── container.ts
│   ├── environment.test.ts
│   ├── environment.ts
│   ├── groups.ts
│   ├── index.ts
│   ├── mounts.ts
│   ├── platform.test.ts
│   ├── platform.ts
│   ├── register.test.ts
│   ├── register.ts
│   ├── service.test.ts
│   ├── service.ts
│   ├── status.ts
│   └── verify.ts
├── setup.sh
├── src/
│   ├── channels/
│   │   ├── index.ts
│   │   ├── registry.test.ts
│   │   └── registry.ts
│   ├── config.ts
│   ├── container-runner.test.ts
│   ├── container-runner.ts
│   ├── container-runtime.test.ts
│   ├── container-runtime.ts
│   ├── credential-proxy.test.ts
│   ├── credential-proxy.ts
│   ├── db.test.ts
│   ├── db.ts
│   ├── env.ts
│   ├── formatting.test.ts
│   ├── group-folder.test.ts
│   ├── group-folder.ts
│   ├── group-queue.test.ts
│   ├── group-queue.ts
│   ├── index.ts
│   ├── ipc-auth.test.ts
│   ├── ipc.ts
│   ├── logger.ts
│   ├── mount-security.ts
│   ├── remote-control.test.ts
│   ├── remote-control.ts
│   ├── router.ts
│   ├── routing.test.ts
│   ├── sender-allowlist.test.ts
│   ├── sender-allowlist.ts
│   ├── task-scheduler.test.ts
│   ├── task-scheduler.ts
│   ├── timezone.test.ts
│   ├── timezone.ts
│   └── types.ts
├── tsconfig.json
├── vitest.config.ts
└── vitest.skills.config.ts
Download .txt
SYMBOL INDEX (305 symbols across 50 files)

FILE: .claude/skills/x-integration/agent.ts
  constant IPC_DIR (line 18) | const IPC_DIR = '/workspace/ipc';
  constant TASKS_DIR (line 19) | const TASKS_DIR = path.join(IPC_DIR, 'tasks');
  constant RESULTS_DIR (line 20) | const RESULTS_DIR = path.join(IPC_DIR, 'x_results');
  function writeIpcFile (line 22) | function writeIpcFile(dir: string, data: object): string {
  function waitForResult (line 32) | async function waitForResult(requestId: string, maxWait = 60000): Promis...
  type SkillToolsContext (line 54) | interface SkillToolsContext {
  function createXTools (line 62) | function createXTools(ctx: SkillToolsContext) {

FILE: .claude/skills/x-integration/host.ts
  type SkillResult (line 18) | interface SkillResult {
  function runScript (line 25) | async function runScript(script: string, args: object): Promise<SkillRes...
  function writeResult (line 67) | function writeResult(dataDir: string, sourceGroup: string, requestId: st...
  function handleXIpc (line 78) | async function handleXIpc(

FILE: .claude/skills/x-integration/lib/browser.ts
  type ScriptResult (line 13) | interface ScriptResult {
  function readInput (line 22) | async function readInput<T>(): Promise<T> {
  function writeResult (line 41) | function writeResult(result: ScriptResult): void {
  function cleanupLockFiles (line 48) | function cleanupLockFiles(): void {
  function validateContent (line 60) | function validateContent(content: string | undefined, type = 'Tweet'): S...
  function getBrowserContext (line 73) | async function getBrowserContext(): Promise<BrowserContext> {
  function extractTweetId (line 94) | function extractTweetId(input: string): string | null {
  function navigateToTweet (line 104) | async function navigateToTweet(
  function runScript (line 134) | async function runScript<T>(

FILE: .claude/skills/x-integration/lib/config.ts
  constant PROJECT_ROOT (line 11) | const PROJECT_ROOT = process.env.NANOCLAW_ROOT || process.cwd();

FILE: .claude/skills/x-integration/scripts/like.ts
  type LikeInput (line 9) | interface LikeInput {
  function likeTweet (line 13) | async function likeTweet(input: LikeInput): Promise<ScriptResult> {

FILE: .claude/skills/x-integration/scripts/post.ts
  type PostInput (line 9) | interface PostInput {
  function postTweet (line 13) | async function postTweet(input: PostInput): Promise<ScriptResult> {

FILE: .claude/skills/x-integration/scripts/quote.ts
  type QuoteInput (line 9) | interface QuoteInput {
  function quoteTweet (line 14) | async function quoteTweet(input: QuoteInput): Promise<ScriptResult> {

FILE: .claude/skills/x-integration/scripts/reply.ts
  type ReplyInput (line 9) | interface ReplyInput {
  function replyToTweet (line 14) | async function replyToTweet(input: ReplyInput): Promise<ScriptResult> {

FILE: .claude/skills/x-integration/scripts/retweet.ts
  type RetweetInput (line 9) | interface RetweetInput {
  function retweet (line 13) | async function retweet(input: RetweetInput): Promise<ScriptResult> {

FILE: .claude/skills/x-integration/scripts/setup.ts
  function setup (line 15) | async function setup(): Promise<void> {

FILE: container/agent-runner/src/index.ts
  type ContainerInput (line 22) | interface ContainerInput {
  type ContainerOutput (line 32) | interface ContainerOutput {
  type SessionEntry (line 39) | interface SessionEntry {
  type SessionsIndex (line 46) | interface SessionsIndex {
  type SDKUserMessage (line 50) | interface SDKUserMessage {
  constant IPC_INPUT_DIR (line 57) | const IPC_INPUT_DIR = '/workspace/ipc/input';
  constant IPC_INPUT_CLOSE_SENTINEL (line 58) | const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
  constant IPC_POLL_MS (line 59) | const IPC_POLL_MS = 500;
  class MessageStream (line 65) | class MessageStream {
    method push (line 70) | push(text: string): void {
    method end (line 80) | end(): void {
  method [Symbol.asyncIterator] (line 85) | async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
  function readStdin (line 97) | async function readStdin(): Promise<string> {
  constant OUTPUT_START_MARKER (line 107) | const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
  constant OUTPUT_END_MARKER (line 108) | const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
  function writeOutput (line 110) | function writeOutput(output: ContainerOutput): void {
  function log (line 116) | function log(message: string): void {
  function getSessionSummary (line 120) | function getSessionSummary(sessionId: string, transcriptPath: string): s...
  function createPreCompactHook (line 145) | function createPreCompactHook(assistantName?: string): HookCallback {
  function sanitizeFilename (line 187) | function sanitizeFilename(summary: string): string {
  function generateFallbackName (line 195) | function generateFallbackName(): string {
  type ParsedMessage (line 200) | interface ParsedMessage {
  function parseTranscript (line 205) | function parseTranscript(content: string): ParsedMessage[] {
  function formatTranscriptMarkdown (line 231) | function formatTranscriptMarkdown(messages: ParsedMessage[], title?: str...
  function shouldClose (line 264) | function shouldClose(): boolean {
  function drainIpcInput (line 276) | function drainIpcInput(): string[] {
  function waitForIpcMessage (line 308) | function waitForIpcMessage(): Promise<string | null> {
  function runQuery (line 332) | async function runQuery(
  function main (line 467) | async function main(): Promise<void> {

FILE: container/agent-runner/src/ipc-mcp-stdio.ts
  constant IPC_DIR (line 14) | const IPC_DIR = '/workspace/ipc';
  constant MESSAGES_DIR (line 15) | const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
  constant TASKS_DIR (line 16) | const TASKS_DIR = path.join(IPC_DIR, 'tasks');
  function writeIpcFile (line 23) | function writeIpcFile(dir: string, data: object): string {

FILE: scripts/run-migrations.ts
  function compareSemver (line 6) | function compareSemver(a: string, b: string): number {
  function resolveTsx (line 17) | function resolveTsx(): string {
  type MigrationResult (line 42) | interface MigrationResult {

FILE: setup/container.ts
  function parseArgs (line 12) | function parseArgs(args: string[]): { runtime: string } {
  function run (line 23) | async function run(args: string[]): Promise<void> {

FILE: setup/environment.ts
  function run (line 15) | async function run(_args: string[]): Promise<void> {

FILE: setup/groups.ts
  function parseArgs (line 17) | function parseArgs(args: string[]): { list: boolean; limit: number } {
  function run (line 30) | async function run(args: string[]): Promise<void> {
  function listGroups (line 42) | async function listGroups(limit: number): Promise<void> {
  function syncGroups (line 66) | async function syncGroups(projectRoot: string): Promise<void> {

FILE: setup/index.ts
  constant STEPS (line 8) | const STEPS: Record<
  function main (line 21) | async function main(): Promise<void> {

FILE: setup/mounts.ts
  function parseArgs (line 13) | function parseArgs(args: string[]): { empty: boolean; json: string } {
  function run (line 26) | async function run(args: string[]): Promise<void> {

FILE: setup/platform.ts
  type Platform (line 8) | type Platform = 'macos' | 'linux' | 'unknown';
  type ServiceManager (line 9) | type ServiceManager = 'launchd' | 'systemd' | 'none';
  function getPlatform (line 11) | function getPlatform(): Platform {
  function isWSL (line 18) | function isWSL(): boolean {
  function isRoot (line 28) | function isRoot(): boolean {
  function isHeadless (line 32) | function isHeadless(): boolean {
  function hasSystemd (line 41) | function hasSystemd(): boolean {
  function openBrowser (line 56) | function openBrowser(url: string): boolean {
  function getServiceManager (line 91) | function getServiceManager(): ServiceManager {
  function getNodePath (line 101) | function getNodePath(): string {
  function commandExists (line 109) | function commandExists(name: string): boolean {
  function getNodeVersion (line 118) | function getNodeVersion(): string | null {
  function getNodeMajorVersion (line 127) | function getNodeMajorVersion(): number | null {

FILE: setup/register.test.ts
  function createTestDb (line 12) | function createTestDb(): Database.Database {

FILE: setup/register.ts
  type RegisterArgs (line 16) | interface RegisterArgs {
  function parseArgs (line 27) | function parseArgs(args: string[]): RegisterArgs {
  function run (line 71) | async function run(args: string[]): Promise<void> {

FILE: setup/service.test.ts
  function generatePlist (line 12) | function generatePlist(
  function generateSystemdUnit (line 49) | function generateSystemdUnit(

FILE: setup/service.ts
  function run (line 23) | async function run(_args: string[]): Promise<void> {
  function setupLaunchd (line 71) | function setupLaunchd(
  function setupLinux (line 147) | function setupLinux(
  function killOrphanedProcesses (line 166) | function killOrphanedProcesses(projectRoot: string): void {
  function checkDockerGroupStale (line 186) | function checkDockerGroupStale(): boolean {
  function setupSystemd (line 204) | function setupSystemd(
  function setupNohupFallback (line 309) | function setupNohupFallback(

FILE: setup/status.ts
  function emitStatus (line 6) | function emitStatus(

FILE: setup/verify.ts
  function run (line 25) | async function run(_args: string[]): Promise<void> {

FILE: src/channels/registry.ts
  type ChannelOpts (line 8) | interface ChannelOpts {
  type ChannelFactory (line 14) | type ChannelFactory = (opts: ChannelOpts) => Channel | null;
  function registerChannel (line 18) | function registerChannel(name: string, factory: ChannelFactory): void {
  function getChannelFactory (line 22) | function getChannelFactory(name: string): ChannelFactory | undefined {
  function getRegisteredChannelNames (line 26) | function getRegisteredChannelNames(): string[] {

FILE: src/config.ts
  constant ASSISTANT_NAME (line 11) | const ASSISTANT_NAME =
  constant ASSISTANT_HAS_OWN_NUMBER (line 13) | const ASSISTANT_HAS_OWN_NUMBER =
  constant POLL_INTERVAL (line 16) | const POLL_INTERVAL = 2000;
  constant SCHEDULER_POLL_INTERVAL (line 17) | const SCHEDULER_POLL_INTERVAL = 60000;
  constant PROJECT_ROOT (line 20) | const PROJECT_ROOT = process.cwd();
  constant HOME_DIR (line 21) | const HOME_DIR = process.env.HOME || os.homedir();
  constant MOUNT_ALLOWLIST_PATH (line 24) | const MOUNT_ALLOWLIST_PATH = path.join(
  constant SENDER_ALLOWLIST_PATH (line 30) | const SENDER_ALLOWLIST_PATH = path.join(
  constant STORE_DIR (line 36) | const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
  constant GROUPS_DIR (line 37) | const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
  constant DATA_DIR (line 38) | const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
  constant CONTAINER_IMAGE (line 40) | const CONTAINER_IMAGE =
  constant CONTAINER_TIMEOUT (line 42) | const CONTAINER_TIMEOUT = parseInt(
  constant CONTAINER_MAX_OUTPUT_SIZE (line 46) | const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
  constant CREDENTIAL_PROXY_PORT (line 50) | const CREDENTIAL_PROXY_PORT = parseInt(
  constant IPC_POLL_INTERVAL (line 54) | const IPC_POLL_INTERVAL = 1000;
  constant IDLE_TIMEOUT (line 55) | const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10);
  constant MAX_CONCURRENT_CONTAINERS (line 56) | const MAX_CONCURRENT_CONTAINERS = Math.max(
  function escapeRegex (line 61) | function escapeRegex(str: string): string {
  constant TRIGGER_PATTERN (line 65) | const TRIGGER_PATTERN = new RegExp(
  constant TIMEZONE (line 72) | const TIMEZONE =

FILE: src/container-runner.test.ts
  constant OUTPUT_START_MARKER (line 6) | const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
  constant OUTPUT_END_MARKER (line 7) | const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
  function createFakeProcess (line 55) | function createFakeProcess() {
  function emitOutputMarker (line 106) | function emitOutputMarker(

FILE: src/container-runner.ts
  constant OUTPUT_START_MARKER (line 33) | const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
  constant OUTPUT_END_MARKER (line 34) | const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
  type ContainerInput (line 36) | interface ContainerInput {
  type ContainerOutput (line 46) | interface ContainerOutput {
  type VolumeMount (line 53) | interface VolumeMount {
  function buildVolumeMounts (line 59) | function buildVolumeMounts(
  function buildContainerArgs (line 215) | function buildContainerArgs(
  function runContainerAgent (line 267) | async function runContainerAgent(
  function writeTasksSnapshot (line 659) | function writeTasksSnapshot(
  type AvailableGroup (line 685) | interface AvailableGroup {
  function writeGroupsSnapshot (line 697) | function writeGroupsSnapshot(

FILE: src/container-runtime.ts
  constant CONTAINER_RUNTIME_BIN (line 12) | const CONTAINER_RUNTIME_BIN = 'docker';
  constant CONTAINER_HOST_GATEWAY (line 15) | const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
  constant PROXY_BIND_HOST (line 23) | const PROXY_BIND_HOST =
  function detectProxyBindHost (line 26) | function detectProxyBindHost(): string {
  function hostGatewayArgs (line 44) | function hostGatewayArgs(): string[] {
  function readonlyMountArgs (line 53) | function readonlyMountArgs(
  function stopContainer (line 61) | function stopContainer(name: string): string {
  function ensureContainerRuntimeRunning (line 66) | function ensureContainerRuntimeRunning(): void {
  function cleanupOrphans (line 104) | function cleanupOrphans(): void {

FILE: src/credential-proxy.test.ts
  function makeRequest (line 16) | function makeRequest(
  function startProxy (line 73) | async function startProxy(env: Record<string, string>): Promise<number> {

FILE: src/credential-proxy.ts
  type AuthMode (line 20) | type AuthMode = 'api-key' | 'oauth';
  type ProxyConfig (line 22) | interface ProxyConfig {
  function startCredentialProxy (line 26) | function startCredentialProxy(
  function detectAuthMode (line 122) | function detectAuthMode(): AuthMode {

FILE: src/db.test.ts
  function store (line 23) | function store(overrides: {

FILE: src/db.ts
  function createSchema (line 17) | function createSchema(database: Database.Database): void {
  function initDatabase (line 144) | function initDatabase(): void {
  function _initTestDatabase (line 156) | function _initTestDatabase(): void {
  function storeChatMetadata (line 165) | function storeChatMetadata(
  function updateChatName (line 206) | function updateChatName(chatJid: string, name: string): void {
  type ChatInfo (line 215) | interface ChatInfo {
  function getAllChats (line 226) | function getAllChats(): ChatInfo[] {
  function getLastGroupSync (line 241) | function getLastGroupSync(): string | null {
  function setLastGroupSync (line 252) | function setLastGroupSync(): void {
  function storeMessage (line 263) | function storeMessage(msg: NewMessage): void {
  function storeMessageDirect (line 281) | function storeMessageDirect(msg: {
  function getNewMessages (line 305) | function getNewMessages(
  function getMessagesSince (line 341) | function getMessagesSince(
  function createTask (line 366) | function createTask(
  function getTaskById (line 388) | function getTaskById(id: string): ScheduledTask | undefined {
  function getTasksForGroup (line 394) | function getTasksForGroup(groupFolder: string): ScheduledTask[] {
  function getAllTasks (line 402) | function getAllTasks(): ScheduledTask[] {
  function updateTask (line 408) | function updateTask(
  function deleteTask (line 449) | function deleteTask(id: string): void {
  function getDueTasks (line 455) | function getDueTasks(): ScheduledTask[] {
  function updateTaskAfterRun (line 468) | function updateTaskAfterRun(
  function logTaskRun (line 483) | function logTaskRun(log: TaskRunLog): void {
  function getRouterState (line 501) | function getRouterState(key: string): string | undefined {
  function setRouterState (line 508) | function setRouterState(key: string, value: string): void {
  function getSession (line 516) | function getSession(groupFolder: string): string | undefined {
  function setSession (line 523) | function setSession(groupFolder: string, sessionId: string): void {
  function getAllSessions (line 529) | function getAllSessions(): Record<string, string> {
  function getRegisteredGroup (line 542) | function getRegisteredGroup(
  function setRegisteredGroup (line 582) | function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
  function getAllRegisteredGroups (line 601) | function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
  function migrateJsonState (line 639) | function migrateJsonState(): void {

FILE: src/env.ts
  function readEnvFile (line 11) | function readEnvFile(keys: string[]): Record<string, string> {

FILE: src/formatting.test.ts
  function makeMsg (line 12) | function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
  function shouldRequireTrigger (line 211) | function shouldRequireTrigger(
  function shouldProcess (line 218) | function shouldProcess(

FILE: src/group-folder.ts
  constant GROUP_FOLDER_PATTERN (line 5) | const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
  constant RESERVED_FOLDERS (line 6) | const RESERVED_FOLDERS = new Set(['global']);
  function isValidGroupFolder (line 8) | function isValidGroupFolder(folder: string): boolean {
  function assertValidGroupFolder (line 18) | function assertValidGroupFolder(folder: string): void {
  function ensureWithinBase (line 24) | function ensureWithinBase(baseDir: string, resolvedPath: string): void {
  function resolveGroupFolderPath (line 31) | function resolveGroupFolderPath(folder: string): string {
  function resolveGroupIpcPath (line 38) | function resolveGroupIpcPath(folder: string): string {

FILE: src/group-queue.ts
  type QueuedTask (line 8) | interface QueuedTask {
  constant MAX_RETRIES (line 14) | const MAX_RETRIES = 5;
  constant BASE_RETRY_MS (line 15) | const BASE_RETRY_MS = 5000;
  type GroupState (line 17) | interface GroupState {
  class GroupQueue (line 30) | class GroupQueue {
    method getGroup (line 38) | private getGroup(groupJid: string): GroupState {
    method setProcessMessagesFn (line 58) | setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
    method enqueueMessageCheck (line 62) | enqueueMessageCheck(groupJid: string): void {
    method enqueueTask (line 90) | enqueueTask(groupJid: string, taskId: string, fn: () => Promise<void>)...
    method registerProcess (line 132) | registerProcess(
    method notifyIdle (line 148) | notifyIdle(groupJid: string): void {
    method sendMessage (line 160) | sendMessage(groupJid: string, text: string): boolean {
    method closeStdin (line 183) | closeStdin(groupJid: string): void {
    method runForGroup (line 196) | private async runForGroup(
    method runTask (line 234) | private async runTask(groupJid: string, task: QueuedTask): Promise<voi...
    method scheduleRetry (line 263) | private scheduleRetry(groupJid: string, state: GroupState): void {
    method drainGroup (line 286) | private drainGroup(groupJid: string): void {
    method drainWaiting (line 318) | private drainWaiting(): void {
    method shutdown (line 347) | async shutdown(_gracePeriodMs: number): Promise<void> {

FILE: src/index.ts
  function loadState (line 76) | function loadState(): void {
  function saveState (line 93) | function saveState(): void {
  function registerGroup (line 98) | function registerGroup(jid: string, group: RegisteredGroup): void {
  function getAvailableGroups (line 126) | function getAvailableGroups(): import('./container-runner.js').Available...
  function _setRegisteredGroups (line 141) | function _setRegisteredGroups(
  function processGroupMessages (line 151) | async function processGroupMessages(chatJid: string): Promise<boolean> {
  function runAgent (line 268) | async function runAgent(
  function startMessageLoop (line 349) | async function startMessageLoop(): Promise<void> {
  function recoverPendingMessages (line 454) | function recoverPendingMessages(): void {
  function ensureContainerSystemRunning (line 468) | function ensureContainerSystemRunning(): void {
  function main (line 473) | async function main(): Promise<void> {

FILE: src/ipc-auth.test.ts
  constant MAIN_GROUP (line 15) | const MAIN_GROUP: RegisteredGroup = {
  constant OTHER_GROUP (line 23) | const OTHER_GROUP: RegisteredGroup = {
  constant THIRD_GROUP (line 30) | const THIRD_GROUP: RegisteredGroup = {
  function isMessageAuthorized (line 391) | function isMessageAuthorized(

FILE: src/ipc.ts
  type IpcDeps (line 13) | interface IpcDeps {
  function startIpcWatcher (line 30) | function startIpcWatcher(deps: IpcDeps): void {
  function processTaskIpc (line 157) | async function processTaskIpc(

FILE: src/mount-security.ts
  constant DEFAULT_BLOCKED_PATTERNS (line 29) | const DEFAULT_BLOCKED_PATTERNS = [
  function loadMountAllowlist (line 54) | function loadMountAllowlist(): MountAllowlist | null {
  function expandPath (line 124) | function expandPath(p: string): string {
  function getRealPath (line 139) | function getRealPath(p: string): string | null {
  function matchesBlockedPattern (line 150) | function matchesBlockedPattern(
  function findAllowedRoot (line 176) | function findAllowedRoot(
  function isValidContainerPath (line 202) | function isValidContainerPath(containerPath: string): boolean {
  type MountValidationResult (line 221) | interface MountValidationResult {
  function validateMount (line 233) | function validateMount(
  function validateAdditionalMounts (line 336) | function validateAdditionalMounts(
  function generateAllowlistTemplate (line 390) | function generateAllowlistTemplate(): string {

FILE: src/remote-control.test.ts
  function createMockProcess (line 26) | function createMockProcess(pid = 12345) {

FILE: src/remote-control.ts
  type RemoteControlSession (line 8) | interface RemoteControlSession {
  constant URL_REGEX (line 18) | const URL_REGEX = /https:\/\/claude\.ai\/code\S+/;
  constant URL_TIMEOUT_MS (line 19) | const URL_TIMEOUT_MS = 30_000;
  constant URL_POLL_MS (line 20) | const URL_POLL_MS = 200;
  constant STATE_FILE (line 21) | const STATE_FILE = path.join(DATA_DIR, 'remote-control.json');
  constant STDOUT_FILE (line 22) | const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout');
  constant STDERR_FILE (line 23) | const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr');
  function saveState (line 25) | function saveState(session: RemoteControlSession): void {
  function clearState (line 30) | function clearState(): void {
  function isProcessAlive (line 38) | function isProcessAlive(pid: number): boolean {
  function restoreRemoteControl (line 51) | function restoreRemoteControl(): void {
  function getActiveSession (line 75) | function getActiveSession(): RemoteControlSession | null {
  function _resetForTesting (line 80) | function _resetForTesting(): void {
  function _getStateFilePath (line 85) | function _getStateFilePath(): string {
  function startRemoteControl (line 89) | async function startRemoteControl(
  function stopRemoteControl (line 205) | function stopRemoteControl():

FILE: src/router.ts
  function escapeXml (line 4) | function escapeXml(s: string): string {
  function formatMessages (line 13) | function formatMessages(
  function stripInternalTags (line 27) | function stripInternalTags(text: string): string {
  function formatOutbound (line 31) | function formatOutbound(rawText: string): string {
  function routeOutbound (line 37) | function routeOutbound(
  function findChannel (line 47) | function findChannel(

FILE: src/sender-allowlist.test.ts
  function cfgPath (line 16) | function cfgPath(name = 'sender-allowlist.json'): string {
  function writeConfig (line 20) | function writeConfig(config: unknown, name?: string): string {

FILE: src/sender-allowlist.ts
  type ChatAllowlistEntry (line 6) | interface ChatAllowlistEntry {
  type SenderAllowlistConfig (line 11) | interface SenderAllowlistConfig {
  constant DEFAULT_CONFIG (line 17) | const DEFAULT_CONFIG: SenderAllowlistConfig = {
  function isValidEntry (line 23) | function isValidEntry(entry: unknown): entry is ChatAllowlistEntry {
  function loadSenderAllowlist (line 33) | function loadSenderAllowlist(
  function getEntry (line 91) | function getEntry(
  function isSenderAllowed (line 98) | function isSenderAllowed(
  function shouldDropMessage (line 108) | function shouldDropMessage(
  function isTriggerAllowed (line 115) | function isTriggerAllowed(

FILE: src/task-scheduler.ts
  function computeNextRun (line 31) | function computeNextRun(task: ScheduledTask): string | null {
  type SchedulerDependencies (line 65) | interface SchedulerDependencies {
  function runTask (line 78) | async function runTask(
  function startSchedulerLoop (line 243) | function startSchedulerLoop(deps: SchedulerDependencies): void {
  function _resetSchedulerLoopForTests (line 280) | function _resetSchedulerLoopForTests(): void {

FILE: src/timezone.ts
  function formatLocalTime (line 5) | function formatLocalTime(utcIso: string, timezone: string): string {

FILE: src/types.ts
  type AdditionalMount (line 1) | interface AdditionalMount {
  type MountAllowlist (line 12) | interface MountAllowlist {
  type AllowedRoot (line 21) | interface AllowedRoot {
  type ContainerConfig (line 30) | interface ContainerConfig {
  type RegisteredGroup (line 35) | interface RegisteredGroup {
  type NewMessage (line 45) | interface NewMessage {
  type ScheduledTask (line 56) | interface ScheduledTask {
  type TaskRunLog (line 71) | interface TaskRunLog {
  type Channel (line 82) | interface Channel {
  type OnInboundMessage (line 96) | type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
  type OnChatMetadata (line 101) | type OnChatMetadata = (
Condensed preview — 134 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (814K chars).
[
  {
    "path": ".claude/settings.json",
    "chars": 3,
    "preview": "{}\n"
  },
  {
    "path": ".claude/skills/add-compact/SKILL.md",
    "chars": 6870,
    "preview": "---\nname: add-compact\ndescription: Add /compact command for manual context compaction. Solves context rot in long sessio"
  },
  {
    "path": ".claude/skills/add-discord/SKILL.md",
    "chars": 6034,
    "preview": "---\nname: add-discord\ndescription: Add Discord bot channel integration to NanoClaw.\n---\n\n# Add Discord Channel\n\nThis ski"
  },
  {
    "path": ".claude/skills/add-gmail/SKILL.md",
    "chars": 7346,
    "preview": "---\nname: add-gmail\ndescription: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends email"
  },
  {
    "path": ".claude/skills/add-image-vision/SKILL.md",
    "chars": 2853,
    "preview": "---\nname: add-image-vision\ndescription: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attach"
  },
  {
    "path": ".claude/skills/add-ollama-tool/SKILL.md",
    "chars": 4248,
    "preview": "---\nname: add-ollama-tool\ndescription: Add Ollama MCP server so the container agent can call local models for cheaper/fa"
  },
  {
    "path": ".claude/skills/add-parallel/SKILL.md",
    "chars": 8747,
    "preview": "# Add Parallel AI Integration\n\nAdds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.\n\n## "
  },
  {
    "path": ".claude/skills/add-pdf-reader/SKILL.md",
    "chars": 2755,
    "preview": "---\nname: add-pdf-reader\ndescription: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Han"
  },
  {
    "path": ".claude/skills/add-reactions/SKILL.md",
    "chars": 2924,
    "preview": "---\nname: add-reactions\ndescription: Add WhatsApp emoji reaction support — receive, send, store, and search reactions.\n-"
  },
  {
    "path": ".claude/skills/add-slack/SKILL.md",
    "chars": 7987,
    "preview": "---\nname: add-slack\ndescription: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket "
  },
  {
    "path": ".claude/skills/add-telegram/SKILL.md",
    "chars": 6669,
    "preview": "---\nname: add-telegram\ndescription: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also c"
  },
  {
    "path": ".claude/skills/add-telegram-swarm/SKILL.md",
    "chars": 14377,
    "preview": "---\nname: add-telegram-swarm\ndescription: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot id"
  },
  {
    "path": ".claude/skills/add-voice-transcription/SKILL.md",
    "chars": 4250,
    "preview": "---\nname: add-voice-transcription\ndescription: Add voice message transcription to NanoClaw using OpenAI's Whisper API. A"
  },
  {
    "path": ".claude/skills/add-whatsapp/SKILL.md",
    "chars": 11060,
    "preview": "---\nname: add-whatsapp\ndescription: Add WhatsApp as a channel. Can replace other channels entirely or run alongside them"
  },
  {
    "path": ".claude/skills/convert-to-apple-container/SKILL.md",
    "chars": 5236,
    "preview": "---\nname: convert-to-apple-container\ndescription: Switch from Docker to Apple Container for macOS-native container isola"
  },
  {
    "path": ".claude/skills/customize/SKILL.md",
    "chars": 4106,
    "preview": "---\nname: customize\ndescription: Add new capabilities or modify NanoClaw behavior. Use when user wants to add channels ("
  },
  {
    "path": ".claude/skills/debug/SKILL.md",
    "chars": 11188,
    "preview": "---\nname: debug\ndescription: Debug container agent issues. Use when things aren't working, container fails, authenticati"
  },
  {
    "path": ".claude/skills/get-qodo-rules/SKILL.md",
    "chars": 5557,
    "preview": "---\nname: get-qodo-rules\ndescription: \"Loads org- and repo-level coding rules from Qodo before code tasks begin, ensurin"
  },
  {
    "path": ".claude/skills/get-qodo-rules/references/output-format.md",
    "chars": 830,
    "preview": "# Formatting and Outputting Rules\n\n## Output Structure\n\nPrint the following header:\n\n```\n# 📋 Qodo Rules Loaded\n\nScope: `"
  },
  {
    "path": ".claude/skills/get-qodo-rules/references/pagination.md",
    "chars": 1275,
    "preview": "# Fetching Rules with Pagination\n\nThe API returns rules in pages of 50. All pages must be fetched to ensure no rules are"
  },
  {
    "path": ".claude/skills/get-qodo-rules/references/repository-scope.md",
    "chars": 956,
    "preview": "# Repository Scope Detection\n\n## Extracting Repository Scope from Git Remote URL\n\nParse the `origin` remote URL to deriv"
  },
  {
    "path": ".claude/skills/qodo-pr-resolver/SKILL.md",
    "chars": 15985,
    "preview": "---\nname: qodo-pr-resolver\ndescription: Review and resolve PR issues with Qodo - get AI-powered code review issues and f"
  },
  {
    "path": ".claude/skills/qodo-pr-resolver/resources/providers.md",
    "chars": 6833,
    "preview": "# Git Provider Commands Reference\n\nThis document contains all provider-specific CLI commands and API interactions for th"
  },
  {
    "path": ".claude/skills/setup/SKILL.md",
    "chars": 11483,
    "preview": "---\nname: setup\ndescription: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate messa"
  },
  {
    "path": ".claude/skills/update-nanoclaw/SKILL.md",
    "chars": 10188,
    "preview": "---\nname: update-nanoclaw\ndescription: Efficiently bring upstream NanoClaw updates into a customized install, with previ"
  },
  {
    "path": ".claude/skills/update-skills/SKILL.md",
    "chars": 5065,
    "preview": "---\nname: update-skills\ndescription: Check for and apply updates to installed skill branches from upstream.\n---\n\n# About"
  },
  {
    "path": ".claude/skills/use-local-whisper/SKILL.md",
    "chars": 4801,
    "preview": "---\nname: use-local-whisper\ndescription: Use when the user wants local voice transcription instead of OpenAI Whisper API"
  },
  {
    "path": ".claude/skills/x-integration/SKILL.md",
    "chars": 11558,
    "preview": "---\nname: x-integration\ndescription: X (Twitter) integration for NanoClaw. Post tweets, like, reply, retweet, and quote."
  },
  {
    "path": ".claude/skills/x-integration/agent.ts",
    "chars": 7495,
    "preview": "/**\n * X Integration - MCP Tool Definitions (Agent/Container Side)\n *\n * These tools run inside the container and commun"
  },
  {
    "path": ".claude/skills/x-integration/host.ts",
    "chars": 4519,
    "preview": "/**\n * X Integration IPC Handler\n *\n * Handles all x_* IPC messages from container agents.\n * This is the entry point fo"
  },
  {
    "path": ".claude/skills/x-integration/lib/browser.ts",
    "chars": 4066,
    "preview": "/**\n * X Integration - Shared utilities\n * Used by all X scripts\n */\n\nimport { chromium, BrowserContext, Page } from 'pl"
  },
  {
    "path": ".claude/skills/x-integration/lib/config.ts",
    "chars": 1505,
    "preview": "/**\n * X Integration - Configuration\n *\n * All environment-specific settings in one place.\n * Override via environment v"
  },
  {
    "path": ".claude/skills/x-integration/scripts/like.ts",
    "chars": 1662,
    "preview": "#!/usr/bin/env npx tsx\n/**\n * X Integration - Like Tweet\n * Usage: echo '{\"tweetUrl\":\"https://x.com/user/status/123\"}' |"
  },
  {
    "path": ".claude/skills/x-integration/scripts/post.ts",
    "chars": 2300,
    "preview": "#!/usr/bin/env npx tsx\n/**\n * X Integration - Post Tweet\n * Usage: echo '{\"content\":\"Hello world\"}' | npx tsx post.ts\n *"
  },
  {
    "path": ".claude/skills/x-integration/scripts/quote.ts",
    "chars": 2815,
    "preview": "#!/usr/bin/env npx tsx\n/**\n * X Integration - Quote Tweet\n * Usage: echo '{\"tweetUrl\":\"https://x.com/user/status/123\",\"c"
  },
  {
    "path": ".claude/skills/x-integration/scripts/reply.ts",
    "chars": 2557,
    "preview": "#!/usr/bin/env npx tsx\n/**\n * X Integration - Reply to Tweet\n * Usage: echo '{\"tweetUrl\":\"https://x.com/user/status/123\""
  },
  {
    "path": ".claude/skills/x-integration/scripts/retweet.ts",
    "chars": 2006,
    "preview": "#!/usr/bin/env npx tsx\n/**\n * X Integration - Retweet\n * Usage: echo '{\"tweetUrl\":\"https://x.com/user/status/123\"}' | np"
  },
  {
    "path": ".claude/skills/x-integration/scripts/setup.ts",
    "chars": 2778,
    "preview": "#!/usr/bin/env npx tsx\n/**\n * X Integration - Authentication Setup\n * Usage: npx tsx setup.ts\n *\n * Interactive script -"
  },
  {
    "path": ".github/CODEOWNERS",
    "chars": 288,
    "preview": "# Core code - maintainer only\n/src/ @gavrielc @gabi-simons\n/container/ @gavrielc @gabi-simons\n/groups/ @gavrielc @gabi-s"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "chars": 395,
    "preview": "## Type of Change\n\n- [ ] **Skill** - adds a new skill in `.claude/skills/`\n- [ ] **Fix** - bug fix or security fix to so"
  },
  {
    "path": ".github/workflows/bump-version.yml",
    "chars": 929,
    "preview": "name: Bump version\n\non:\n  push:\n    branches: [main]\n    paths: ['src/**', 'container/**']\n\njobs:\n  bump-version:\n    ru"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 420,
    "preview": "name: CI\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  ci:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: acti"
  },
  {
    "path": ".github/workflows/merge-forward-skills.yml",
    "chars": 5056,
    "preview": "name: Merge-forward skill branches\n\non:\n  push:\n    branches: [main]\n\npermissions:\n  contents: write\n  issues: write\n\njo"
  },
  {
    "path": ".github/workflows/update-tokens.yml",
    "chars": 1277,
    "preview": "name: Update token count\n\non:\n  workflow_dispatch:\n  push:\n    branches: [main]\n    paths: ['src/**', 'container/**', 'l"
  },
  {
    "path": ".gitignore",
    "chars": 450,
    "preview": "# Dependencies\nnode_modules/\n.npm-cache/\n# Build output\ndist/\n\n# Local data & auth\nstore/\ndata/\nlogs/\n\n# Groups - only t"
  },
  {
    "path": ".husky/pre-commit",
    "chars": 19,
    "preview": "npm run format:fix\n"
  },
  {
    "path": ".mcp.json",
    "chars": 23,
    "preview": "{\n  \"mcpServers\": {}\n}\n"
  },
  {
    "path": ".nvmrc",
    "chars": 3,
    "preview": "22\n"
  },
  {
    "path": ".prettierrc",
    "chars": 26,
    "preview": "{\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 383,
    "preview": "# Changelog\n\nAll notable changes to NanoClaw will be documented in this file.\n\n## [1.2.0](https://github.com/qwibitai/na"
  },
  {
    "path": "CLAUDE.md",
    "chars": 2967,
    "preview": "# NanoClaw\n\nPersonal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](d"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 868,
    "preview": "# Contributing\n\n## Source Code Changes\n\n**Accepted:** Bug fixes, security fixes, simplifications, reducing code.\n\n**Not "
  },
  {
    "path": "CONTRIBUTORS.md",
    "chars": 665,
    "preview": "# Contributors\n\nThanks to everyone who has contributed to NanoClaw!\n\n- [Alakazam03](https://github.com/Alakazam03) — Vai"
  },
  {
    "path": "LICENSE",
    "chars": 1064,
    "preview": "MIT License\n\nCopyright (c) 2026 Gavriel\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof"
  },
  {
    "path": "README.md",
    "chars": 11434,
    "preview": "<p align=\"center\">\n  <img src=\"assets/nanoclaw-logo.png\" alt=\"NanoClaw\" width=\"400\">\n</p>\n\n<p align=\"center\">\n  An AI as"
  },
  {
    "path": "README_ja.md",
    "chars": 7673,
    "preview": "<p align=\"center\">\n  <img src=\"assets/nanoclaw-logo.png\" alt=\"NanoClaw\" width=\"400\">\n</p>\n\n<p align=\"center\">\n  エージェントを専"
  },
  {
    "path": "README_zh.md",
    "chars": 5690,
    "preview": "<p align=\"center\">\n  <img src=\"assets/nanoclaw-logo.png\" alt=\"NanoClaw\" width=\"400\">\n</p>\n\n<p align=\"center\">\n  NanoClaw"
  },
  {
    "path": "config-examples/mount-allowlist.json",
    "chars": 475,
    "preview": "{\n  \"allowedRoots\": [\n    {\n      \"path\": \"~/projects\",\n      \"allowReadWrite\": true,\n      \"description\": \"Development "
  },
  {
    "path": "container/Dockerfile",
    "chars": 2125,
    "preview": "# NanoClaw Agent Container\n# Runs Claude Agent SDK in isolated Linux VM with browser automation\n\nFROM node:22-slim\n\n# In"
  },
  {
    "path": "container/agent-runner/package.json",
    "chars": 495,
    "preview": "{\n  \"name\": \"nanoclaw-agent-runner\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"description\": \"Container-side agent ru"
  },
  {
    "path": "container/agent-runner/src/index.ts",
    "chars": 17538,
    "preview": "/**\n * NanoClaw Agent Runner\n * Runs inside a container, receives config via stdin, outputs result to stdout\n *\n * Input"
  },
  {
    "path": "container/agent-runner/src/ipc-mcp-stdio.ts",
    "chars": 13013,
    "preview": "/**\n * Stdio MCP Server for NanoClaw\n * Standalone process that agent teams subagents can inherit.\n * Reads context from"
  },
  {
    "path": "container/agent-runner/tsconfig.json",
    "chars": 330,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"outD"
  },
  {
    "path": "container/build.sh",
    "chars": 637,
    "preview": "#!/bin/bash\n# Build the NanoClaw agent container image\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd"
  },
  {
    "path": "container/skills/agent-browser/SKILL.md",
    "chars": 4826,
    "preview": "---\nname: agent-browser\ndescription: Browse the web for any task — research topics, read articles, interact with web app"
  },
  {
    "path": "container/skills/capabilities/SKILL.md",
    "chars": 3170,
    "preview": "---\nname: capabilities\ndescription: Show what this NanoClaw instance can do — installed skills, available tools, and sys"
  },
  {
    "path": "container/skills/status/SKILL.md",
    "chars": 2572,
    "preview": "---\nname: status\ndescription: Quick read-only health check — session context, workspace mounts, tool availability, and t"
  },
  {
    "path": "docs/APPLE-CONTAINER-NETWORKING.md",
    "chars": 2946,
    "preview": "# Apple Container Networking Setup (macOS 26)\n\nApple Container's vmnet networking requires manual configuration for cont"
  },
  {
    "path": "docs/DEBUG_CHECKLIST.md",
    "chars": 5081,
    "preview": "# NanoClaw Debug Checklist\n\n## Known Issues (2026-02-08)\n\n### 1. [FIXED] Resume branches from stale tree position\nWhen a"
  },
  {
    "path": "docs/REQUIREMENTS.md",
    "chars": 8467,
    "preview": "# NanoClaw Requirements\n\nOriginal requirements and design decisions from the project creator.\n\n---\n\n## Why This Exists\n\n"
  },
  {
    "path": "docs/SDK_DEEP_DIVE.md",
    "chars": 24885,
    "preview": "# Claude Agent SDK Deep Dive\n\nFindings from reverse-engineering `@anthropic-ai/claude-agent-sdk` v0.2.29–0.2.34 to under"
  },
  {
    "path": "docs/SECURITY.md",
    "chars": 5548,
    "preview": "# NanoClaw Security Model\n\n## Trust Model\n\n| Entity | Trust Level | Rationale |\n|--------|-------------|-----------|\n| M"
  },
  {
    "path": "docs/SPEC.md",
    "chars": 29142,
    "preview": "# NanoClaw Specification\n\nA personal Claude assistant with multi-channel support, persistent memory per conversation, sc"
  },
  {
    "path": "docs/docker-sandboxes.md",
    "chars": 12221,
    "preview": "# Running NanoClaw in Docker Sandboxes (Manual Setup)\n\nThis guide walks through setting up NanoClaw inside a [Docker San"
  },
  {
    "path": "docs/nanoclaw-architecture-final.md",
    "chars": 43497,
    "preview": "# NanoClaw Skills Architecture\n\n## Core Principle\n\nSkills are self-contained, auditable packages that apply programmatic"
  },
  {
    "path": "docs/nanorepo-architecture.md",
    "chars": 10136,
    "preview": "# NanoClaw Skills Architecture\n\n## What Skills Are For\n\nNanoClaw's core is intentionally minimal. Skills are how users e"
  },
  {
    "path": "docs/skills-as-branches.md",
    "chars": 27257,
    "preview": "# Skills as Branches\n\n## Overview\n\nNanoClaw skills are distributed as git branches on the upstream repository. Applying "
  },
  {
    "path": "launchd/com.nanoclaw.plist",
    "chars": 1000,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
  },
  {
    "path": "package.json",
    "chars": 1108,
    "preview": "{\n  \"name\": \"nanoclaw\",\n  \"version\": \"1.2.19\",\n  \"description\": \"Personal Claude assistant. Lightweight, secure, customi"
  },
  {
    "path": "repo-tokens/README.md",
    "chars": 3618,
    "preview": "# Repo Tokens\n\nA GitHub Action that calculates the size of your codebase in terms of tokens and updates a badge in your "
  },
  {
    "path": "repo-tokens/action.yml",
    "chars": 6566,
    "preview": "name: Repo Tokens\ndescription: Count codebase tokens with tiktoken and update a README badge\n\ninputs:\n  include:\n    des"
  },
  {
    "path": "scripts/run-migrations.ts",
    "chars": 2875,
    "preview": "#!/usr/bin/env tsx\nimport { execFileSync, execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';"
  },
  {
    "path": "setup/container.ts",
    "chars": 3697,
    "preview": "/**\n * Step: container — Build container image and verify with test run.\n * Replaces 03-setup-container.sh\n */\nimport { "
  },
  {
    "path": "setup/environment.test.ts",
    "chars": 3446,
    "preview": "import { describe, it, expect, beforeEach } from 'vitest';\nimport fs from 'fs';\n\nimport Database from 'better-sqlite3';\n"
  },
  {
    "path": "setup/environment.ts",
    "chars": 2607,
    "preview": "/**\n * Step: environment — Detect OS, Node, container runtimes, existing config.\n * Replaces 01-check-environment.sh\n */"
  },
  {
    "path": "setup/groups.ts",
    "chars": 6483,
    "preview": "/**\n * Step: groups — Fetch group metadata from messaging platforms, write to DB.\n * WhatsApp requires an upfront sync ("
  },
  {
    "path": "setup/index.ts",
    "chars": 1570,
    "preview": "/**\n * Setup CLI entry point.\n * Usage: npx tsx setup/index.ts --step <name> [args...]\n */\nimport { logger } from '../sr"
  },
  {
    "path": "setup/mounts.ts",
    "chars": 3304,
    "preview": "/**\n * Step: mounts — Write mount allowlist config file.\n * Replaces 07-configure-mounts.sh\n */\nimport fs from 'fs';\nimp"
  },
  {
    "path": "setup/platform.test.ts",
    "chars": 2714,
    "preview": "import { describe, it, expect } from 'vitest';\n\nimport {\n  getPlatform,\n  isWSL,\n  isRoot,\n  isHeadless,\n  hasSystemd,\n "
  },
  {
    "path": "setup/platform.ts",
    "chars": 3423,
    "preview": "/**\n * Cross-platform detection utilities for NanoClaw setup.\n */\nimport { execSync } from 'child_process';\nimport fs fr"
  },
  {
    "path": "setup/register.test.ts",
    "chars": 6973,
    "preview": "import { describe, it, expect, beforeEach } from 'vitest';\n\nimport Database from 'better-sqlite3';\n\n/**\n * Tests for the"
  },
  {
    "path": "setup/register.ts",
    "chars": 5009,
    "preview": "/**\n * Step: register — Write channel registration config, create group folders.\n *\n * Accepts --channel to specify the "
  },
  {
    "path": "setup/service.test.ts",
    "chars": 5078,
    "preview": "import { describe, it, expect } from 'vitest';\nimport path from 'path';\n\n/**\n * Tests for service configuration generati"
  },
  {
    "path": "setup/service.ts",
    "chars": 10383,
    "preview": "/**\n * Step: service — Generate and load service manager config.\n * Replaces 08-setup-service.sh\n *\n * Fixes: Root→syste"
  },
  {
    "path": "setup/status.ts",
    "chars": 439,
    "preview": "/**\n * Structured status block output for setup steps.\n * Each step emits a block that the SKILL.md LLM can parse.\n */\n\n"
  },
  {
    "path": "setup/verify.ts",
    "chars": 5442,
    "preview": "/**\n * Step: verify — End-to-end health check of the full installation.\n * Replaces 09-verify.sh\n *\n * Uses better-sqlit"
  },
  {
    "path": "setup.sh",
    "chars": 3506,
    "preview": "#!/bin/bash\nset -euo pipefail\n\n# setup.sh — Bootstrap script for NanoClaw\n# Handles Node.js/npm setup, then hands off to"
  },
  {
    "path": "src/channels/index.ts",
    "chars": 169,
    "preview": "// Channel self-registration barrel file.\n// Each import triggers the channel module's registerChannel() call.\n\n// disco"
  },
  {
    "path": "src/channels/registry.test.ts",
    "chars": 1525,
    "preview": "import { describe, it, expect, beforeEach } from 'vitest';\n\nimport {\n  registerChannel,\n  getChannelFactory,\n  getRegist"
  },
  {
    "path": "src/channels/registry.ts",
    "chars": 692,
    "preview": "import {\n  Channel,\n  OnInboundMessage,\n  OnChatMetadata,\n  RegisteredGroup,\n} from '../types.js';\n\nexport interface Cha"
  },
  {
    "path": "src/config.ts",
    "chars": 2454,
    "preview": "import os from 'os';\nimport path from 'path';\n\nimport { readEnvFile } from './env.js';\n\n// Read config values from .env "
  },
  {
    "path": "src/container-runner.test.ts",
    "chars": 5362,
    "preview": "import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';\nimport { EventEmitter } from 'events';\nimport "
  },
  {
    "path": "src/container-runner.ts",
    "chars": 22093,
    "preview": "/**\n * Container Runner for NanoClaw\n * Spawns agent execution in containers and handles IPC\n */\nimport { ChildProcess, "
  },
  {
    "path": "src/container-runtime.test.ts",
    "chars": 4101,
    "preview": "import { describe, it, expect, vi, beforeEach } from 'vitest';\n\n// Mock logger\nvi.mock('./logger.js', () => ({\n  logger:"
  },
  {
    "path": "src/container-runtime.ts",
    "chars": 4191,
    "preview": "/**\n * Container runtime abstraction for NanoClaw.\n * All runtime-specific logic lives here so swapping runtimes means c"
  },
  {
    "path": "src/credential-proxy.test.ts",
    "chars": 5436,
    "preview": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport http from 'http';\nimport type { Address"
  },
  {
    "path": "src/credential-proxy.ts",
    "chars": 4099,
    "preview": "/**\n * Credential proxy for container isolation.\n * Containers connect here instead of directly to the Anthropic API.\n *"
  },
  {
    "path": "src/db.test.ts",
    "chars": 13179,
    "preview": "import { describe, it, expect, beforeEach } from 'vitest';\n\nimport {\n  _initTestDatabase,\n  createTask,\n  deleteTask,\n  "
  },
  {
    "path": "src/db.ts",
    "chars": 19881,
    "preview": "import Database from 'better-sqlite3';\nimport fs from 'fs';\nimport path from 'path';\n\nimport { ASSISTANT_NAME, DATA_DIR,"
  },
  {
    "path": "src/env.ts",
    "chars": 1288,
    "preview": "import fs from 'fs';\nimport path from 'path';\nimport { logger } from './logger.js';\n\n/**\n * Parse the .env file and retu"
  },
  {
    "path": "src/formatting.test.ts",
    "chars": 7957,
    "preview": "import { describe, it, expect } from 'vitest';\n\nimport { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js';\nimport {\n"
  },
  {
    "path": "src/group-folder.test.ts",
    "chars": 1356,
    "preview": "import path from 'path';\n\nimport { describe, expect, it } from 'vitest';\n\nimport {\n  isValidGroupFolder,\n  resolveGroupF"
  },
  {
    "path": "src/group-folder.ts",
    "chars": 1475,
    "preview": "import path from 'path';\n\nimport { DATA_DIR, GROUPS_DIR } from './config.js';\n\nconst GROUP_FOLDER_PATTERN = /^[A-Za-z0-9"
  },
  {
    "path": "src/group-queue.test.ts",
    "chars": 14159,
    "preview": "import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';\n\nimport { GroupQueue } from './group-queue.js'"
  },
  {
    "path": "src/group-queue.ts",
    "chars": 10664,
    "preview": "import { ChildProcess } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\n\nimport { DATA_DIR, MAX_CONC"
  },
  {
    "path": "src/index.ts",
    "chars": 20052,
    "preview": "import fs from 'fs';\nimport path from 'path';\n\nimport {\n  ASSISTANT_NAME,\n  CREDENTIAL_PROXY_PORT,\n  IDLE_TIMEOUT,\n  POL"
  },
  {
    "path": "src/ipc-auth.test.ts",
    "chars": 17095,
    "preview": "import { describe, it, expect, beforeEach } from 'vitest';\n\nimport {\n  _initTestDatabase,\n  createTask,\n  getAllTasks,\n "
  },
  {
    "path": "src/ipc.ts",
    "chars": 14712,
    "preview": "import fs from 'fs';\nimport path from 'path';\n\nimport { CronExpressionParser } from 'cron-parser';\n\nimport { DATA_DIR, I"
  },
  {
    "path": "src/logger.ts",
    "chars": 463,
    "preview": "import pino from 'pino';\n\nexport const logger = pino({\n  level: process.env.LOG_LEVEL || 'info',\n  transport: { target: "
  },
  {
    "path": "src/mount-security.ts",
    "chars": 10633,
    "preview": "/**\n * Mount Security Module for NanoClaw\n *\n * Validates additional mounts against an allowlist stored OUTSIDE the proj"
  },
  {
    "path": "src/remote-control.test.ts",
    "chars": 13157,
    "preview": "import fs from 'fs';\nimport { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\n\n// Mock config before im"
  },
  {
    "path": "src/remote-control.ts",
    "chars": 5416,
    "preview": "import { spawn } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\n\nimport { DATA_DIR } from './config"
  },
  {
    "path": "src/router.ts",
    "chars": 1458,
    "preview": "import { Channel, NewMessage } from './types.js';\nimport { formatLocalTime } from './timezone.js';\n\nexport function esca"
  },
  {
    "path": "src/routing.test.ts",
    "chars": 4185,
    "preview": "import { describe, it, expect, beforeEach } from 'vitest';\n\nimport { _initTestDatabase, getAllChats, storeChatMetadata }"
  },
  {
    "path": "src/sender-allowlist.test.ts",
    "chars": 6196,
    "preview": "import fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nimport { afterEach, beforeEach, describe, expect, it,"
  },
  {
    "path": "src/sender-allowlist.ts",
    "chars": 3142,
    "preview": "import fs from 'fs';\n\nimport { SENDER_ALLOWLIST_PATH } from './config.js';\nimport { logger } from './logger.js';\n\nexport"
  },
  {
    "path": "src/task-scheduler.test.ts",
    "chars": 3845,
    "preview": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { _initTestDatabase, createTask, getTa"
  },
  {
    "path": "src/task-scheduler.ts",
    "chars": 8110,
    "preview": "import { ChildProcess } from 'child_process';\nimport { CronExpressionParser } from 'cron-parser';\nimport fs from 'fs';\n\n"
  },
  {
    "path": "src/timezone.test.ts",
    "chars": 932,
    "preview": "import { describe, it, expect } from 'vitest';\n\nimport { formatLocalTime } from './timezone.js';\n\n// --- formatLocalTime"
  },
  {
    "path": "src/timezone.ts",
    "chars": 423,
    "preview": "/**\n * Convert a UTC ISO timestamp to a localized display string.\n * Uses the Intl API (no external dependencies).\n */\ne"
  },
  {
    "path": "src/types.ts",
    "chars": 3250,
    "preview": "export interface AdditionalMount {\n  hostPath: string; // Absolute path on host (supports ~ for home)\n  containerPath?: "
  },
  {
    "path": "tsconfig.json",
    "chars": 481,
    "preview": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"lib\""
  },
  {
    "path": "vitest.config.ts",
    "chars": 153,
    "preview": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    include: ['src/**/*.test.ts',"
  },
  {
    "path": "vitest.skills.config.ts",
    "chars": 148,
    "preview": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    include: ['.claude/skills/**/"
  }
]

About this extraction

This page contains the full source code of the qwibitai/nanoclaw GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 134 files (761.8 KB), approximately 200.6k tokens, and a symbol index with 305 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!