Compare commits

..

25 Commits

Author SHA1 Message Date
omri-maya 5459925c65 Apply suggestions from code review
Co-authored-by: omri-maya <omri@nanoco.ai>
2026-07-09 11:23:08 +03:00
Omri Maya b0c76ce4c5 feat(tasks): ncl tasks control plane — isolated per-series sessions, strict verb contract, script gate
Scheduled tasks move from MCP tools to a first-class ncl resource:
list / get / create / update / cancel / pause / resume / run / append-log,
with args declared on every verb (strict validation, fix-carrying errors)
and deep per-verb help with executable examples.

A series is CronJob-like: the live (pending/paused) row is the next
occurrence, completed rows are its run history. tasks list reads as a
compact run-history table, server-rendered so the container agent prints
the same aligned table. Short named ids (<slug>-<hex>) are
filesystem/thread-safe and copy-pasteable. Agents keep a per-series work
journal: the run log at tasks/<series>.md (append-log + auto-logged
final text).

Each series runs in its own isolated system session
(system:tasks:<series>); the live-task cap is dropped — isolation
replaces throttling. Spent task sessions are GC'd by the sweep.

--script on a task runs a bash gate BEFORE the agent wakes. It prints a
JSON verdict as its last stdout line: {"wakeAgent": bool, "data": {...}}.
wakeAgent:false handles the occurrence without waking the agent; data is
threaded into the fire's prompt, so a gate can fetch/inspect and hand the
agent exactly what changed. Failing scripts back off exponentially
(2·2^(n-1) min, cap 60) and auto-pause the series after 8 consecutive
failures with a host-written run-log note; manual runs are excluded from
the streak.

BREAKING: the schedule_task / list_tasks / update_task / cancel_task /
pause_task / resume_task MCP tools are removed. Agents schedule via
ncl tasks (available under cli_scope=group). Existing task rows keep
firing — storage (messages_in kind='task') is unchanged; only the
management surface moved. Migration: docs/ncl-tasks-migration.md.
Review round (gavrielc):
- Recurrence frequency guard: create/update refuse a recurrence more
  frequent than 4 fires/day with a warning steering to gate scripts;
  --dangerously-override-recurrence-limit bypasses after explicit user
  confirmation. Counted over the next 24h in the instance TZ. Guarded on
  update too, so create-slow-then-update cannot sneak past.
- ncl tasks help scripts: resource help topics (new helpTopics seam on
  registerResource) carry the full gate-script guide — contract, examples,
  state, failure/backoff semantics, testing directions.
- Agent-facing scheduling instructions slimmed: no recurring-frequency
  prose in the fragment; one pointer to the help topic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:04:45 +03:00
Gabi Simons 87a2d3af8d Merge pull request #2978 from nanocoai/feat/core-team-pr-label
ci: auto-label PRs from core team members
2026-07-08 14:49:23 +03:00
Gabi Simons 68ebc02114 Update CONTRIBUTING.md 2026-07-08 14:48:25 +03:00
Gabi Simons d4e73c5523 Merge branch 'main' into feat/core-team-pr-label 2026-07-08 14:35:23 +03:00
github-actions[bot] 9a103f4f93 docs: update token count to 213k tokens · 106% of context window 2026-07-08 11:29:45 +00:00
github-actions[bot] a8554c6248 chore: bump version to 2.1.40 2026-07-08 11:29:40 +00:00
gavrielc 2480ae7cb8 Merge pull request #2980 from nanocoai/tasks/01-cli-verb-args-human-view
ncl CLI: verb-level args, deep help, server-rendered human view
2026-07-08 14:29:24 +03:00
Omri Maya 763a3f757b feat(cli): verb-level args, deep help, server-rendered human view
Every ncl verb now declares its args: strict validation with fix-carrying
error messages, generated per-verb --help, and multi-word custom-op key
resolution (spaces vs dashes). Longest-prefix command fallback keeps dashed
positional ids intact.

Responses gain a server-rendered 'human' frame field (formatHuman hook): the
host renders tables once and every client prints them verbatim — container
agents cannot import host formatters, so this is how they get aligned output
instead of a raw column dump. --help responses carry 'human' for clean
multi-line rendering.

Robustness fixes riding along:
- generic list returns newest rows first, so the LIMIT no longer hides
  recent rows
- stdout flushed before exit — >64KB responses were silently truncated

Additive only: new response-frame fields, no schema changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:39:10 +03:00
Gabi Simons 8943c1cbf4 ci: auto-label PRs from core team members
Extends the existing label-pr workflow with an author allowlist that
applies a core-team label. The label is auto-provisioned on first use
so no manual repo setup is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:11:43 +03:00
github-actions[bot] 559bb5ca1a chore: bump version to 2.1.39 2026-07-07 20:00:50 +00:00
gavrielc 04b364e828 Merge pull request #2965 from nanocoai/fix/rate-limit-event-shape
fix(agent-runner): match rate_limit_event as a top-level SDK message type
2026-07-07 23:00:36 +03:00
gavrielc 627ab23bad Merge branch 'main' into fix/rate-limit-event-shape 2026-07-07 23:00:24 +03:00
gavrielc 8986fef586 Apply suggestion from @gavrielc 2026-07-07 23:00:07 +03:00
gavrielc 7f49450c0c Merge pull request #2964 from nanocoai/docs-sdk-deep-dive
docs: update SDK deep-dive from 0.2.x to 0.3.197
2026-07-07 22:58:40 +03:00
gavrielc cdc519e1f4 Merge pull request #2963 from nanocoai/docs-rewrite-core
docs: rewrite architecture.md and agent-runner-details.md to match current code
2026-07-07 22:56:12 +03:00
gavrielc 1276645c3b Merge pull request #2962 from nanocoai/docs-fix-schema
docs: sync DB schema and entity docs with migrations 010-018
2026-07-07 22:55:40 +03:00
github-actions[bot] d3499b7d70 docs: update token count to 209k tokens · 104% of context window 2026-07-07 19:55:04 +00:00
gavrielc c2cf19ec28 Merge pull request #2961 from nanocoai/docs-fix-mechanical
docs: fix stale claims across README, CONTRIBUTING, CLAUDE.md and operational docs
2026-07-07 22:54:48 +03:00
glifocat 44f351349a docs: Output Delivery — messages_out comes from <message> envelope parsing, not raw results
The section still described the pre-envelope model where every provider
result was written to messages_out verbatim. Since the envelope parser
landed, the agent-runner parses the final text for <message to="name">
blocks (dispatchResultText, poll-loop.ts): one messages_out row per
block, bare/<internal> text is scratchpad (logged, never sent), unknown
destinations are dropped, unwrapped output gets a one-time re-wrap
nudge, and non-retryable error results are delivered as error notices
instead of being dropped.
2026-07-07 00:26:12 +00:00
glifocat e8a32207d8 fix(agent-runner): match rate_limit_event as a top-level SDK message type
The claude provider mapped rate-limit events with
`message.type === 'system' && subtype === 'rate_limit_event'`, but
`@anthropic-ai/claude-agent-sdk` 0.3.x ships rate limits as a top-level
`SDKRateLimitEvent` (`{ type: 'rate_limit_event', ... }`) with no `system`
subtype. The old condition never matched, so the quota-classified error
event was never emitted and rate-limit signals were silently dropped.

Match the top-level `type` instead.
2026-07-06 15:05:01 +00:00
glifocat 1dda751a48 docs: update SDK deep-dive from 0.2.x to 0.3.x 2026-07-06 14:52:03 +00:00
glifocat 4f1b17c737 docs: sync DB schema + entity docs with migrations 010-018 2026-07-06 13:04:14 +00:00
glifocat 967aee2c27 docs: fix stale claims vs v2.1.38 (skills lists, runtime claims, file refs) 2026-07-06 13:04:14 +00:00
glifocat 5aac750aa5 docs: rewrite architecture + agent-runner internals to match current code 2026-07-06 13:04:14 +00:00
159 changed files with 10654 additions and 10020 deletions
+73 -50
View File
@@ -11,8 +11,6 @@ NanoClaw selects each group's agent backend from `container_configs.provider` (d
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
The mechanical steps under **Install** carry `nc:` directive fences: an agent reads the prose and applies them, and a parser can apply them deterministically from the same document. Every directive is idempotent, so the whole skill is safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
### Pre-flight
@@ -25,69 +23,92 @@ Check whether the payload is already wired (a prior apply, or a trunk that still
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
- an `@openai/codex` entry in `container/cli-tools.json`
### 1. Fetch and copy the payload
### Fetch and copy
Fetch the `providers` branch and copy the Codex payload into all three trees (additive — overwrite each file, never merge the branch). The host files are the provider contribution + AGENTS.md compose + their guards; the container files are the provider runtime (turn loop, JSON-RPC wrapper, per-exchange archiver) + their guards; the setup file is the picker entry + vault auth walk-through; `container/AGENTS.md` is the runtime-contract base the composed AGENTS.md embeds.
```nc:copy from-branch:providers
src/providers/codex.ts
src/providers/codex-agents-md.ts
src/providers/codex-registration.test.ts
src/providers/codex-host-contribution.test.ts
src/providers/codex-agents-md.test.ts
container/agent-runner/src/providers/codex.ts
container/agent-runner/src/providers/codex-app-server.ts
container/agent-runner/src/providers/exchange-archive.ts
container/agent-runner/src/providers/exchange-archive.test.ts
container/agent-runner/src/providers/codex-registration.test.ts
container/agent-runner/src/providers/codex.factory.test.ts
container/agent-runner/src/providers/codex.turns.test.ts
container/agent-runner/src/providers/codex-app-server.test.ts
container/agent-runner/src/providers/codex-cli-tools.test.ts
setup/providers/codex.ts
setup/providers/codex.test.ts
setup/providers/codex-registration.test.ts
container/AGENTS.md
```bash
git fetch origin providers
```
### 2. Wire the barrels
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
Append the self-registration import to each of the three provider barrels (skipped if the line is already present). Each barrel-registration test imports its real barrel and asserts `codex` is registered — they go red the moment a barrel line is missing or drifts.
Host (`src/providers/`):
- `codex.ts` — provider contribution: per-group `.codex-shared` state dir, AGENTS.md compose, skill links
- `codex-agents-md.ts` — AGENTS.md composition (32KB Codex cap: degrades by dropping the largest instruction sections, never blocks a spawn)
- `codex-registration.test.ts` — barrel-driven host registration guard
- `codex-host-contribution.test.ts` — drives the real contribution against a real test DB (the "consumes core" leg)
- `codex-agents-md.test.ts` — cap-degradation behavior
```nc:append to:src/providers/index.ts
import './codex.js';
```
```nc:append to:container/agent-runner/src/providers/index.ts
import './codex.js';
```
```nc:append to:setup/providers/index.ts
import './codex.js';
Container (`container/agent-runner/src/providers/`):
- `codex.ts` — the provider (turn loop, steering, memory scaffold + `onExchangeComplete` archiving)
- `codex-app-server.ts` — JSON-RPC child-process wrapper
- `exchange-archive.ts` — per-exchange markdown writer the `onExchangeComplete` hook uses (provider-owned, not runner code)
- `exchange-archive.test.ts` — writer behavior
- `codex-registration.test.ts` — barrel-driven container registration guard
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
Setup (`setup/providers/`):
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
- `codex.test.ts` — install-check coverage
- `codex-registration.test.ts` — barrel-driven setup registration guard
Shared base (skip if present):
- `container/AGENTS.md` — the runtime-contract base the composed AGENTS.md embeds
### Wire the barrels
Append `import './codex.js';` to each of:
- `src/providers/index.ts`
- `container/agent-runner/src/providers/index.ts`
- `setup/providers/index.ts`
### CLI manifest
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
```bash
node -e '
const fs = require("fs");
const file = "container/cli-tools.json";
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
if (!tools.some((t) => t.name === "@openai/codex")) {
tools.push({ name: "@openai/codex", version: "0.138.0" });
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
}
'
```
### 3. CLI manifest
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — idempotent on `name`, so a re-run is a no-op. `@openai/codex` has no native postinstall, so no `onlyBuilt`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
### Build
```nc:json-merge into:container/cli-tools.json key:name
{ "name": "@openai/codex", "version": "0.138.0" }
```
The version (`0.138.0`) is the canonical pin — this SKILL.md is the source of truth.
### 4. Build
```nc:run effect:build
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
### 5. Validate
### Restart the host
```nc:run effect:test
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
The image rebuild does not reload the **host**. Codex's host contribution
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
passthrough, and the running host only picks it up on restart. Skip this and the
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml`
with no mount, Docker auto-creates the dir root-owned and the non-root container
user can't write to it.
```bash
# macOS (launchd)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux (systemd)
systemctl --user restart nanoclaw
```
```nc:run effect:test
### Validate
```bash
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
cd container/agent-runner && bun test src/providers/
```
@@ -95,7 +116,9 @@ The registration tests import only the real barrels — they go red if a barrel
## Authenticate
```nc:run effect:external
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
```bash
pnpm exec tsx setup/index.ts --step provider-auth codex
```
@@ -0,0 +1,39 @@
// Structural guard for the Codex CLI install in container/cli-tools.json.
//
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
// json-merge seam), not an importable package, so the barrel-driven
// registration tests cannot see it. This test reads the real cli-tools.json
// and asserts the @openai/codex entry is present and pinned to an exact
// version. It goes red if the manifest entry is dropped or unpins.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/cli-tools.json
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
const manifestPresent = existsSync(MANIFEST);
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
// read has to be guarded for the bare-branch (no manifest) case.
const tools: Array<{ name: string; version: string }> = manifestPresent
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
: [];
const codex = tools.find((t) => t.name === '@openai/codex');
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
// so skip there. In an installed tree (trunk + this payload) it must carry the
// pinned @openai/codex entry.
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
it('includes the @openai/codex entry', () => {
expect(codex).toBeDefined();
});
it('pins it to an exact semver (no latest, no ranges)', () => {
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
});
});
+1
View File
@@ -89,6 +89,7 @@ DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
+58 -111
View File
@@ -5,151 +5,98 @@ description: Add Discord bot channel integration via Chat SDK.
# Add Discord Channel
Adds Discord bot support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Discord adapter in from the `channels` branch.
Adds Discord bot support via the Chat SDK bridge.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch.
### 1. Copy the adapter and its registration test
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Discord adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/discord.ts
src/channels/discord-registration.test.ts
- `src/channels/discord.ts` exists
- `src/channels/discord-registration.test.ts` exists
- `src/channels/index.ts` contains `import './discord.js';`
- `@chat-adapter/discord` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './discord.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/discord@4.29.0
```bash
pnpm install @chat-adapter/discord@4.29.0
```
### 4. Build and validate
### 5. Build and validate
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/discord-registration.test.ts
```
`discord-registration.test.ts` imports the real channel barrel and asserts the
registry contains `discord`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed
(the import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real server is verified manually once the service runs.
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
Discord app setup is human and interactive — no parser can click through the
Discord Developer Portal. The adapter is installed and registered, but it can't
receive a message until the bot exists, has Message Content Intent, and shares a
server with you. Tell the user:
### Create Discord Bot
```nc:operator
Create the Discord bot:
1. Go to https://discord.com/developers/applications → New Application. Name it (e.g. "NanoClaw Assistant").
2. Bot tab → Add Bot if needed → Reset Token, then copy the Bot Token (it's shown only once).
3. Bot tab → Privileged Gateway Intents → enable Message Content Intent.
4. OAuth2 → URL Generator → Scopes: bot; Bot Permissions: Send Messages, Read Message History, Add Reactions, Attach Files, Use Slash Commands.
5. Open the generated URL and invite the bot to a server you're also in (a personal server is fine) — the bot can only DM you once you share a server.
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant")
3. From the **General Information** tab, copy the **Application ID** and **Public Key**
4. Go to the **Bot** tab and click **Add Bot** if needed
5. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once)
6. Under **Privileged Gateway Intents**, enable **Message Content Intent**
7. Go to **OAuth2** > **URL Generator**:
- Scopes: select `bot`
- Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands`
8. Copy the generated URL and open it in your browser to invite the bot to your server
### Configure environment
All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`.
Add to `.env`:
```bash
DISCORD_BOT_TOKEN=your-bot-token
DISCORD_APPLICATION_ID=your-application-id
DISCORD_PUBLIC_KEY=your-public-key
```
Paste the Bot Token (it's shown only once). You don't paste the Application ID or
the Public Key by hand — the bot's own application record carries both, so a
single call derives them from the token:
```nc:prompt bot_token secret validate:^[A-Za-z0-9._-]{50,}$
Paste the Bot Token — Bot tab. Click `Reset Token` if you need a new one.
```
Read the application's own record. `GET /oauth2/applications/@me` returns the
Application ID (`id`), the Public Key (`verify_key`), and your own account as the
app's owner (`owner.id`) — so the App ID, the Public Key, and your Discord user ID
all come from this one call instead of being copied by hand. A bad token fails
here, before the restart, rather than silently later:
```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch
curl -sf https://discord.com/api/v10/oauth2/applications/@me -H "Authorization: Bot {{bot_token}}"
```
Store the token and the two derived credentials — the adapter reads them from
`.env` and fails to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`
(set-if-absent, so a value you've already filled in is never overwritten):
```nc:env-set
DISCORD_BOT_TOKEN={{bot_token}}
DISCORD_APPLICATION_ID={{application_id}}
DISCORD_PUBLIC_KEY={{public_key}}
```
## Restart
Restart the service so it loads the Discord adapter and the credentials you just
stored, and wait for its CLI socket before resolving:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Invite the bot to a shared server
The bot can only DM you once it shares a server with you. If you didn't already
invite it via the OAuth2 URL Generator while setting up the app, do it now: add
the bot to a server you're also in (a personal server is fine). Tell the user:
```nc:operator
Open the invite link — https://discord.com/oauth2/authorize?client_id={{application_id}}&scope=bot&permissions=2147584064 — and add the bot to a server you're also in (a personal server works fine); the bot can only DM you once you share a server. If you already invited it while setting up the app, you can skip this.
```
## Resolve your DM channel
The agent talks to you in your direct-message channel with the bot. Your Discord
user ID was already derived as the application's owner (`owner_handle`), so all
that's left is to open the DM and read back its channel id.
Open the DM with `POST /users/@me/channels` and take the channel id it returns as
the conversation address `discord:@me:<channelId>` (if Discord refuses, the bot
doesn't share a server with you yet — invite it, then retry):
```nc:run capture:platform_id effect:fetch
curl -s -X POST https://discord.com/api/v10/users/@me/channels -H "Authorization: Bot {{bot_token}}" -H "Content-Type: application/json" -d '{"recipient_id":"{{owner_handle}}"}' | jq -er '"discord:@me:" + .id'
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
greeting goes out over the DM channel, which works as soon as the bot shares a
server with you.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `discord`
- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages.
- **platform-id-format**: `discord:@me:{dmChannelId}` for the owner DM (e.g. `discord:@me:1399...`), `discord:{guildId}:{channelId}` for server channels — both IDs required for channels.
- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server and select "Copy Server ID" for the guild ID, and right-click the text channel and select "Copy Channel ID." The platform ID format used in registration is `discord:{guildId}:{channelId}` — both IDs are required.
- **supports-threads**: yes
- **typical-use**: Interactive chat — server channels or direct messages
+6 -4
View File
@@ -54,8 +54,10 @@ Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, o
Reload your config or restart Emacs.
## 5. Messaging group (left intact)
## 5. Remove the messaging group (optional)
Your wired messaging group and conversation history are **not** removed — you
created them at runtime, not this skill's install. To purge them deliberately,
delete them yourself with `ncl messaging-groups delete <id>`.
To clean up the wired messaging group:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
+6 -5
View File
@@ -12,13 +12,14 @@ ncl groups config remove-mcp-server --id <group-id> --name calendar
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
This is a **host-only / operator** verb — run it host-side. It's idempotent (a no-op if the mount is absent):
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
```bash
ncl groups config remove-mount \
--id <group-id> \
--host "$HOME/.calendar-mcp" \
--container .calendar-mcp
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
## 3. Delete the copied test file
+13 -10
View File
@@ -133,8 +133,6 @@ pnpm exec vitest run src/gcal-dockerfile.test.ts
`cp` overwrites in place, so re-running this skill is safe.
**This is the skill's only in-tree integration test.** The Phase 3 `ncl groups config add-mcp-server` and `add-mount` steps are runtime writes to the central DB — they leave no line in the source tree whose deletion a test could catch, so a registration test is structurally inapplicable. They're verified at runtime instead (Phase 5).
### Rebuild the container image
```bash
@@ -162,22 +160,27 @@ Approval behaviour depends on where you run it: from inside an agent's container
### Add the `.calendar-mcp` mount
This is a **host-only / operator** verb — it's rejected from inside a container at any `cli_scope`, so run it host-side when you (the operator) apply this skill via `/setup`, `/customize`, or `/manage-mounts`. It's idempotent (skips if the mount is already present).
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
ncl groups config add-mount \
--id <group-id> \
--host "$HOME/.calendar-mcp" \
--container .calendar-mcp
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
`--container` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`). No `--ro`: the MCP server may rewrite `credentials.json` on token refresh, so the mount must be read-write.
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
The mount also needs to be in the external mount allowlist (`~/.config/nanoclaw/mount-allowlist.json`) to take effect at spawn — see the Phase 1 "Verify mount allowlist covers the path" step. A container restart (`ncl groups restart`) is needed for the mount to apply.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `add-mount` appends to `additional_mounts` without disturbing existing entries.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
## Phase 4: Build and Restart
+42 -62
View File
@@ -5,70 +5,63 @@ description: Add Google Chat channel integration via Chat SDK.
# Add Google Chat Channel
Adds Google Chat support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Google Chat adapter in from the `channels`
branch.
Adds Google Chat support via the Chat SDK bridge.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch.
### 1. Copy the adapter and its registration test
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Google Chat adapter and its
registration test into `src/channels/` (overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/gchat.ts
src/channels/gchat-registration.test.ts
- `src/channels/gchat.ts` exists
- `src/channels/gchat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './gchat.js';`
- `@chat-adapter/gchat` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './gchat.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/gchat@4.29.0
```bash
pnpm install @chat-adapter/gchat@4.29.0
```
### 4. Build and validate
### 5. Build and validate
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
`gchat-registration.test.ts` imports the real channel barrel and asserts the
registry contains `gchat`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real Google Chat space is verified manually once the service
runs — see Credentials and Next Steps.
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
Google Cloud setup is human and interactive — these steps are prose, not
directives (no parser can click through the Google Cloud Console). A recipe
rebuild produces a compiling, registered adapter that cannot receive a message
until they're done.
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
> 2. Create or select a project
> 3. Enable the **Google Chat API**
@@ -80,32 +73,21 @@ until they're done.
> - Grant the Chat Bot role
> - Create a JSON key and download it
### Store the credentials
### Configure environment
Capture the service account JSON, then write it. `prompt` only *asks* and binds
the answer to a name; a separate directive consumes it — so the same prompt
could feed `ncl` or the OneCLI vault instead of `.env` by swapping only the
consumer. Here it goes to `.env` (set-if-absent — a value you've already filled
in is never overwritten) as a single-line string:
Add the service account JSON as a single-line string to `.env`:
```nc:prompt gchat_credentials secret
Paste the service account JSON as a single line — the key file you downloaded, e.g. `{"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}`.
```bash
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
```
```nc:env-set
GCHAT_CREDENTIALS={{gchat_credentials}}
```
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/gchat`. This port must be
publicly reachable for Google Chat to deliver events — it's the HTTP endpoint
URL you set in the Connection settings above. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
@@ -115,5 +97,3 @@ If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
- **supports-threads**: yes
- **typical-use**: Interactive chat — team spaces or direct messages
- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts.
</content>
</invoke>
+42 -56
View File
@@ -5,68 +5,64 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
# Add GitHub Channel
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and
issue comment threads. NanoClaw doesn't ship channels in trunk — this skill
copies the GitHub adapter in from the `channels` branch.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
## Prerequisites
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
## Apply
## Install
### 1. Copy the adapter
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
Fetch the `channels` branch and copy the GitHub adapter into `src/channels/`
(overwrite — the branch is canonical):
### Pre-flight (idempotent)
```nc:copy from-branch:channels
src/channels/github.ts
src/channels/github-registration.test.ts
Skip to **Credentials** if all of these are already in place:
- `src/channels/github.ts` exists
- `src/channels/github-registration.test.ts` exists
- `src/channels/index.ts` contains `import './github.js';`
- `@chat-adapter/github` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './github.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/github@4.29.0
```bash
pnpm install @chat-adapter/github@4.29.0
```
### 4. Build and validate
### 5. Build and validate
The build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter import throws if `@chat-adapter/github`
isn't present):
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/github-registration.test.ts
```
`github-registration.test.ts` imports the real channel barrel and asserts the
registry contains `github`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed
(the import throws) — so it also covers the dependency from step 3.
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real GitHub repo is verified manually once
the service is running — see Next Steps and the webhook setup below.
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
@@ -92,28 +88,18 @@ On each repo (logged in as the repo owner/admin):
### 3. Configure environment
Capture the three values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten):
Add to `.env`:
```nc:prompt github_token secret
Paste the Fine-grained Personal Access Token for the bot account — starts with `github_pat_`.
```
```nc:prompt webhook_secret secret
Paste the webhook secret you generated for the repo webhook(s).
```
```nc:prompt bot_username
Enter the bot account's GitHub username exactly (used for @-mention detection).
```
```nc:env-set
GITHUB_TOKEN={{github_token}}
GITHUB_WEBHOOK_SECRET={{webhook_secret}}
GITHUB_BOT_USERNAME={{bot_username}}
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public repo?**
+7 -7
View File
@@ -19,17 +19,17 @@ ncl groups config remove-mcp-server --id <group-id> --name gmail
## 3. Remove the `.gmail-mcp` mount (per group)
Remove the mount with the host-only `ncl groups config remove-mount` verb (operator-only; rejected from inside a container). For each group that had Gmail wired:
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
```bash
ncl groups config remove-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
GROUP_ID='<group-id>'
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
The verb is idempotent — a no-op if the mount is already absent.
## 4. Remove the Dockerfile install
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
+11 -6
View File
@@ -181,16 +181,21 @@ Approval behaviour depends on where you run it: from inside an agent's container
### Add the `.gmail-mcp` mount
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
ncl groups config add-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
`--host` is the host path, `--container` is the in-container path (relative, lands at `/workspace/extra/.gmail-mcp`). No `--ro` — the MCP server writes refreshed token state back into the mount. The verb is idempotent (a re-run skips if the mount is already present) and operator-only (host-side; rejected from inside a container), so run it from a host operator shell when applying this skill.
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
@@ -8,9 +8,10 @@
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* so the derivation is non-invocable from a test — we guard it structurally. Delete or
* rename either half (the function or the spread into allowedTools) and this goes red,
* surfacing that `gmail` tools would silently be filtered out despite being registered.
* so we assert the derivation structurally. Delete or rename the derivation and this goes
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
*
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
*/
import fs from 'fs';
import path from 'path';
@@ -24,6 +25,11 @@ function source(): { sf: ts.SourceFile; text: string } {
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
}
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
function expectedPattern(name: string): string {
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
const { sf, text } = source();
@@ -42,4 +48,8 @@ describe('claude.ts derives MCP allow-patterns from the registered servers', ()
const flat = text.replace(/\s+/g, ' ');
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
});
it('maps a gmail server name to mcp__gmail__*', () => {
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
});
});
+56 -142
View File
@@ -5,196 +5,110 @@ description: Add iMessage channel integration via Chat SDK. Local (macOS) or rem
# Add iMessage Channel
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full
Disk Access) or remote (Photon API). NanoClaw doesn't ship channels in trunk —
this skill copies the iMessage adapter in from the `channels` branch.
Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API).
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
### 1. Copy the adapter
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the iMessage adapter into `src/channels/`
(overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/imessage.ts
src/channels/imessage-registration.test.ts
- `src/channels/imessage.ts` exists
- `src/channels/imessage-registration.test.ts` exists
- `src/channels/index.ts` contains `import './imessage.js';`
- `chat-adapter-imessage` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './imessage.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
chat-adapter-imessage@0.1.1
```bash
pnpm install chat-adapter-imessage@0.1.1
```
### 4. Build and validate
### 5. Build and validate
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter's top-level `import` from
`chat-adapter-imessage` throws if it isn't):
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
`imessage-registration.test.ts` imports the real channel barrel and asserts the
registry contains `imessage` — it goes red if the import line is deleted or
drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't
installed (the import throws), so it also covers the dependency from step 3.
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real iMessage account is verified manually
once the service is running — see Next Steps.
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
## Credentials
iMessage runs in one of two modes:
- **Local (macOS)** — the bot runs on this Mac and talks via the signed-in
iMessage account. Reading `chat.db` needs Full Disk Access granted to the
Node binary the host runs under.
- **Remote (Photon API)** — the bot talks to a separate Photon server that owns
an iMessage account on another Mac. Use this off macOS, or to keep this Mac's
chat history out of the loop.
Mode choice and the Full Disk Access / Photon walkthroughs are human and
interactive. Pick the mode first (local is the macOS default; remote is the only
option off macOS), then walk only that mode's setup — the other mode's steps are
skipped:
```nc:prompt mode validate:^(local|remote)$
How should iMessage run — `local` (this Mac, needs Full Disk Access) or `remote` (a Photon server)?
```
### Local Mode (macOS)
Requirements: macOS, with Full Disk Access granted to the Node binary. Without
it the adapter can't read `chat.db` and inbound messages never arrive.
Requirements: macOS with Full Disk Access granted to the Node.js binary.
Local mode only works on a Mac — it reads this machine's iMessage `chat.db`
directly, and there is no such database off macOS. On any other OS, stop here and
use remote (Photon) mode instead; otherwise you'd write a local config that can
never receive a message:
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`). To make it easy, open the folder in Finder so the user can drag the file into System Settings:
```nc:run effect:check when:mode=local
[ "$(uname)" = Darwin ]
```
The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`),
so open its folder in Finder to make the drag-and-drop target obvious. Harmless
off a desktop (SSH/headless) — it just no-ops:
```nc:run effect:external when:mode=local
open "$(dirname "$(which node)")" 2>/dev/null || true
```bash
open "$(dirname "$(which node)")"
```
Then tell the user:
```nc:operator when:mode=local
Grant Full Disk Access to Node so iMessage can read your chat history:
1. Open System Settings > Privacy & Security > Full Disk Access.
2. Click +, then drag the "node" file from the Finder window that just opened.
3. Toggle it on, then come back here.
```
1. Open **System Settings** > **Privacy & Security** > **Full Disk Access**
2. Click **+**, then drag the `node` file from the Finder window that just opened
3. Toggle it on
Stop and wait for the user to confirm Full Disk Access is granted before
continuing.
Stop and wait for the user to confirm before continuing.
### Remote Mode (Photon API)
Photon is a separate service that owns an iMessage account and exposes it over
HTTP; NanoClaw talks to it via its API. Tell the user:
```nc:operator when:mode=remote
Set up remote iMessage via Photon:
1. Create a Photon server: https://photon.codes
2. Copy the server URL and API key from your Photon dashboard.
```
Then collect the two values:
```nc:prompt server_url when:mode=remote validate:^https?:// flags:i reuse:IMESSAGE_SERVER_URL
Your Photon server URL — starts with http:// or https:// (e.g. https://photon.example.com).
```
```nc:prompt api_key secret when:mode=remote reuse:IMESSAGE_API_KEY
Your Photon API key — from the Photon dashboard.
```
1. Set up a [Photon](https://photon.codes) account
2. Get your server URL and API key
### Configure environment
The two modes use different `.env` keys. Write only the keys for the chosen
mode, and strip the opposite mode's keys so a stale value can't confuse the
adapter's factory. The configure script owns this upsert-and-remove (a plain
set-if-absent env write can neither replace a stale value nor delete a key):
**Local mode** -- add to `.env`:
**Local mode** — writes `IMESSAGE_LOCAL=true` and `IMESSAGE_ENABLED=true`, and
removes `IMESSAGE_SERVER_URL` / `IMESSAGE_API_KEY` if present:
```nc:run effect:external when:mode=local
bash setup/channels/imessage-configure.sh local
```bash
IMESSAGE_ENABLED=true
IMESSAGE_LOCAL=true
```
**Remote mode** — writes `IMESSAGE_LOCAL=false`, `IMESSAGE_SERVER_URL`, and
`IMESSAGE_API_KEY`, and removes `IMESSAGE_ENABLED` if present:
**Remote mode** -- add to `.env`:
```nc:run effect:external when:mode=remote
bash setup/channels/imessage-configure.sh remote "{{server_url}}" "{{api_key}}"
```bash
IMESSAGE_LOCAL=false
IMESSAGE_SERVER_URL=https://your-photon-server.com
IMESSAGE_API_KEY=your-api-key
```
## Restart
Restart the service so it loads the iMessage adapter and the credentials you
just stored, and wait for its CLI socket before wiring:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Resolve your iMessage handle
The agent greets you in the iMessage conversation tied to the phone number or
Apple ID email you message from — that handle is both your identity and the
conversation address. Resolve it so the owner-wiring step can target it.
```nc:prompt owner_handle validate:^(\+\d{8,15}|[^\s@]+@[^\s@]+\.[^\s@]+)$
The phone number or email you iMessage from — a +E.164 number (e.g. +14155551234) or an email / Apple ID (e.g. you@icloud.com).
```
iMessage is a native adapter: it sends the raw handle as the conversation
address, with no channel prefix — so the messaging-group platform id is that
handle as-is.
```nc:run capture:platform_id
echo "{{owner_handle}}"
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
welcome iMessage goes out through the adapter once the service is running — in
local mode that needs Full Disk Access granted (above); in remote mode it goes
via your Photon server.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
+10 -1
View File
@@ -18,7 +18,16 @@ rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
## 2. Remove credentials
Remove `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_API_KEY`, `LINEAR_WEBHOOK_SECRET`, `LINEAR_BOT_USERNAME`, and `LINEAR_TEAM_KEY` from `.env`, then re-sync to the container:
Remove the Linear env vars from `.env`, then re-sync to the container:
```bash
LINEAR_CLIENT_ID
LINEAR_CLIENT_SECRET
LINEAR_API_KEY
LINEAR_WEBHOOK_SECRET
LINEAR_BOT_USERNAME
LINEAR_TEAM_KEY
```
```bash
mkdir -p data/env && cp .env data/env/env
+66 -98
View File
@@ -5,15 +5,7 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
# Add Linear Channel
Adds Linear support via the Chat SDK bridge. The agent participates in issue
comment threads. Every comment on a Linear issue triggers the agent — no
@-mention needed. NanoClaw doesn't ship channels in trunk — this skill copies the
Linear adapter in from the `channels` branch.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
## Prerequisites
@@ -28,65 +20,61 @@ re-run; anything a parser can't apply falls back to the prose beside it.
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
## Apply
## Install
Linear OAuth apps post and read comments under an app identity that can't be
@-mentioned, so when you wire the channel in `/manage-channels`, pick an engage
mode that responds to plain comments rather than mention-only.
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and wires it into the channel registry. Linear OAuth apps post and read comments under an app identity that can't be @-mentioned, so when you wire the channel in `/manage-channels`, pick an engage mode that responds to plain comments rather than mention-only.
### 1. Copy the adapter and its registration test
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Linear adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/linear.ts
src/channels/linear-registration.test.ts
- `src/channels/linear.ts` exists
- `src/channels/linear-registration.test.ts` exists
- `src/channels/index.ts` contains `import './linear.js';`
- `@chat-adapter/linear` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into the channel
registry:
```bash
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
git show origin/channels:src/channels/linear-registration.test.ts > src/channels/linear-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './linear.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/linear@4.29.0
```bash
pnpm install @chat-adapter/linear@4.29.0
```
### 4. Build and validate
### 5. Build and validate
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/linear-registration.test.ts
```
Both must be clean before proceeding. `linear-registration.test.ts` imports the
real channel barrel and asserts the registry contains `linear`. It goes red if
the `import './linear.js';` line is deleted or drifts, if the barrel fails to
evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so
it also covers the dependency from step 3. End-to-end message delivery against a
real Linear workspace is verified manually once the service is running — see
Wiring and Next Steps.
Both must be clean before proceeding. `linear-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `linear`. It goes red if the `import './linear.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/linear` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Linear workspace is verified manually once the service is running — see Wiring and Next Steps.
## Credentials
Linear app and webhook setup is human and interactive — these steps are prose
(no parser can click through the Linear UI), except the final env write.
### 1. Set up a webhook
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
@@ -98,68 +86,48 @@ Linear app and webhook setup is human and interactive — these steps are prose
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Store the credentials
### 2. Configure environment
Capture the values, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it. Here they go to `.env`
(set-if-absent — a value you've already filled in is never overwritten) and sync
to the container.
Use **either** the OAuth app credentials (recommended) **or** a Personal API key.
For the API-key path, paste `none` at the OAuth prompts and set `LINEAR_API_KEY`
in `.env` by hand (commented in the template below). `LINEAR_BOT_USERNAME` is the
display name for the bot, used for self-message detection when using a Personal
API Key. `LINEAR_TEAM_KEY` is the Linear team key (e.g. `ENG`, `NAN`) — find it
in Linear under Settings > Teams; all issues in this team route to one messaging
group.
```nc:prompt linear_client_id secret
Paste the OAuth Client ID — Linear Settings > API > OAuth Applications. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_client_secret secret
Paste the OAuth Client Secret. Paste `none` if using a Personal API key instead.
```
```nc:prompt linear_webhook_secret secret
Paste the webhook signing secret from the webhook you just created.
```
```nc:prompt linear_team_key
Enter the Linear team key (e.g. `ENG`, `NAN`) — Settings > Teams.
```
```nc:prompt linear_bot_username
Enter the bot display name (e.g. `NanoClaw Bot`).
```
```nc:env-set
LINEAR_CLIENT_ID={{linear_client_id}}
LINEAR_CLIENT_SECRET={{linear_client_secret}}
LINEAR_WEBHOOK_SECRET={{linear_webhook_secret}}
LINEAR_TEAM_KEY={{linear_team_key}}
LINEAR_BOT_USERNAME={{linear_bot_username}}
```
If you went the Personal API key route, add this line to `.env` instead of the
OAuth pair (agent posts as you, your own comments are filtered):
Add to `.env`:
```bash
LINEAR_API_KEY=lin_api_...
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
# OR Personal API key (simpler, but agent posts as you)
# LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
LINEAR_BOT_USERNAME=NanoClaw Bot
LINEAR_TEAM_KEY=ENG
```
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Linear is team-routed: the assistant watches one team and answers *every* comment
on its issues (it can't be @-mentioned). Wire the team you set up to an agent —
pick which one should answer (`ncl groups list` shows their folders):
Ask the user: **Is this a private or public Linear workspace?**
```nc:prompt agent_folder
Which agent should answer Linear comments? Enter its folder (run `ncl groups list`).
```
```nc:run effect:wire
ncl messaging-groups create --channel-type linear --platform-id linear:{{linear_team_key}} --is-group 1 --unknown-sender-policy public --name {{linear_team_key}}
ncl wirings create --channel-type linear --platform-id linear:{{linear_team_key}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern . --session-mode per-thread
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
```
Each issue thread becomes its own conversation. There's no welcome — Linear has
no direct message, so the assistant greets people when it first answers a comment.
For a public-internet workspace, restrict it to people you've registered with
`--unknown-sender-policy strict` (see the GitHub skill for adding members).
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
## Next Steps
+16 -1
View File
@@ -18,7 +18,22 @@ rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
## 2. Remove credentials
Remove the Matrix env vars apply set — `MATRIX_BASE_URL`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME`, and whichever auth path you chose (`MATRIX_USERNAME` + `MATRIX_PASSWORD`, or `MATRIX_ACCESS_TOKEN`) — from `.env`, then re-sync to the container:
Remove the `MATRIX_*` lines from `.env`:
```bash
MATRIX_BASE_URL
MATRIX_USERNAME
MATRIX_PASSWORD
MATRIX_USER_ID
MATRIX_BOT_USERNAME
MATRIX_ACCESS_TOKEN
MATRIX_INVITE_AUTOJOIN
MATRIX_INVITE_AUTOJOIN_ALLOWLIST
MATRIX_RECOVERY_KEY
MATRIX_DEVICE_ID
```
Then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
+42 -83
View File
@@ -5,53 +5,57 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
# Add Matrix Channel
Adds Matrix support via the Chat SDK bridge. NanoClaw doesn't ship channels in
trunk — this skill copies the Matrix adapter in from the `channels` branch.
Adds Matrix support via the Chat SDK bridge.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
### 1. Copy the adapter
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
(overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/matrix.ts
src/channels/matrix-registration.test.ts
- `src/channels/matrix.ts` exists
- `src/channels/matrix-registration.test.ts` exists
- `src/channels/index.ts` contains `import './matrix.js';`
- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
git show origin/channels:src/channels/matrix-registration.test.ts > src/channels/matrix-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './matrix.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
The Matrix adapter lives in the `@beeper/` namespace and versions on its own
track (not the `@chat-adapter/*` family), so it carries its own pin:
```nc:dep
@beeper/chat-adapter-matrix@0.2.0
```bash
pnpm install @beeper/chat-adapter-matrix@0.2.0
```
### 4. Patch matrix-js-sdk ESM imports
### 5. Patch matrix-js-sdk ESM imports
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
extensions, which fails under Node 22 strict ESM resolution. Add the missing
extensions (idempotent — safe to re-run). Re-run this after every `pnpm install`
that touches the adapter:
extensions (idempotent — safe to re-run):
```nc:run effect:external
```bash
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
@@ -65,32 +69,22 @@ node -e '
'
```
### 5. Build
Re-run this after every `pnpm install` that touches the adapter.
Build guards the typed `createChatSdkBridge(...)` core call the adapter makes
and proves the dependency is installed and the ESM patch took. It also fails if
the `import './matrix.js';` line is missing or the barrel can't evaluate.
### 6. Build and validate
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/matrix-registration.test.ts
```
`matrix-registration.test.ts` imports the real channel barrel and asserts the
registry contains `matrix`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't
installed (the import throws) — so it also covers the dependency from step 3.
Both must be clean before proceeding. `matrix-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `matrix`. It goes red if the `import './matrix.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@beeper/chat-adapter-matrix` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Matrix homeserver is verified
manually once the service is running — see Next Steps.
End-to-end message delivery against a real Matrix homeserver is verified manually once the service is running — see Next Steps.
## Credentials
The bot needs its own Matrix account — separate from the user's account. This is
required because Matrix cannot send DMs to yourself. These steps are human and
interactive (no parser can click through Element), so they stay prose.
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
### Create a bot account
@@ -137,47 +131,12 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
```
### Store the credentials
### Configure environment
Capture the values for the auth method you chose, then write them. `prompt` only
*asks* and binds the answer to a name; a separate directive consumes it — so the
same prompts could feed `ncl` or the OneCLI vault instead of `.env` by swapping
only the consumer. The homeserver URL, the bot's user ID, and a display name are
shared across both auth methods:
Add the chosen env vars to `.env`, then sync:
```nc:prompt base_url
Paste the homeserver base URL, e.g. `https://matrix.org`.
```
```nc:prompt user_id
Paste the bot's full Matrix user ID, e.g. `@andybot:matrix.org`.
```
```nc:prompt bot_username
Paste a display name for the bot, e.g. `Andy`.
```
```nc:env-set
MATRIX_BASE_URL={{base_url}}
MATRIX_USER_ID={{user_id}}
MATRIX_BOT_USERNAME={{bot_username}}
```
For **Option A** capture the bot login, for **Option B** capture the access
token — set only the block matching your chosen method:
```nc:prompt username
Option A only — the bot's login username (the localpart, e.g. `andybot`).
```
```nc:prompt password secret
Option A only — the bot account's password.
```
```nc:env-set
MATRIX_USERNAME={{username}}
MATRIX_PASSWORD={{password}}
```
```nc:prompt access_token secret
Option B only — the access token from Element Settings > Help & About, or from the login API.
```
```nc:env-set
MATRIX_ACCESS_TOKEN={{access_token}}
```bash
mkdir -p data/env && cp .env data/env/env
```
## Next Steps
+46 -97
View File
@@ -5,70 +5,61 @@ description: Add Resend (email) channel integration via Chat SDK.
# Add Resend Email Channel
Connect NanoClaw to email via Resend for async email conversations. NanoClaw
doesn't ship channels in trunk — this skill copies the Resend adapter in from the
`channels` branch.
Connect NanoClaw to email via Resend for async email conversations.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch.
### 1. Copy the adapter
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Resend adapter into `src/channels/`
(overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/resend.ts
src/channels/resend-registration.test.ts
- `src/channels/resend.ts` exists
- `src/channels/resend-registration.test.ts` exists
- `src/channels/index.ts` contains `import './resend.js';`
- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
git show origin/channels:src/channels/resend-registration.test.ts > src/channels/resend-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './resend.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@resend/chat-sdk-adapter@0.1.1
```bash
pnpm install @resend/chat-sdk-adapter@0.1.1
```
### 4. Build and validate
### 5. Build and validate
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed (the adapter imports `@resend/chat-sdk-adapter`; if it
isn't installed the barrel throws). End-to-end email delivery against a real
domain is verified manually once the service runs.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/resend-registration.test.ts
```
`resend-registration.test.ts` imports the real channel barrel and asserts the
registry contains `resend`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed
(the import throws) — so it also covers the dependency from step 3.
Both must be clean before proceeding. `resend-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `resend`. It goes red if the `import './resend.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@resend/chat-sdk-adapter` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
Resend account and domain setup is human and interactive — these steps are
prose, not directives (no parser can verify a sending domain or click through the
Resend UI). A recipe rebuild produces a compiling, registered adapter that cannot
receive a message until they're done.
1. Go to [resend.com](https://resend.com) and create an account.
2. Add and verify your sending domain.
3. Go to **API Keys** and create a new key.
@@ -78,72 +69,30 @@ receive a message until they're done.
- Events: select **email.received**.
- Copy the signing secret.
### Store the credentials
### Configure environment
Capture the secrets, then write them. `prompt` only *asks* and binds the answer
to a name; a separate directive consumes it — so the same prompts could feed
`ncl` or the OneCLI vault instead of `.env` by swapping only the consumer. Here
they go to `.env` (set-if-absent — a value you've already filled in is never
overwritten):
Add to `.env`:
```nc:prompt api_key secret
Paste the Resend API key — API Keys, starts with `re_`.
```
```nc:prompt webhook_secret secret
Paste the webhook signing secret — Webhooks, the value you copied above.
```
```nc:prompt from_address
The bot's sending email address on your verified domain (e.g. `bot@yourdomain.com`).
```
```nc:prompt from_name
The display name to send as (e.g. `NanoClaw`).
```
```nc:env-set
RESEND_API_KEY={{api_key}}
RESEND_FROM_ADDRESS={{from_address}}
RESEND_FROM_NAME={{from_name}}
RESEND_WEBHOOK_SECRET={{webhook_secret}}
```
## Connect yourself
Because email is direct-addressable, the bot can write to you first — so wire
your own address as the owner and have it email you a hello. Tell it your address
and which agent should answer your email (`ncl groups list` shows their folders):
```nc:prompt owner_email
Your email address — I'll wire you as owner and email you a hello.
```
```nc:prompt agent_folder
Which agent should answer your email? Enter its folder (run `ncl groups list`).
```bash
RESEND_API_KEY=re_...
RESEND_FROM_ADDRESS=bot@yourdomain.com
RESEND_FROM_NAME=NanoClaw
RESEND_WEBHOOK_SECRET=your-webhook-secret
```
Register yourself as the owner, wire your address so the agent answers your email,
and send the hello:
```nc:run effect:wire
ncl users create --id resend:{{owner_email}} --kind resend --display-name Owner
ncl roles grant --user resend:{{owner_email}} --role owner
ncl messaging-groups create --channel-type resend --platform-id resend:{{owner_email}} --is-group 0
ncl wirings create --channel-type resend --platform-id resend:{{owner_email}} --agent-group {{agent_folder}} --engage-mode pattern --engage-pattern .
ncl messaging-groups send --channel-type resend --platform-id resend:{{owner_email}} --sender-id resend:{{owner_email}} --sender Owner --text "Hi — I'm your NanoClaw assistant, reachable by email now. Reply to this thread anytime."
```
The hello arrives as a fresh email thread; reply to keep the conversation going.
Your own address is the conversation key (`resend:<your-address>`).
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. (Answering an
*open* inbox — anyone who emails in, not just you — is a separate, not-yet-wired
case: email is plain-message, so the router never auto-creates a group for an
unknown sender; each correspondent's `resend:<their-address>` must be wired
explicitly.)
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `resend`
- **terminology**: Resend handles email. The bot has one fixed sending identity (`RESEND_FROM_ADDRESS`, e.g. `bot@yourdomain.com`); every *external correspondent* the bot emails with is a separate conversation, keyed by *their* address.
- **how-to-find-id**: The platform ID is the **correspondent's** email address, prefixed — `resend:<their-address>` (e.g. `resend:you@example.com`) — **not** the bot's from-address. The adapter derives it from the reply-to party (`channelIdFromThreadId` returns `resend:<address>`); each distinct email thread from that person (by root `Message-ID`) is a sub-conversation under it.
- **supports-threads**: no (the adapter sets `supportsThreads: false`; replies still thread via email headers, but the router does not treat threads as the primary conversation unit)
- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity.
- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation.
- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together)
- **typical-use**: Async communication -- email conversations with longer response expectations
- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels.
+11 -5
View File
@@ -4,15 +4,21 @@ Idempotent — safe to run even if some steps were never applied. Run Steps 1
## 1. Remove the mount from the container config
Remove the rtk mount with the host-only `remove-mount` verb. It is idempotent — a no-op if the mount isn't present:
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
```bash
ncl groups config remove-mount --id <group-id> \
--host ~/.local/bin/rtk \
--container /usr/local/bin/rtk
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
```
This verb is operator-only and runs host-side; it is rejected from inside a container.
Write the filtered array (omit any entry with `"containerPath":"/usr/local/bin/rtk"`):
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"UPDATE container_configs SET additional_mounts = '<filtered-json>' WHERE agent_group_id = '<group-id>'"
```
If no rtk entry is present, leave the array as-is.
## 2. Remove the PreToolUse hook from settings.json
+21 -15
View File
@@ -13,10 +13,6 @@ Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 6090%
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
## Integration tests
This skill has **no in-tree integration test** by design. Its only functional reach-ins are runtime operator actions — the host-only `ncl groups config add-mount` (Step 3) and the `settings.json` `PreToolUse` hook write (Step 4) — neither of which leaves a line in the source tree whose deletion a test could catch. There are no package dependencies or Dockerfile edits to guard either. Conformance is idempotent apply + `REMOVE.md`; the mount and hook are verified at runtime (see Verify).
## Step 1 — Install rtk on the host
```bash
@@ -47,24 +43,33 @@ Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 35 for each
## Step 3 — Mount rtk into the container config
Mount the host rtk binary read-only into the container with the host-only `add-mount` verb. It is idempotent — re-running skips the entry if it is already present:
`additional_mounts` is a JSON array column on `container_configs`. Read the current value, merge in the rtk entry, and write the merged array back.
Read current mounts first:
```bash
ncl groups config add-mount --id <group-id> \
--host ~/.local/bin/rtk \
--container /usr/local/bin/rtk \
--ro
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
```
This verb is operator-only and runs host-side (via `/setup`, `/customize`, or `/manage-mounts`); it is rejected from inside a container.
Build the merged array: keep every existing entry, drop any entry whose `containerPath` is `/usr/local/bin/rtk` (so re-running replaces rather than duplicates), then add the rtk entry:
The host root (`~/.local/bin`) must also be in the external mount allowlist at `~/.config/nanoclaw/mount-allowlist.json` for the mount to take effect at spawn. Add it there if it isn't already.
```json
{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}
```
Write the merged array back:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
```
Verify:
```bash
ncl groups config get --id <group-id>
# Look for the /usr/local/bin/rtk mount
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
```
## Step 4 — Add the PreToolUse hook to settings.json
@@ -115,8 +120,9 @@ Then ask the agent to run `git status` or any other supported command. rtk inter
Mount wasn't applied or container wasn't restarted:
```bash
ncl groups config get --id <group-id>
# Look for the /usr/local/bin/rtk mount
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
# Look for entry with /usr/local/bin/rtk
ncl groups restart --id <group-id>
```
+188 -154
View File
@@ -1,168 +1,53 @@
---
name: add-signal
description: Add Signal channel integration via signal-cli device-link. Native adapter — no Chat SDK bridge.
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal support via a native adapter that speaks JSON-RPC to a
[signal-cli](https://github.com/AsamK/signal-cli) daemon — no Chat SDK bridge,
only Node.js builtins. NanoClaw links to Signal as a *secondary device* on your
existing phone: no new number, no bot API. Your assistant sends and receives as
the number on the phone that scans the link.
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
## Apply
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
### 1. Install signal-cli
## Prerequisites
NanoClaw talks to Signal through signal-cli, which has no bot API of its own.
Install it if it isn't on PATH yet — Homebrew on macOS, the native release binary
on Linux (neither needs Java). If it's already installed this is a no-op:
### Java
```nc:run effect:external
command -v signal-cli >/dev/null 2>&1 || bash setup/install-signal-cli.sh
signal-cli requires Java 17+:
```bash
java -version
```
### 2. Copy the adapter and its registration test
If missing:
- **macOS:** `brew install --cask temurin@17`
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
Fetch the `channels` branch and copy the Signal adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
Java 1725 all work.
```nc:copy from-branch:channels
src/channels/signal.ts
src/channels/signal-registration.test.ts
### signal-cli
- **macOS:** `brew install signal-cli`
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
```bash
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar -xz -C ~/.local
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
signal-cli --version
```
### 3. Register the adapter
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
Append the self-registration import to the channel barrel (skipped if the line is
already present). This one line is the skill's only reach-in into core:
## Registration
```nc:append to:src/channels/index.ts
import './signal.js';
```
Two paths. The new-number path is recommended and battle-tested.
### 4. Install the QR-rendering dependency
### Path A: Register a new number (recommended)
The device-link step renders the linking URL as a terminal QR via `qrcode`.
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`:
```nc:dep
qrcode@1.5.4
@types/qrcode@1.5.6
```
The adapter itself consumes only Node.js builtins, so there is no adapter package
to install — `qrcode` is purely for rendering the link during setup.
### 5. Build and validate
Build first: it guards the adapter's typed core-API consumption. Then run the one
integration test.
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/signal-registration.test.ts
```
`signal-registration.test.ts` imports the real channel barrel and asserts the
registry contains `signal`. It goes red if the `import './signal.js';` line is
deleted or drifts, or if the barrel fails to evaluate — so the channel genuinely
would not register. The adapter has no npm dependency to guard; its typed
core-API consumption is covered by the build. End-to-end delivery against a real
Signal account is verified manually once the service runs.
## Link your Signal account
This is the whole credential step. signal-cli opens a device-link handshake,
prints a `sgnl://linkdevice…` URL, and renders it as a scannable QR. You scan it
once from the phone that already runs Signal; that phone's number becomes the
account NanoClaw sends and receives as — no number is registered.
The device-link runs signal-cli, so it must be reachable first — on `PATH`, or at
`$SIGNAL_CLI_PATH`. If step 1's install didn't land, the link step has nothing to
drive; confirm it's present before linking (re-run step 1 if this fails):
```nc:run effect:check
command -v signal-cli >/dev/null 2>&1 || [ -x "$SIGNAL_CLI_PATH" ]
```
Tell the user:
```nc:operator
Link NanoClaw to your Signal account:
1. On the phone that runs Signal, open Signal → Settings → Linked Devices → Link New Device.
2. Scan the QR code shown below — or open the `sgnl://linkdevice…` link printed under it on that phone.
3. Wait for confirmation. The linking URL expires after ~3 minutes; re-run this step for a fresh one.
```
Run the device-link. It blocks until you scan, then reports the linked phone
number back as the account — that number is both your owner handle and the
conversation address the wiring step needs:
```nc:run effect:step capture:platform_id=ACCOUNT,owner_handle=ACCOUNT
pnpm exec tsx setup/index.ts --step signal-auth
```
`owner_handle` and `platform_id` both come back as the bare phone number (e.g.
`+15551234567`). Your assistant reaches you through Signal's Note to Self, so the
owner conversation is addressed by your own number — not a per-contact UUID.
## Persist the account
Store the linked number so the adapter binds the right account on start, then sync
it into the container env:
```nc:env-set
SIGNAL_ACCOUNT={{platform_id}}
```
## Restart
Restart the service so it loads the Signal adapter and binds the account you just
linked, and wait for its CLI socket before wiring:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups." The owner reaches their own assistant through Note to Self.
- **platform-id-format**:
- Owner DM (Note to Self): the bare phone number `+<number>` (e.g. `+15551234567`) — your own messages route back as inbound with `isFromMe`, addressed by your number.
- Third-party DM: `signal:{UUID}` — the sender's Signal ACI, **not** their phone number.
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID.
- **how-to-find-id**: The owner number comes back from the device-link step above. For third parties or groups, send a message to the bot, then query `messaging_groups`.
- **supports-threads**: no
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode.
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles).
- Quoted replies — `replyTo*` fields populated from Signal quotes.
- Typing indicators — DMs only (Signal doesn't support group typing).
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`.
- Voice attachments — detected but not transcribed by default; the agent receives a `[Voice Message]` placeholder. Run `/add-voice-transcription` for local transcription.
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Alternatives
### Register a dedicated number instead of linking
The device-link above joins Signal as a *secondary device* on an existing number.
If you'd rather give the assistant its own number, register a dedicated SIM or
VoIP number that NanoClaw owns entirely. This path takes a captcha, an SMS (or
voice) verification, and an optional profile name.
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
@@ -222,12 +107,69 @@ signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start $(systemd_unit)
```
Once registered, set `SIGNAL_ACCOUNT` to this number (as under **Persist the account** above) and restart the service.
### Path B: Link as secondary device
## Optional configuration
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
These `.env` keys tune how NanoClaw talks to the signal-cli daemon. All are
optional — the defaults work for the device-link flow above.
```bash
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
```
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/signal.ts` exists
- `src/channels/signal.test.ts` exists
- `src/channels/signal-registration.test.ts` exists
- `src/channels/index.ts` contains `import './signal.js';`
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter and tests
```bash
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
git show origin/channels:src/channels/signal-registration.test.ts > src/channels/signal-registration.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './signal.js';
```
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/signal-registration.test.ts
```
Both must be clean before proceeding. `signal-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `signal`. It goes red if the `import './signal.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter consumes only Node.js builtins, so there is no npm dependency to guard for this channel. The adapter's typed core-API consumption is guarded by `pnpm run build`.
## Credentials
Add to `.env`:
```bash
SIGNAL_ACCOUNT=+1YOURNUMBER
```
### Optional settings
```bash
# TCP daemon host and port (default: 127.0.0.1:7583)
@@ -247,6 +189,94 @@ SIGNAL_DATA_DIR=~/.local/share/signal-cli
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
# Linux
systemctl --user restart $(systemd_unit)
```
## Wiring
### DMs
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
### Groups
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
"
```
### Grant user access
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
"
```
Find the UUID from `messaging_groups.platform_id` or the `users` table.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
- **supports-threads**: no
- **platform-id-format**:
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
- Quoted replies — `replyTo*` fields populated from Signal quotes
- Typing indicators — DMs only (Signal doesn't support group typing)
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Troubleshooting
### Daemon not reachable
@@ -257,9 +287,9 @@ grep "Signal" logs/nanoclaw.log | tail
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
- Confirm the account is linked: `signal-cli -a +YOURNUMBER listIdentities` should succeed without prompting
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +YOURNUMBER daemon --tcp 127.0.0.1:7583`.
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
### Bot not responding
@@ -278,7 +308,7 @@ If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs
### Messages dropped with `not_member`
The Signal user hasn't been granted membership. New Signal senders — including the owner's Signal identity — are gated until granted access. `/init-first-agent` grants the owner automatically; for other users, wire access with `/manage-channels` after their first message appears in `messaging_groups`. This affects every new Signal user, since their Signal identity is a separate user record from their identity on other channels even if it's the same person.
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
### Captcha required
@@ -296,6 +326,10 @@ signal-cli holds an exclusive lock on its data directory while the daemon is run
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
### QR / linking URL expired
### Java not found
The `sgnl://linkdevice…` URL (and the Path A registration captcha) expire after a few minutes. Re-run the device-link step to get a fresh QR.
Install Java 17+ — see the Prerequisites section above.
### QR code expired (Path B)
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
+76 -143
View File
@@ -5,181 +5,114 @@ description: Add Slack channel integration via Chat SDK.
# Add Slack Channel
Adds Slack support via the Chat SDK bridge. NanoClaw doesn't ship channels in
trunk — this skill copies the Slack adapter in from the `channels` branch.
Adds Slack support via the Chat SDK bridge.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch.
### 1. Copy the adapter and its registration test
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Slack adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/slack.ts
src/channels/slack-registration.test.ts
- `src/channels/slack.ts` exists
- `src/channels/slack-registration.test.ts` exists
- `src/channels/index.ts` contains `import './slack.js';`
- `@chat-adapter/slack` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './slack.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/slack@4.29.0
```bash
pnpm install @chat-adapter/slack@4.29.0
```
### 4. Build and validate
### 5. Build and validate
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/slack-registration.test.ts
```
`slack-registration.test.ts` imports the real channel barrel and asserts the
registry contains `slack`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real workspace is verified manually once the service runs.
Both must be clean before proceeding. `slack-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `slack`. It goes red if the `import './slack.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/slack` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Slack workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
Slack can deliver events two ways. Socket Mode holds an outbound WebSocket open
— no public URL, so it works on a laptop or behind NAT — and is the right
default. Webhook delivery needs a public HTTPS Request URL but avoids the
long-lived socket. The adapter picks Socket Mode automatically whenever
`SLACK_APP_TOKEN` is set; otherwise it serves the webhook.
### Create Slack App
```nc:prompt connection validate:^(socket|webhook)$
How should Slack deliver events? `socket` (Socket Mode — no public URL, recommended for local or behind-NAT installs) or `webhook` (needs a public HTTPS Request URL).
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
### Enable DMs
6. Go to **App Home** and enable the **Messages Tab**
7. Check **"Allow users to send Slash commands and messages from the messages tab"**
### Event Subscriptions
8. Go to **Event Subscriptions** and toggle **Enable Events**
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
### Interactivity
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
### Configure environment
Add to `.env`:
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
```
Walk the operator through creating the Slack app, then collect the secrets it
hands back. The adapter is already installed and registered — it just can't
receive a message until this is done. For Socket Mode, tell the user:
Sync to container: `mkdir -p data/env && cp .env data/env/env`
```nc:operator when:connection=socket
Create the Slack app (Socket Mode):
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
5. Socket Mode → toggle "Enable Socket Mode" on.
6. Event Subscriptions → toggle "Enable Events" on, then under "Subscribe to bot events" add: message.channels, message.groups, message.im, app_mention. Save Changes. (No Request URL is needed in Socket Mode.)
7. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
```
### Webhook server
For webhook delivery, tell the user:
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
```nc:operator when:connection=webhook
Create the Slack app (webhook delivery):
1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace.
2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write.
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
4. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
5. Basic Information → copy the Signing Secret.
```
Collect the secrets and store them (the bridge reads them from `.env`; the
app-level token doubles as the Socket Mode switch, the signing secret
authenticates webhook requests — each mode needs only its own):
```nc:prompt bot_token secret validate:^xoxb-
Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`.
```
```nc:prompt app_token secret validate:^xapp- reuse:SLACK_APP_TOKEN when:connection=socket
Paste the App-Level Token — Basic Information → App-Level Tokens, starts with `xapp-`.
```
```nc:prompt signing_secret secret validate:^[a-fA-F0-9]{16,}$ when:connection=webhook
Paste the Signing Secret — Basic Information.
```
```nc:env-set
SLACK_BOT_TOKEN={{bot_token}}
```
```nc:env-set when:connection=socket
SLACK_APP_TOKEN={{app_token}}
```
```nc:env-set when:connection=webhook
SLACK_SIGNING_SECRET={{signing_secret}}
```
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
automatically; to receive replies, that port must be reachable from the internet
and registered with Slack as the Request URL (Socket Mode needs no public URL —
with the bot events subscribed above, events arrive over the socket as soon as
the service restarts). Tell the user:
```nc:operator when:connection=webhook
Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS):
1. Event Subscriptions → Enable Events. Set the Request URL to https://<your-public-host>/webhook/slack and wait for the challenge to pass.
2. Subscribe to bot events: message.channels, message.groups, message.im, app_mention. Save Changes.
3. Interactivity & Shortcuts → toggle Interactivity on, set the same Request URL, Save Changes, then reinstall the app when Slack prompts.
```
## Resolve your DM channel
The agent talks to you in your direct-message channel with the bot. Resolve its
address so the owner-wiring step can target it. Validating the token here, before
the restart, fast-fails a bad credential while it's still cheap to fix. You'll
need your Slack member ID: open your profile (your avatar, bottom-left), then
**⋮** → **Copy member ID** — it starts with `U`.
```nc:prompt owner_handle validate:^U[A-Z0-9]{8,}$
Your Slack member ID (Profile → ⋮ → "Copy member ID"; starts with U).
```
Confirm the bot token works and capture the bot identity — `auth.test` returns the
bot user and workspace, and fails here if the token is bad:
```nc:run capture:connected_as effect:fetch
curl -sf -X POST https://slack.com/api/auth.test -H "Authorization: Bearer {{bot_token}}" | jq -er '"@" + .user + " in " + .team'
```
Open the DM with `conversations.open` and take the channel id it returns as the
conversation address `slack:<channelId>` (if Slack returns no channel, the bot is
missing the `im:write` scope — add it and reinstall):
```nc:run capture:platform_id effect:fetch
curl -s -X POST https://slack.com/api/conversations.open -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"users":"{{owner_handle}}"}' | jq -er '"slack:" + .channel.id'
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
greeting goes out over `chat.postMessage`, which works right away. Receiving
replies needs the event path live: with Socket Mode that happens as soon as the
service restarts below; with webhook delivery, finish the Event Subscriptions
and Interactivity steps above first.
## Restart
With the credential validated, restart the service so it loads the Slack adapter
and the secrets you just stored, and wait for its CLI socket before wiring:
```nc:run effect:restart
bash setup/lib/restart.sh
```
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
+8 -1
View File
@@ -18,7 +18,14 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
## 2. Remove credentials
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
Remove the `TEAMS_*` lines from `.env`, then re-sync to the container:
```bash
TEAMS_APP_ID
TEAMS_APP_PASSWORD
TEAMS_APP_TENANT_ID
TEAMS_APP_TYPE
```
```bash
mkdir -p data/env && cp .env data/env/env
+153 -245
View File
@@ -5,255 +5,68 @@ description: Add Microsoft Teams channel integration via Chat SDK.
# Add Microsoft Teams Channel
Adds Microsoft Teams support via the Chat SDK bridge — interactive chat in team
channels, group chats, and direct messages. NanoClaw doesn't ship channels in
trunk — this skill copies the Teams adapter in from the `channels` branch.
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
Teams is the most involved channel NanoClaw supports — there's no "paste a
token" shortcut. You'll walk through about seven Azure portal steps (app
registration, client secret, Azure Bot resource, messaging endpoint, Teams
channel, app package, sideload). Take them one at a time; the prompts below
collect each value as you produce it.
NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter and its registration test
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Teams adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
- `src/channels/teams.ts` exists
- `src/channels/teams-registration.test.ts` exists
- `src/channels/index.ts` contains `import './teams.js';`
- `@chat-adapter/teams` is listed in `package.json` dependencies
```nc:copy from-branch:channels
src/channels/teams.ts
src/channels/teams-registration.test.ts
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/teams.ts > src/channels/teams.ts
git show origin/channels:src/channels/teams-registration.test.ts > src/channels/teams-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './teams.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/teams@4.29.0
```bash
pnpm install @chat-adapter/teams@4.29.0
```
### 4. Build and validate
### 5. Build and validate
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/teams-registration.test.ts
```
`teams-registration.test.ts` imports the real channel barrel and asserts the
registry contains `teams`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the
import throws) — so it also covers the dependency from step 3. End-to-end
delivery against a real Teams workspace is verified manually once the service
runs.
Both must be clean before proceeding. `teams-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `teams`. It goes red if the `import './teams.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/teams` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Teams workspace is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
The adapter is installed and registered, but it can't receive a message until a
bot exists in Azure, points at this machine, and is sideloaded into Teams. None
of those steps can be clicked through by a parser, so they're operator
instructions — relay each one, then collect the value it produces.
Before you start, tell the user:
```nc:operator
Confirm you have everything Teams setup needs:
1. A Microsoft 365 tenant where you can sideload custom apps — free personal Teams does NOT support this; you need a Microsoft 365 Business / EDU / developer tenant with Teams admin or developer rights.
2. A way to expose an HTTPS endpoint from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it.
```
### Public URL
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
don't have a tunnel running yet, start one in another terminal first — e.g.
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
```
### Register the Azure app
Tell the user:
```nc:operator
Create the Azure AD app registration:
1. In https://portal.azure.com, search "App registrations" → "New registration".
2. Name it (e.g. "NanoClaw").
3. Supported account types: Single tenant (your org only — most common for self-host) OR Multi tenant (any Microsoft 365 tenant can add the bot).
4. Click Register.
5. On the Overview page, copy the Application (client) ID and, for a single-tenant app, the Directory (tenant) ID.
```
```nc:prompt app_id validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
Paste the Application (client) ID — App registration Overview page.
```
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked).
```
```nc:prompt app_tenant_id when:app_type=SingleTenant validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only).
```
### Create a client secret
Tell the user:
```nc:operator
Create the client secret:
1. In your app registration, open "Certificates & secrets".
2. Click "New client secret" — Description "nanoclaw", Expires 180 days (recommended) or longer.
3. Click Add.
4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
```
```nc:prompt app_password secret validate:^.{20,}$
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
```
### Store the credentials
The adapter reads these from `.env` (set-if-absent, so a value you've already
filled in is never overwritten).
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
doesn't need it.
```nc:env-set
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TYPE={{app_type}}
```
```nc:env-set when:app_type=SingleTenant
TEAMS_APP_TENANT_ID={{app_tenant_id}}
```
### Create the Azure Bot resource
Tell the user:
```nc:operator
Create the Azure Bot resource and point it at this machine:
1. In https://portal.azure.com, search "Azure Bot" → Create.
2. Bot handle: a unique name, e.g. nanoclaw-bot.
3. Type of App: {{app_type}} — Creation type: Use existing app registration.
4. App ID: {{app_id}}.
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
```
### Enable the Teams channel
Tell the user (finish every Azure step above before continuing — the package
built next bakes in the app registration, so the Azure app and bot must already
exist):
```nc:operator
Enable the Microsoft Teams channel on the bot:
1. Open your Azure Bot resource → Channels.
2. Click Microsoft Teams → Accept terms → Apply.
When the Azure app, bot resource, and Teams channel are all in place, continue.
```
### Build the Teams app package
The manifest bakes in the Application (client) ID, so the Azure app registration
above must be done first — building with a blank `app_id` produces a package that
no bot can claim. Confirm it's set before generating the zip:
```nc:run effect:check
[ -n "{{app_id}}" ]
```
Generate the zip you'll sideload into Teams (manifest + icons, written to
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
is safe to repeat.
```nc:run effect:external
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
```
### Sideload the app into Teams
Tell the user (do this before the restart below — the service should come up with
the app already sideloaded):
```nc:operator
Sideload the generated app package into Teams:
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app.
2. Click "Upload a custom app" (or "Upload for me or my teams").
3. Select data/teams/teams-app-package.zip and click Add.
4. If "Upload a custom app" is missing, your tenant admin has disabled sideloading — enable it in Teams Admin Center → Teams apps → Setup policies → Global → Upload custom apps = On.
Once the app is added in Teams, continue.
```
## Restart
Restart the service so it loads the Teams adapter and the credentials you just
stored:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Finish wiring
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the
bot for the first time — the adapter derives it from the inbound activity. So
this skill installs the adapter and stops here; you finish the wiring once the
bot has seen its first message. Tell the user:
```nc:operator
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
1. Find your bot in Teams (search by name, or via the app you just sideloaded) and send it a message ("hi" is fine).
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
```
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise,
once you've DM'd the bot, wire this channel with `/init-first-agent` (or
`/manage-channels`).
## Channel Info
- **type**: `teams`
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
- **platform-id-format**: `teams:{base64url-conversation-id}:{base64url-service-url}` — auto-generated by the adapter from the first inbound activity, not human-readable. Use the auto-created messaging group for wiring.
- **how-to-find-id**: Send a message to the bot in the channel or a DM. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group for wiring.
- **supports-threads**: yes (channels only; DMs and group chats are flat)
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
## Alternatives
Two paths — manual (Azure Portal) or auto (Teams CLI).
### Auto: Teams CLI
The Credentials flow above walks the manual Azure Portal path. If you'd rather
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot
registration in one command. Requires Node.js 18+, a Microsoft 365 account with
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel,
or similar).
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
1. Install the CLI:
@@ -284,10 +97,38 @@ or similar).
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
### Azure CLI for the bot resource
Continue to [Configure environment](#configure-environment).
The Azure Bot resource and Teams channel can also be created with `az` instead of
clicking through the portal:
---
The steps below describe the **manual Azure Portal path**.
### Step 1: Create an Azure AD App Registration
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
2. Name it (e.g., "NanoClaw")
3. Supported account types: **Single tenant** (your org only) or **Multi tenant** (any org)
4. Click **Register**
5. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page
### Step 2: Create a Client Secret
1. In the App Registration, go to **Certificates & secrets**
2. Click **New client secret**, description "nanoclaw", expiry 180 days
3. Click **Add** and **copy the Value immediately** (shown only once)
### Step 3: Create an Azure Bot
1. Go to Azure Portal > search **Azure Bot** > **Create**
2. Fill in:
- **Bot handle**: unique name (e.g., "nanoclaw-bot")
- **Type of App**: match your app registration (Single or Multi Tenant)
- **Creation type**: **Use existing app registration**
- **App ID**: paste from Step 1
- **App tenant ID**: paste from Step 1 (Single Tenant only)
3. Click **Review + create** > **Create**
Or use Azure CLI:
```bash
az group create --name nanoclaw-rg --location eastus
@@ -298,16 +139,72 @@ az bot create \
--appid YOUR_APP_ID \
--tenant-id YOUR_TENANT_ID \
--endpoint "https://your-domain/api/webhooks/teams"
```
### Step 4: Configure Messaging Endpoint
1. Go to your Azure Bot resource > **Configuration**
2. Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams`
3. Click **Apply**
### Step 5: Enable Teams Channel
1. In the Azure Bot resource, go to **Channels**
2. Click **Microsoft Teams** > Accept terms > **Apply**
Or via CLI:
```bash
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
```
## Optional configuration
### Step 6: Create and Sideload Teams App
### Receive all channel messages (without @-mention)
Create a `manifest.json`:
By default the bot only receives messages when @-mentioned. To receive every
message in a channel, add an RSC (resource-specific consent) permission to your
Teams app `manifest.json`:
```json
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
"manifestVersion": "1.16",
"version": "1.0.0",
"id": "YOUR_APP_ID",
"packageName": "com.nanoclaw.bot",
"developer": {
"name": "NanoClaw",
"websiteUrl": "https://your-domain",
"privacyUrl": "https://your-domain",
"termsOfUseUrl": "https://your-domain"
},
"name": { "short": "NanoClaw", "full": "NanoClaw Assistant" },
"description": {
"short": "NanoClaw assistant bot",
"full": "NanoClaw personal assistant powered by Claude."
},
"icons": { "outline": "outline.png", "color": "color.png" },
"accentColor": "#4A90D9",
"bots": [{
"botId": "YOUR_APP_ID",
"scopes": ["personal", "team", "groupchat"],
"supportsFiles": false,
"isNotificationOnly": false
}],
"permissions": ["identity", "messageTeamMembers"],
"validDomains": ["your-domain"]
}
```
Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together.
**Sideload in Teams:**
1. Open Teams > **Apps** > **Manage your apps**
2. Click **Upload an app** > **Upload a custom app**
3. Select the zip file
Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant.
### Step 7: Receive All Messages (Optional)
By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`:
```json
{
@@ -321,27 +218,38 @@ Teams app `manifest.json`:
}
```
Re-sideload the updated app package for the change to take effect.
### Configure environment
## Troubleshooting
Add to `.env`:
### "Upload a custom app" is missing in Teams
```bash
TEAMS_APP_ID=your-app-id
TEAMS_APP_PASSWORD=your-client-secret
# For Single Tenant only:
TEAMS_APP_TENANT_ID=your-tenant-id
TEAMS_APP_TYPE=SingleTenant
```
Your tenant admin has disabled sideloading. Enable it in Teams Admin Center →
**Teams apps** → **Setup policies** → **Global** → **Upload custom apps** = On.
Free personal Teams does not support sideloading at all — use a Microsoft 365
Business / EDU / developer tenant.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Bot never receives messages
### Webhook server
1. The tunnel is up and the messaging endpoint matches it: Azure Bot → **Configuration** → **Messaging endpoint** must be `https://<your-domain>/api/webhooks/teams`, and your tunnel (e.g. `ngrok http 3000`) must be forwarding to this machine's port 3000.
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities.
### `Unauthorized` / 401 from Azure Bot Service
For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration.
The client secret is wrong or expired, or — for a Single Tenant app — `TEAMS_APP_TENANT_ID` is missing. Regenerate the secret in **Certificates & secrets** (copy the Value, not the Secret ID), update `.env`, and restart.
## Next Steps
### Replies land in the wrong place
If you're in the middle of `/setup`, return to the setup flow now.
A Teams bot's platform ID is derived from the first inbound activity, so wire the messaging group that the router auto-creates after you DM the bot — don't guess the platform ID. See **Finish wiring** above.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `teams`
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring.
- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring.
- **supports-threads**: yes (channels only; DMs and group chats are flat)
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
+66 -111
View File
@@ -5,156 +5,111 @@ description: Add Telegram channel integration via Chat SDK.
# Add Telegram Channel
Adds Telegram bot support via the Chat SDK bridge. NanoClaw doesn't ship
channels in trunk — this skill copies the Telegram adapter, its
formatting/pairing helpers, and their tests in from the `channels` branch. The
`pair-telegram` setup step is maintained in trunk, so it is not copied here.
Adds Telegram bot support via the Chat SDK bridge.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter, its formatting/pairing helpers, their tests, and the `pair-telegram` setup step in from the `channels` branch.
### 1. Copy the adapter, helpers, and tests
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Telegram adapter, its pairing and
markdown-sanitize helpers (with their tests), and the registration test into
place (overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/telegram.ts
src/channels/telegram-pairing.ts
src/channels/telegram-pairing.test.ts
src/channels/telegram-markdown-sanitize.ts
src/channels/telegram-markdown-sanitize.test.ts
src/channels/telegram-registration.test.ts
- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
- `src/channels/telegram-registration.test.ts` exists
- `src/channels/index.ts` contains `import './telegram.js';`
- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':`
- `@chat-adapter/telegram` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter, helpers, tests, registration test, and setup step
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts
git show origin/channels:src/channels/telegram-registration.test.ts > src/channels/telegram-registration.test.ts
git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts
git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts
git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts
git show origin/channels:src/channels/telegram-markdown-sanitize.test.ts > src/channels/telegram-markdown-sanitize.test.ts
git show origin/channels:setup/pair-telegram.ts > setup/pair-telegram.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
import './telegram.js';
```
### 3. Register the pairing setup step
### 4. Register the setup step
Add the `pair-telegram` loader to the `STEPS` map in `setup/index.ts`, inside the
dormant marker region (skipped if already present — `pair-telegram` ships in core,
so this idempotent-skips on a normal install, but is expressed for a
clean-upstream rebuild). The pairing handshake below spawns this step:
In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present):
```nc:append to:setup/index.ts at:nanoclaw:setup-steps
```typescript
'pair-telegram': () => import('./pair-telegram.js'),
```
### 4. Install the adapter package
### 5. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/telegram@4.29.0
```bash
pnpm install @chat-adapter/telegram@4.29.0
```
### 5. Build and validate
### 6. Build and validate
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/telegram-registration.test.ts
```
`telegram-registration.test.ts` imports the real channel barrel and asserts the
registry contains `telegram`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed
(the import throws) — so it also covers the dependency from step 4. End-to-end
delivery against a real bot is verified manually once the service runs.
Both must be clean before proceeding. `telegram-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `telegram`. It goes red if the `import './telegram.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/telegram` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Telegram bot is verified manually once the service is running — see Next Steps and the pairing flow in Channel Info.
## Credentials
Bot creation in Telegram is human and interactive — no parser can click through
BotFather. The adapter is installed and registered, but it can't receive a
message until the bot exists. Tell the user:
### Create Telegram Bot
```nc:operator
Create the Telegram bot:
1. Open Telegram and message @BotFather — Telegram's official bot for creating bots.
2. Send /newbot and follow the prompts: a friendly name, then a username that must end in "bot".
3. Copy the bot token it gives you (looks like 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).
4. Planning to use the bot in group chats? Send /mybots → your bot → Bot Settings → Group Privacy → Turn off, so the bot can see all messages and not just @mentions.
1. Open Telegram and search for `@BotFather`
2. Send `/newbot` and follow the prompts:
- Bot name: Something friendly (e.g., "NanoClaw Assistant")
- Bot username: Must end with "bot" (e.g., "nanoclaw_bot")
3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
**Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages:
1. Open `@BotFather` > `/mybots` > select your bot
2. **Bot Settings** > **Group Privacy** > **Turn off**
### Configure environment
Add to `.env`:
```bash
TELEGRAM_BOT_TOKEN=your-bot-token
```
Collect the bot token and store it — the bridge reads it from `.env` (set-if-absent,
so a value you've already filled in is never overwritten) and syncs it to the
container:
```nc:prompt bot_token secret validate:^[0-9]+:[A-Za-z0-9_-]{35,}$
Paste the bot token from BotFather (looks like `123456:ABC-DEF...`).
```
```nc:env-set
TELEGRAM_BOT_TOKEN={{bot_token}}
```
Confirm the token works and capture the bot's handle — `getMe` returns the bot
account and fails here if the token is bad. You'll use the handle to open the
right chat just before pairing:
```nc:run capture:bot_username effect:fetch
curl -sf https://api.telegram.org/bot{{bot_token}}/getMe | jq -er '.result.username'
```
## Restart
Restart the service so it loads the Telegram adapter and the token you just
stored, and wait for its CLI socket. The adapter must be live and polling before
pairing — it's the thing that observes the code you send:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Pair your chat
Telegram tokens carry no user binding, so the agent proves you own the chat with
a one-time pairing handshake: it issues a 4-digit code, you send those exact 4
digits to the bot from the chat you want to register, and the live adapter
matches them. Open the bot first so you're on the right screen when the code
appears. Tell the user:
```nc:operator
Open @{{bot_username}} (https://t.me/{{bot_username}}) in Telegram now and keep it on screen — a 4-digit pairing code is about to appear in this terminal. When it does, send just those 4 digits to the bot as a message (in a group chat with Group Privacy on, prefix them with @{{bot_username}}). A wrong guess is rejected and a fresh code is issued automatically.
```
Run the pairing handshake. It prints the code, streams "waiting…" and wrong-code
feedback while it watches for your message, and resolves your chat address
`telegram:<chatId>` plus your Telegram user id once the code matches:
```nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_USER_ID
pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main
```
`owner_handle` (your Telegram user id) and `platform_id` (`telegram:<chatId>`)
are what the owner-wiring step needs. The greeting goes out over the same chat as
soon as pairing completes.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `telegram`
- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot.
- **platform-id-format**: `telegram:{chatId}` (e.g. `telegram:123456789` for a DM, `telegram:-1001234567890` for a group — negative chat IDs are groups/channels).
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`. The step prints a 4-digit code (and re-prints a fresh one if a wrong code invalidates it, up to 5 times); tell the user to send just those 4 digits from the chat they want to register (DM the bot for `main`, post in the group otherwise; with Group Privacy ON, prefix `@<botname> CODE`). Success emits a `PAIR_TELEGRAM` block with `STATUS=success`, `PLATFORM_ID`, `IS_GROUP`, `ADMIN_USER_ID` (the bare Telegram user id) and `PAIRED_USER_ID` (the `telegram:`-prefixed form). The service must be running — the polling adapter is what observes the code.
- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <main|wire-to:folder|new-agent:folder>`, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block (follow the `REMINDER_TO_ASSISTANT` line in that block), and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@<botname> CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code).
- **supports-threads**: no
- **typical-use**: Interactive chat — direct messages or small groups
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
+47 -72
View File
@@ -5,110 +5,85 @@ description: Add Webex channel integration via Chat SDK.
# Add Webex Channel
Adds Cisco Webex support via the Chat SDK bridge. NanoClaw doesn't ship channels
in trunk — this skill copies the Webex adapter in from the `channels` branch.
Adds Cisco Webex support via the Chat SDK bridge.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch.
### 1. Copy the adapter and its registration test
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the Webex adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/webex.ts
src/channels/webex-registration.test.ts
- `src/channels/webex.ts` exists
- `src/channels/webex-registration.test.ts` exists
- `src/channels/index.ts` contains `import './webex.js';`
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
git show origin/channels:src/channels/webex-registration.test.ts > src/channels/webex-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './webex.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`.
The Webex adapter ships under the third-party `@bitbasti/*` namespace, not
`@chat-adapter/*`, so it carries its own version line (`0.1.0`) rather than
tracking the chat core version:
```nc:dep
@bitbasti/chat-adapter-webex@0.1.0
```bash
pnpm install @bitbasti/chat-adapter-webex@0.1.0
```
### 4. Build and validate
### 5. Build and validate
Build first: it guards the typed `createChatSdkBridge(...)` core call and proves
the dependency is installed. Then run the one integration test.
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/webex-registration.test.ts
```
`webex-registration.test.ts` imports the real channel barrel and asserts the
registry contains `webex`. It goes red if the import line is deleted or drifts,
if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't
installed (the import throws) — so it also covers the dependency from step 3.
End-to-end delivery against a real Webex space is verified manually once the
service runs — see the webhook setup below.
Both must be clean before proceeding. `webex-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `webex`. It goes red if the `import './webex.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@bitbasti/chat-adapter-webex` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Webex space is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
Webex bot setup is human and interactive — these steps are prose, not directives
(no parser can click through the Webex Developer Portal). A recipe rebuild
produces a compiling, registered adapter that cannot receive a message until
they're done.
### Create the Webex bot
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot.
2. Copy the **Bot Access Token**.
1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot
2. Copy the **Bot Access Token**
3. Set up a webhook:
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`.
- Set a webhook secret for signature verification.
- Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex`
- Set a webhook secret for signature verification
### Store the credentials
### Configure environment
Capture the two values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten):
Add to `.env`:
```nc:prompt bot_token secret
Paste the Bot Access Token — from the Webex bot you created.
```bash
WEBEX_BOT_TOKEN=your-bot-token
WEBEX_WEBHOOK_SECRET=your-webhook-secret
```
```nc:prompt webhook_secret secret
Paste the webhook secret you set for signature verification.
```
```nc:env-set
WEBEX_BOT_TOKEN={{bot_token}}
WEBEX_WEBHOOK_SECRET={{webhook_secret}}
```
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/webex`. This port must be
publicly reachable for Webex to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL above.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise run
`/manage-channels` to wire this channel to an agent group.
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
+9 -5
View File
@@ -36,12 +36,16 @@ pnpm uninstall wechat-ilink-client
rm -rf data/wechat
```
The channel's messaging groups, wirings, and conversation history are **left
intact** — you created those at runtime (wiring + use), not this skill's install,
so removal doesn't touch them. To purge them deliberately, delete them yourself
with `ncl messaging-groups delete <id>`.
## 5. Remove DB wiring
## 5. Rebuild and restart
```sql
-- Remove any sessions first (foreign key)
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
```
## 6. Rebuild and restart
Run from your NanoClaw project root:
+1
View File
@@ -85,6 +85,7 @@ Add to `.env`:
WECHAT_ENABLED=true
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
+40 -73
View File
@@ -6,71 +6,62 @@ description: Add WhatsApp Business Cloud API channel via Chat SDK. Official Meta
# Add WhatsApp Cloud API Channel
Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API.
NanoClaw doesn't ship channels in trunk — this skill copies the WhatsApp Cloud
adapter in from the `channels` branch.
The mechanical steps under **Apply** carry `nc:` directive fences: an agent reads
the prose and applies them, and a parser can apply them deterministically from
the same document. Every directive is idempotent, so the whole skill is safe to
re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch.
### 1. Copy the adapter
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the WhatsApp Cloud adapter into
`src/channels/` (overwrite — the branch is canonical):
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/whatsapp-cloud.ts
src/channels/whatsapp-cloud-registration.test.ts
- `src/channels/whatsapp-cloud.ts` exists
- `src/channels/whatsapp-cloud-registration.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp-cloud.js';`
- `@chat-adapter/whatsapp` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and its registration test
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts
git show origin/channels:src/channels/whatsapp-cloud-registration.test.ts > src/channels/whatsapp-cloud-registration.test.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './whatsapp-cloud.js';
```
### 3. Install the adapter package
### 4. Install the adapter package (pinned)
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/whatsapp@4.29.0
```bash
pnpm install @chat-adapter/whatsapp@4.29.0
```
### 4. Build and validate
### 5. Build and validate
Build guards the typed `createChatSdkBridge(...)` core call and proves the
dependency is installed — the import throws at evaluation if `@chat-adapter/whatsapp`
is missing or the barrel drifts:
```nc:run effect:build
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
```
`whatsapp-cloud-registration.test.ts` imports the real channel barrel and asserts
the registry contains `whatsapp-cloud` — it goes red if the import line is deleted
or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't
installed (the import throws), so it also covers the dependency from step 3.
Both must be clean before proceeding. `whatsapp-cloud-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp-cloud`. It goes red if the `import './whatsapp-cloud.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/whatsapp` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real WhatsApp Business number is verified
manually once the service is running — see Next Steps and the webhook setup
below.
End-to-end message delivery against a real WhatsApp Business number is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
Meta app setup is human and interactive — these steps are prose, not directives
(no parser can click through the Meta dashboard). A recipe rebuild produces a
compiling, registered adapter that cannot receive a message until they're done.
1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business).
2. Add the **WhatsApp** product.
3. Go to **WhatsApp** > **API Setup**:
@@ -82,40 +73,18 @@ compiling, registered adapter that cannot receive a message until they're done.
- Subscribe to webhook fields: `messages`.
5. Copy the **App Secret** from **Settings** > **Basic**.
### Store the credentials
### Configure environment
Capture the four values, then write them. `prompt` only *asks* and binds the
answer to a name; a separate directive consumes it — so the same prompts could
feed `ncl` or the OneCLI vault instead of `.env` by swapping only the consumer.
Here they go to `.env` (set-if-absent — a value you've already filled in is
never overwritten):
Add to `.env`:
```nc:prompt access_token secret
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
```bash
WHATSAPP_ACCESS_TOKEN=your-system-user-access-token
WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id
WHATSAPP_APP_SECRET=your-app-secret
WHATSAPP_VERIFY_TOKEN=your-verify-token
```
```nc:prompt phone_number_id
Paste the Phone Number ID — WhatsApp > API Setup (not the phone number itself).
```
```nc:prompt app_secret secret
Paste the App Secret — Settings > Basic.
```
```nc:prompt verify_token secret
Paste the Verify Token — the random string you set under WhatsApp > Configuration.
```
```nc:env-set
WHATSAPP_ACCESS_TOKEN={{access_token}}
WHATSAPP_PHONE_NUMBER_ID={{phone_number_id}}
WHATSAPP_APP_SECRET={{app_secret}}
WHATSAPP_VERIFY_TOKEN={{verify_token}}
```
### Webhook server
The Chat SDK bridge automatically starts a shared webhook server on port 3000
(`WEBHOOK_PORT` to change it), handling `/webhook/whatsapp`. This port must be
publicly reachable for Meta to deliver events. Running locally, expose it with
ngrok (`ngrok http 3000`), a Cloudflare Tunnel, or a reverse proxy on a VPS —
the resulting public URL is the base for the webhook URL set under WhatsApp >
Configuration above.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
@@ -131,5 +100,3 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: no
- **typical-use**: Interactive 1:1 chat -- direct messages only
- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts.
</content>
</invoke>
+153 -199
View File
@@ -5,202 +5,216 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
# Add WhatsApp Channel
Adds WhatsApp support via the native Baileys adapter — a direct WhatsApp Web
connection, no Chat SDK bridge. NanoClaw doesn't ship channels in trunk — this
skill copies the WhatsApp adapter in from the `channels` branch.
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
The mechanical steps under **Apply** carry `nc:` directive fences: an agent
reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
### 1. Copy the adapter and its registration test
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the WhatsApp adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical). The
`whatsapp-auth` setup step is maintained in trunk, so it is not copied here:
Skip to **Credentials** if all of these are already in place:
```nc:copy from-branch:channels
src/channels/whatsapp.ts
src/channels/whatsapp-registration.test.ts
- `src/channels/whatsapp.ts` exists
- `src/channels/whatsapp-registration.test.ts` exists
- `src/channels/whatsapp.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp.js';`
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Register the adapter
### 2. Copy the adapter and setup steps
Append the self-registration import to the channel barrel (skipped if the line
is already present). This one line is the skill's only reach-in into core:
```bash
git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts
git show origin/channels:src/channels/whatsapp-registration.test.ts > src/channels/whatsapp-registration.test.ts
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
git show origin/channels:setup/groups.ts > setup/groups.ts
```
```nc:append to:src/channels/index.ts
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
import './whatsapp.js';
```
### 3. Install the adapter packages
### 4. Register the setup steps
Pinned to exact versions — the supply-chain policy rejects ranges and `latest`.
Baileys is the WhatsApp Web client; `qrcode` renders the device-link QR in the
terminal; `pino` is Baileys' logger:
In `setup/index.ts`, add these entries to the `STEPS` map (skip lines already present):
```nc:dep
@whiskeysockets/baileys@7.0.0-rc.9
qrcode@1.5.4
@types/qrcode@1.5.6
pino@9.6.0
```typescript
groups: () => import('./groups.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
```
### 4. Build and validate
### 5. Install the adapter packages (pinned)
Build first: it typechecks the adapter against core and proves the dependencies
are installed. Then run the one integration test.
```bash
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
```nc:run effect:build
### 6. Build and validate
```bash
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-registration.test.ts
```
`whatsapp-registration.test.ts` imports the real channel barrel and asserts the
registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line
is deleted or drifts, if the barrel fails to evaluate, or if
`@whiskeysockets/baileys` isn't installed (the import throws) — so it also covers
the dependency from step 3. End-to-end delivery against a real WhatsApp number is
verified manually once the service runs.
Both must be clean before proceeding. `whatsapp-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `whatsapp`. It goes red if the `import './whatsapp.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@whiskeysockets/baileys` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 5.
## Authenticate
End-to-end message delivery against a real WhatsApp number is verified manually once the service is running — see Credentials, Wiring, and Troubleshooting.
WhatsApp uses linked-device authentication — no API key, just a one-time pairing
from your phone. The adapter is installed and registered, but its factory returns
`null` (and the channel stays dark) until `store/auth/creds.json` exists.
## Credentials
Pick how to link the device. `qr` shows a rotating QR you scan with your phone's
camera; `pairing-code` shows an 8-character code you type into WhatsApp (no camera
needed, but it needs your phone number):
WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone.
```nc:prompt auth_method validate:^(qr|pairing-code)$
How do you want to link WhatsApp? Type `qr` to scan a QR code in this terminal, or `pairing-code` to enter a code on your phone (no camera needed).
### Check current state
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
```
The pairing-code method needs the number you're linking, the way WhatsApp expects
it — digits only, country code first, no `+`, spaces, or dashes (the QR method
skips this entirely):
### Detect environment
```nc:prompt phone validate:^\d{8,15}$ when:auth_method=pairing-code
Your WhatsApp phone number — digits only, country code first (e.g. 14155551234 for +1 415-555-1234).
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"
```
Point the user at the right screen before the code appears. For the QR method,
tell the user:
### Ask the user
```nc:operator when:auth_method=qr
Link WhatsApp by QR:
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device.
2. A QR code will appear in this terminal below and refresh every ~20 seconds. Point your phone's camera at it to scan.
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) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser
- **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? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.)
### Clean previous auth state (if re-authenticating)
```bash
rm -rf store/auth/
```
For the pairing-code method, tell the user:
### Run WhatsApp authentication
```nc:operator when:auth_method=pairing-code
Link WhatsApp by pairing code:
1. On your phone, open WhatsApp → Settings → Linked Devices → Link a Device → tap "Link with phone number instead".
2. An 8-character code will appear in this terminal below. Enter it on your phone immediately — it expires in about 60 seconds.
For QR code in browser (recommended):
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
```
Now run the linked-device handshake. It streams the live QR (or the pairing-code
card) to this terminal and, on success, reports the linked WhatsApp number. Run
the command for the method chosen above — `qr` or `pairing-code`:
(Bash timeout: 150000ms)
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=qr
The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`.
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
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
```
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=pairing-code
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone {{phone}}
(Bash timeout: 150000ms)
The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above.
Tell the user:
> 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 && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number> > /tmp/wa-auth.log 2>&1 &
```
If the handshake fails (`logged_out` or a timeout), the code expired — clear
`store/auth/` and run the step again for a fresh one. See Troubleshooting.
Then immediately poll for the code (do NOT wait for the background command to finish):
A successful link reports the number back as `bot_phone`. If it came back empty,
the device never confirmed (an expired QR or pairing code), so don't restart or
wire against a blank number — clear `store/auth/` and re-run the link step first:
```nc:run effect:check
[ -n "{{bot_phone}}" ]
```bash
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
```
## Restart
Display the code to the user the moment it appears. Tell them:
Restart the service so it loads the WhatsApp adapter and picks up the
credentials you just linked, and wait for its CLI socket before resolving:
> **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
```nc:run effect:restart
bash setup/lib/restart.sh
After the user enters the code, poll for authentication to complete:
```bash
for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
```
## Resolve your DM channel
**If failed:** logged_out → delete `store/auth/` and re-run. timeout → ask user, offer retry.
The agent talks to you in your WhatsApp chat. Tell the user which number that
chat happens on — usually the same one they just linked:
### Verify authentication succeeded
```nc:operator
You're linked to WhatsApp as +{{bot_phone}}.
- "shared" — you'll message the assistant from this same (personal) WhatsApp number. Replies land in your own "You" / self-chat.
- "dedicated" — the assistant has its own separate phone/SIM, and you'll message it from a different number.
```bash
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
```
Pick which it is. Most people use `shared`:
### Shared vs dedicated number
```nc:prompt number_kind validate:^(shared|dedicated)$
Is the assistant on a `shared` number (your personal WhatsApp) or a `dedicated` number (a separate line for the assistant)?
```
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
- **Dedicated number** — a separate phone/SIM for the assistant
For a dedicated number, collect the number you'll actually chat from (skipped
entirely for a shared number):
If dedicated, add to `.env`:
```nc:prompt chat_phone validate:^\d{8,15}$ when:number_kind=dedicated
The phone number you'll message the assistant from — digits only, country code first (e.g. 14155551234).
```
A dedicated number means the assistant owns its own line, so outbound replies
shouldn't be prefixed with its name. Record that (skipped for a shared number):
```nc:env-set when:number_kind=dedicated
```bash
ASSISTANT_HAS_OWN_NUMBER=true
```
Resolve the conversation address as the WhatsApp JID for the number you chat
from — the linked number for a shared account, or the dedicated number you just
gave. Run the one matching the choice above:
```nc:run capture:platform_id effect:fetch when:number_kind=shared
echo "{{bot_phone}}@s.whatsapp.net"
```
```nc:run capture:platform_id effect:fetch when:number_kind=dedicated
echo "{{chat_phone}}@s.whatsapp.net"
```
For WhatsApp, your owner handle is that same JID:
```nc:run capture:owner_handle effect:fetch
echo "{{platform_id}}"
```
`owner_handle` and `platform_id` are what the owner-wiring step needs. The
greeting goes out over your WhatsApp chat as soon as the service reconnects with
the linked credentials.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire
this channel with `/init-first-agent` (or `/manage-channels`).
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `whatsapp`
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
- **platform-id-format**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. Native adapterthe JID is the platform ID as-is, no `whatsapp:` prefix.
- **how-to-find-id**: To find your linked number after auth: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0].split('@')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discoveredcheck `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **supports-threads**: no
- **typical-use**: Interactive chat — direct messages or small groups
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
@@ -214,74 +228,18 @@ this channel with `/init-first-agent` (or `/manage-channels`).
- Typing indicators — composing presence updates
- Credential requests — text fallback (WhatsApp has no modal support)
Not supported (WhatsApp linked-device limitation): edit messages, delete messages.
## Alternatives
### QR code in a browser
Besides the in-terminal QR and the pairing code the Apply flow uses, this skill
ships a helper that renders the rotating QR as a PNG in your default browser —
handy when the terminal QR is too small to scan reliably. It spawns the same
`whatsapp-auth` step, parses each rotating QR from its `WHATSAPP_AUTH_QR` status
blocks, and serves the current one on a local HTTP server (default port `8765`,
falls back to a free port):
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
```
Flags: `--clean` wipes `store/auth/` before spawning, `--port N` pins the port.
A browser window opens with a QR code. On your phone, open WhatsApp →
**Settings** → **Linked Devices** → **Link a Device**, scan the QR, and the page
shows "Authenticated!" when done.
### Headless environments
On a headless host (no display server — no `$DISPLAY`/`$WAYLAND_DISPLAY`, not
macOS), the browser method can't open a window. Detect it and fall back to the
pairing-code method (no camera needed):
```bash
[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false"
```
## Optional configuration
If the assistant runs on a dedicated number (its own phone/SIM, not your personal
WhatsApp), tell the adapter so it doesn't prefix outbound replies with its name:
```bash
ASSISTANT_HAS_OWN_NUMBER=true
```
The Apply flow sets this for you when you pick a `dedicated` number; this is the
key it writes, for reference. A shared (personal) number leaves it unset.
Not supported (WhatsApp linked device limitation): edit messages, delete messages.
## Troubleshooting
### QR code or pairing code expired
### QR code expired
Codes expire after ~60 seconds. The QR rotates automatically while the auth step
is running; if the step exited, clear the auth state and re-run it:
QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`:
```bash
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
```
For pairing code, ensure digits only (no `+`), the phone has internet, and
WhatsApp is updated:
```bash
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <phone>
```
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
device." This is a server-side rejection unrelated to the code itself. If you hit
it more than once, switch to the QR method — it has a noticeably higher success
rate.
### Pairing code not working
Codes expire in ~60 seconds. Delete auth and retry:
@@ -292,11 +250,7 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
Ensure: digits only (no `+`), phone has internet, WhatsApp is updated.
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link
device — An error happened. Please try again." This is a server-side rejection
unrelated to the code itself; we've seen it happen twice in a row on fresh
dedicated numbers. If you hit it more than once, switch to QR-browser auth — it
has a noticeably higher success rate:
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate:
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
@@ -304,8 +258,9 @@ pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
### "waiting for this message" on reactions
WhatsApp sessions corrupted from rapid restarts. Clear sessions, then restart the
service. Run from your NanoClaw project root:
Signal sessions corrupted from rapid restarts. Clear sessions.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
@@ -323,5 +278,4 @@ systemctl --user start $(systemd_unit)
### "conflict" disconnection
Two instances connected with the same credentials. Ensure only one NanoClaw
process is running.
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
+19
View File
@@ -14,6 +14,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write # createLabel — auto-provisions the core-team label
steps:
- uses: actions/github-script@v7
with:
@@ -30,6 +31,24 @@ jobs:
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
// Lowercase GitHub logins; keep in sync with the core team roster.
const CORE_TEAM = ['gavrielc', 'koshkoshinsk', 'glifocat', 'gabi-simons', 'omri-maya', 'amit-shafnir', 'moshe-nanoco'];
const author = context.payload.pull_request.user.login.toLowerCase();
if (CORE_TEAM.includes(author)) {
labels.push('core-team');
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'core-team',
color: '1D76DB',
description: 'PR opened by a core team member',
});
} catch (e) {
if (e.status !== 422) throw e; // 422: label already exists
}
}
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
+2
View File
@@ -4,6 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
+12 -11
View File
@@ -15,11 +15,11 @@ If you are a fresh install (you ran `git clone`, not `git pull`) and there are n
# NanoClaw
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
Personal AI assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
## Quick Context
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls the agent, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
@@ -32,7 +32,7 @@ agent_group_members (user_id, agent_group_id) — unprivileged access gate
user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
↕ many-to-many via messaging_group_agents (session_mode, engage_mode/engage_pattern, sender_scope, priority)
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
@@ -44,8 +44,8 @@ Privilege is user-level (owner/admin), not agent-group-level. See [docs/isolatio
Each session has **two** SQLite files under `data/v2-sessions/<session_id>/`:
- `inbound.db` — host writes, container reads. `messages_in`, routing, destinations, pending_questions, processing_ack.
- `outbound.db` — container writes, host reads. `messages_out`, session_state.
- `inbound.db` — host writes, container reads. `messages_in`, delivered, destinations, session_routing.
- `outbound.db` — container writes, host reads. `messages_out`, processing_ack, session_state, container_state.
Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd.
@@ -65,7 +65,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence |
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup |
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
@@ -105,6 +105,7 @@ ncl help
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| sessions | list, get | Active sessions (read-only) |
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
@@ -115,7 +116,7 @@ Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud, Signal, WeChat, DeltaChat, Emacs (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
- **`providers` branch** — OpenCode (and any future non-default agent providers). Installed via `/add-opencode`.
Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install <pkg>@<pinned-version>` → build.
@@ -137,7 +138,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
@@ -170,7 +171,7 @@ No container restart needed — the gateway looks up secrets per request.
Approval-gating credentialed actions is a **two-sided** flow:
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@2.2.5`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only.
If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired.
@@ -216,7 +217,7 @@ Run commands directly — don't tell the user to run them.
```bash
# Host (Node + pnpm)
pnpm run dev # Host with hot reload
pnpm run dev # Host via tsx (no watch)
pnpm run build # Compile host TypeScript (src/)
./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest)
pnpm test # Host tests (vitest)
@@ -297,7 +298,7 @@ The agent container runs on **Bun**; the host runs on **Node** (pnpm). They comm
- **Writing a new named-param SQL insert/update in the container** → use `$name` in both SQL and JS keys: `.run({ $id: msg.id })`. `bun:sqlite` does not auto-strip the prefix the way `better-sqlite3` does on the host. Positional `?` params work normally.
- **Adding a test in `container/agent-runner/src/`** → import from `bun:test`, not `vitest`. Vitest runs on Node and can't load `bun:sqlite`. `vitest.config.ts` excludes this tree.
- **Adding a Node CLI the agent invokes at runtime** (like `agent-browser`, `claude-code`, `vercel`) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a new `ARG`. Don't use `bun install -g` — that bypasses the pnpm supply-chain policy.
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~301) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~503) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
- **Changing session-DB pragmas** (`container/agent-runner/src/db/connection.ts`) → `journal_mode=DELETE` is load-bearing for cross-mount visibility. Read the comment block at the top of the file first.
## CJK font support
+4 -4
View File
@@ -75,7 +75,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
#### 3. Operational skills (instruction-only)
Workflows and guides with no code changes. The SKILL.md is the entire skill — Claude follows the instructions to perform a task.
Workflows and guides with no code changes. The SKILL.md is the entire skill — the coding agent follows the instructions to perform a task.
**Location:** `.claude/skills/` on `main`
@@ -88,13 +88,13 @@ Workflows and guides with no code changes. The SKILL.md is the entire skill —
#### 4. Container skills (agent runtime)
Skills that run inside the agent container, not on the host. These teach the container agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
Skills that run inside the agent container, not on the host. These teach the NanoClaw agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
**Location:** `container/skills/<name>/`
**Examples:** `agent-browser` (web browsing), `capabilities` (/capabilities command), `status` (/status command), `slack-formatting` (Slack mrkdwn syntax)
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `slack-formatting` (Slack mrkdwn syntax), `vercel-cli`, `welcome`, `whatsapp-formatting`
**Key difference:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves.
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
**Guidelines:**
- Follow the same SKILL.md + frontmatter format
+8 -11
View File
@@ -22,7 +22,7 @@
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Claude agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
## Quick Start
@@ -49,7 +49,7 @@ bash migrate-v2.sh
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including the Baileys keystore for WhatsApp — LID mapping is now resolved per-message by the Baileys v7 adapter, not migrated), builds the agent container.
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
@@ -78,11 +78,11 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
- **Scheduled tasks** — recurring jobs executed by the agent, which can message you the results
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
- **Container isolation** — agents are sandboxed in Docker containers (macOS/Linux/WSL2)
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle via `ncl groups create --template <ref>`. Templates load from the local `templates/` folder; populate it by hand or by copying from the [public library](https://github.com/nanocoai/nanoclaw-templates). See [docs/templates.md](docs/templates.md).
## Usage
@@ -124,10 +124,7 @@ This keeps trunk as pure registry and infra, and every fork stays lean — users
### RFS (Request for Skills)
Skills we'd like to see:
**Communication Channels**
- `/add-signal` — Add Signal as a channel
No channel or provider skills are currently requested — propose one via an issue.
## Requirements
@@ -142,7 +139,7 @@ Skills we'd like to see:
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
```
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs the agent, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.
@@ -165,7 +162,7 @@ Key files:
**Why Docker?**
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem.
**Can I run this on Linux or Windows?**
+6 -1
View File
@@ -22,7 +22,9 @@ type RequestFrame = {
};
type ResponseFrame =
| { id: string; ok: true; data: unknown }
// `human` mirrors src/cli/frame.ts: an optional server-rendered string we
// print verbatim instead of running our own (drift-prone) formatter.
| { id: string; ok: true; data: unknown; human?: string }
| { id: string; ok: false; error: { code: string; message: string } };
// ---------------------------------------------------------------------------
@@ -244,6 +246,9 @@ if (!resp) {
if (json) {
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
} else if (resp.ok && resp.human !== undefined) {
// Server-rendered view — print verbatim.
process.stdout.write(resp.human + '\n');
} else {
const output = formatHuman(resp);
if (!resp.ok) {
@@ -120,6 +120,24 @@ export function markCompleted(ids: string[]): void {
})();
}
/**
* Ack task messages whose pre-task script gated the run. The reason decides
* the ack: `gated` (wakeAgent=false) is the monitor working as designed → a
* plain `completed`; `error` (broken script) → `script-skip:error`, which the
* host's ack sync records as a FAILED run so recurrence can read the trailing
* failed streak off the occurrence rows and back the series off.
*/
export function markScriptSkipped(skips: Array<{ id: string; reason: string }>): void {
if (skips.length === 0) return;
const db = getOutboundDb();
const stmt = db.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))",
);
db.transaction(() => {
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed');
})();
}
/** Mark a single message as failed — writes to processing_ack in outbound.db. */
export function markFailed(id: string): void {
getOutboundDb()
@@ -141,3 +141,22 @@ export function getUndeliveredMessages(): MessageOutRow[] {
)
.all() as MessageOutRow[];
}
/**
* True if a deliberate send with this exact destination + text already exists
* (an MCP send_message row from the current turn). Used by the task-fire
* final-text dispatcher to drop the turn-final <message> echo of a send the
* agent already made — the dedup happens where the duplication originates.
*/
export function hasIdenticalSend(platformId: string, channelType: string, text: string): boolean {
const row = getOutboundDb()
.prepare(
`SELECT 1 FROM messages_out
WHERE platform_id = $platform_id AND channel_type = $channel_type
AND (in_reply_to IS NULL OR in_reply_to = '')
AND json_extract(content, '$.text') = $text
LIMIT 1`,
)
.get({ $platform_id: platformId, $channel_type: channelType, $text: text });
return row != null;
}
+9 -4
View File
@@ -105,13 +105,11 @@ function buildDestinationsSection(): string {
const lines = ['## Sending messages', ''];
if (all.length === 1) {
const d = all[0];
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`Your destination is \`${d.name}\`${label}.`);
lines.push(`Your destination is \`${d.name}\`${destinationLabel(d)}.`);
} else {
lines.push('You can send messages to the following destinations:', '');
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
lines.push(`- \`${d.name}\`${destinationLabel(d)}`);
}
}
lines.push('');
@@ -128,3 +126,10 @@ function buildDestinationsSection(): string {
);
return lines.join('\n');
}
function destinationLabel(d: DestinationEntry): string {
const parts: string[] = [];
if (d.channelType) parts.push(d.channelType);
if (d.displayName && d.displayName !== d.name) parts.push(d.displayName);
return parts.length > 0 ? ` (${parts.join(' · ')})` : '';
}
+6
View File
@@ -98,6 +98,11 @@ export interface RoutingContext {
channelType: string | null;
threadId: string | null;
inReplyTo: string | null;
/** Batch is a task fire — explicit `<message to>` sends must NOT inherit
* inReplyTo (the series id), or the host's task-fire suppression drops
* them as turn-final echoes: zero delivery. Deliberate sends are
* in_reply_to-null, same as the out-of-process MCP send_message path. */
taskFire: boolean;
}
/**
@@ -111,6 +116,7 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
channelType: first?.channel_type ?? null,
threadId: first?.thread_id ?? null,
inReplyTo: first?.id ?? null,
taskFire: messages.length > 0 && messages.every((m) => m.kind === 'task'),
};
}
@@ -18,12 +18,13 @@ Your CLI access may be scoped. Run `ncl help` to see which resources are availab
Run `ncl help` for the full list. Common resources:
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
| Resource | Verbs | What it is |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
| tasks | list, get, create, update, cancel, pause, resume, delete, append-log | Scheduled tasks for your agent group |
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
@@ -33,11 +34,12 @@ Additional resources (available under `global` scope only): messaging-groups, wi
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
- **Checking who's in your group** — `ncl members list`.
- **Seeing your destinations** — `ncl destinations list`.
- **Scheduling work** — `ncl tasks create`, then `ncl tasks list/get/update/cancel/pause/resume/delete`; `ncl tasks run <id>` fires one extra run now (testing) without changing the schedule. At the end of each task run, `ncl tasks append-log --msg "…"` to record what happened (host-timestamped, not a message).
- **Answering questions about the system** — query `ncl` rather than guessing.
### Access rules
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
Read commands (list, get) are open. Most write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. `ncl tasks` is the exception: an agent can manage its own group tasks without approval.
### Approval flow
@@ -61,6 +63,13 @@ ncl groups config get
ncl sessions list
ncl destinations list
ncl members list
ncl tasks list
# Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid).
# For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots.
ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *"
# At the END of every task run, record one line of history. The host stamps the UTC time — you supply only the summary.
# This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task fire --id is auto-derived from your session.
ncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"
# Write commands (approval required)
ncl groups restart
@@ -6,7 +6,6 @@
* at module scope, and append the import here. No central list.
*/
import './core.js';
import './scheduling.js';
import './interactive.js';
import './agents.js';
import './self-mod.js';
@@ -1,40 +1,25 @@
## Task scheduling (`schedule_task`)
## Task scheduling (`ncl tasks`)
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below.
Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session, so when it fires you must choose a destination explicitly with `<message to="name">...</message>` or `send_message({ to: "name", ... })`.
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule.
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"` `sales-briefing-a25c`); without it ids are `t-<hex>`.
Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes.
### How it works
1. Provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first
3. Script returns: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — claude receives the script's data + prompt and handles
### Always test your script first
Before scheduling, run the script directly to verify it works:
Common commands:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
ncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"
ncl tasks list
ncl tasks get ping-a25c # includes run count, failures, and recent run-log lines
ncl tasks run ping-a25c # fire once now without changing the schedule (testing)
ncl tasks update ping-a25c --prompt "New instructions"
ncl tasks pause ping-a25c
ncl tasks resume ping-a25c
ncl tasks cancel ping-a25c # or --all as a kill switch
ncl tasks delete ping-a25c
```
### When NOT to use scripts
Use good judgement on whether it's appropriate to check in with the user about the task prompt before task creation, and if so, whether to share verbatim or a description of it.
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts.
`--process-after` accepts UTC timestamps or naive local timestamps interpreted in the instance timezone (shown in the `<context timezone="..."/>` header).
### Frequent task guidance
If a user wants a task to run more than a few times a day and a script can't be used:
- Explain that each time the task fires it uses API credits and risks rate limits
- Suggest adjusting the task requirements in a way that will allow you to use a script
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency
Run `ncl tasks create --help` for schedules, options, and pre-task gate scripts (checks that run before you wake).
@@ -1,302 +0,0 @@
/**
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
*
* With the two-DB split, the container cannot write to inbound.db (host-owned).
* Scheduling operations are sent as system actions via messages_out — the host
* reads them during delivery and applies the changes to inbound.db.
*/
import { getInboundDb } from '../db/connection.js';
import { writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function routing() {
return getSessionRouting();
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
export const scheduleTask: McpToolDefinition = {
tool: {
name: 'schedule_task',
description:
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
inputSchema: {
type: 'object' as const,
properties: {
prompt: { type: 'string', description: 'Task instructions/prompt' },
processAfter: {
type: 'string',
description:
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
},
recurrence: {
type: 'string',
description:
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
},
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
},
required: ['prompt', 'processAfter'],
},
},
async handler(args) {
const prompt = args.prompt as string;
const processAfterIn = args.processAfter as string;
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
let processAfter: string;
try {
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
processAfter = d.toISOString();
} catch {
return err(`invalid processAfter: ${processAfterIn}`);
}
const id = generateId();
const r = routing();
const recurrence = (args.recurrence as string) || null;
const script = (args.script as string) || null;
// Write as a system action — host will insert into inbound.db
writeMessageOut({
id,
kind: 'system',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({
action: 'schedule_task',
taskId: id,
prompt,
script,
processAfter,
recurrence,
platformId: r.platform_id,
channelType: r.channel_type,
threadId: r.thread_id,
}),
});
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
},
};
export const listTasks: McpToolDefinition = {
tool: {
name: 'list_tasks',
description:
'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.',
inputSchema: {
type: 'object' as const,
properties: {
status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' },
},
},
},
async handler(args) {
const status = args.status as string | undefined;
const db = getInboundDb();
// One row per series — the live (pending or paused) occurrence. Recurring
// tasks accumulate one completed row per firing plus one live follow-up;
// exposing the whole pile to the agent is noisy and confuses task identity
// ("which id do I cancel?"). The series_id is the stable handle.
//
// SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY
// query, the bare columns take values from the row that contains that max
// — that's how we pick "the latest live row per series" in one pass.
let rows;
if (status) {
rows = db
.prepare(
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
FROM messages_in
WHERE kind = 'task' AND status = ?
GROUP BY series_id
ORDER BY process_after ASC`,
)
.all(status);
} else {
rows = db
.prepare(
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
FROM messages_in
WHERE kind = 'task' AND status IN ('pending', 'paused')
GROUP BY series_id
ORDER BY process_after ASC`,
)
.all();
}
if ((rows as unknown[]).length === 0) return ok('No tasks found.');
const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => {
const content = JSON.parse(r.content);
const prompt = (content.prompt as string || '').slice(0, 80);
return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}${prompt}`;
});
return ok(lines.join('\n'));
},
};
export const cancelTask: McpToolDefinition = {
tool: {
name: 'cancel_task',
description: 'Cancel a scheduled task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to cancel' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
// Write as a system action — host will update inbound.db
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'cancel_task', taskId }),
});
log(`cancel_task: ${taskId}`);
return ok(`Task cancellation requested: ${taskId}`);
},
};
export const pauseTask: McpToolDefinition = {
tool: {
name: 'pause_task',
description: 'Pause a scheduled task. It will not run until resumed.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to pause' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'pause_task', taskId }),
});
log(`pause_task: ${taskId}`);
return ok(`Task pause requested: ${taskId}`);
},
};
export const resumeTask: McpToolDefinition = {
tool: {
name: 'resume_task',
description: 'Resume a paused task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to resume' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'resume_task', taskId }),
});
log(`resume_task: ${taskId}`);
return ok(`Task resume requested: ${taskId}`);
},
};
export const updateTask: McpToolDefinition = {
tool: {
name: 'update_task',
description:
'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' },
prompt: { type: 'string', description: 'New task prompt (optional)' },
recurrence: {
type: 'string',
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
},
processAfter: {
type: 'string',
description:
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
},
script: {
type: 'string',
description: 'New pre-agent script (optional). Pass empty string to clear.',
},
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const update: Record<string, unknown> = { taskId };
if (typeof args.prompt === 'string') update.prompt = args.prompt;
if (typeof args.processAfter === 'string') {
try {
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
update.processAfter = d.toISOString();
} catch {
return err(`invalid processAfter: ${args.processAfter}`);
}
}
// Empty string clears recurrence/script; undefined leaves them as-is.
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
if (Object.keys(update).length === 1) return err('at least one field to update is required');
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'update_task', ...update }),
});
log(`update_task: ${taskId}`);
return ok(`Task update requested: ${taskId}`);
},
};
registerTools([scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask]);
+19 -10
View File
@@ -1,6 +1,6 @@
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { getPendingMessages, markProcessing, markCompleted, markScriptSkipped, type MessageInRow } from './db/messages-in.js';
import { hasIdenticalSend, writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import {
clearContinuation,
@@ -207,15 +207,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Without the scheduling module, the marker block is empty, `keep`
// falls back to `normalMessages`, and no gating happens.
let keep: MessageInRow[] = normalMessages;
let skipped: string[] = [];
let skipped: Array<{ id: string; reason: string }> = [];
// MODULE-HOOK:scheduling-pre-task:start
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
const preTask = await applyPreTaskScripts(normalMessages);
keep = preTask.keep;
skipped = preTask.skipped;
if (skipped.length > 0) {
markCompleted(skipped);
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
markScriptSkipped(skipped);
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.map((s) => s.id).join(', ')}`);
}
// MODULE-HOOK:scheduling-pre-task:end
@@ -238,7 +238,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
});
// Process the query while concurrently polling for new messages
const skippedSet = new Set(skipped);
const skippedSet = new Set(skipped.map((s) => s.id));
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
// can stamp it on outbound rows — needed for a2a return-path routing.
@@ -401,15 +401,15 @@ export async function processQuery(
// its script gate and always wakes the agent, defeating the gate.
// Mirrors the initial-batch hook above.
let keep = newMessages;
let skipped: string[] = [];
let skipped: Array<{ id: string; reason: string }> = [];
// MODULE-HOOK:scheduling-pre-task-followup:start
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
const preTask = await applyPreTaskScripts(newMessages);
keep = preTask.keep;
skipped = preTask.skipped;
if (skipped.length > 0) {
markCompleted(skipped);
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
markScriptSkipped(skipped);
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.map((s) => s.id).join(', ')}`);
}
// MODULE-HOOK:scheduling-pre-task-followup:end
@@ -650,6 +650,15 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
// Task fires: an explicitly-addressed final-text block is either the echo of
// an MCP send the agent already made this turn (drop it HERE, where the
// duplication originates) or the agent's only deliberate send (write it
// in_reply_to-null like the MCP path, or the host's task-fire suppression
// would discard it — zero delivery).
if (routing.taskFire && hasIdenticalSend(platformId, channelType, body)) {
log(`Dropping turn-final echo of an already-sent task message to ${dest.name}`);
return;
}
// Resolve thread_id per-destination from the most recent inbound message
// that came from this same channel+platform. In agent-shared sessions,
// different destinations have different thread contexts — using a single
@@ -657,7 +666,7 @@ function sendToDestination(dest: DestinationEntry, body: string, routing: Routin
const destRouting = resolveDestinationThread(channelType, platformId);
writeMessageOut({
id: generateId(),
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
in_reply_to: destRouting?.inReplyTo ?? (routing.taskFire ? null : routing.inReplyTo),
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
@@ -17,7 +17,7 @@ function log(msg: string): void {
// Code's interactive UI and would hang here).
//
// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable
// scheduling via mcp__nanoclaw__schedule_task.
// scheduling via `ncl tasks`.
// - AskUserQuestion: SDK returns a placeholder instead of blocking on a
// real answer — we have mcp__nanoclaw__ask_user_question that persists
// the question and blocks on the real reply.
@@ -449,7 +449,7 @@ export class ClaudeProvider implements AgentProvider {
yield { type: 'result', text, isError: m.is_error === true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
yield { type: 'error', message: 'API retry', retryable: true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
} else if (message.type === 'rate_limit_event') {
yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
@@ -0,0 +1,72 @@
/**
* Container leg of the script-failure backoff chain, tested at unit level so
* the e2e suite doesn't need a live multi-sweep scenario for it:
*
* script error → applyPreTaskScripts skips with reason 'error'
* → markScriptSkipped acks `script-skip:error` in outbound.db
* (gated → plain 'completed': the monitor working as designed).
*
* The host leg (ack → FAILED run → streak backoff) is pinned in
* src/db/session-db.test.ts and src/modules/scheduling/recurrence.test.ts —
* both sides pin the literal 'script-skip:error'; if either renames it, its
* own test goes red.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
import { getPendingMessages, markScriptSkipped } from '../db/messages-in.js';
import { applyPreTaskScripts } from './task-script.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function insertTask(id: string, script: string) {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, trigger, content)
VALUES (?, 'task', datetime('now'), 'pending', 1, ?)`,
)
.run(id, JSON.stringify({ prompt: 'monitor', script }));
}
const ackStatus = (id: string): string | undefined =>
(getOutboundDb().prepare('SELECT status FROM processing_ack WHERE message_id = ?').get(id) as { status: string } | undefined)
?.status;
describe('script-skip ack chain (container leg)', () => {
it('an erroring script skips with reason "error" and acks script-skip:error', async () => {
insertTask('t-err', 'echo boom >&2; exit 1');
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
expect(keep).toHaveLength(0);
expect(skipped).toEqual([{ id: 't-err', reason: 'error' }]);
markScriptSkipped(skipped);
expect(ackStatus('t-err')).toBe('script-skip:error');
});
it('a deliberate wakeAgent=false gate acks plain completed — never backs off', async () => {
insertTask('t-gated', 'echo \'{"wakeAgent": false}\'');
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
expect(keep).toHaveLength(0);
expect(skipped).toEqual([{ id: 't-gated', reason: 'gated' }]);
markScriptSkipped(skipped);
expect(ackStatus('t-gated')).toBe('completed');
});
it('wakeAgent=true keeps the task and enriches the prompt with script data', async () => {
insertTask('t-wake', 'echo \'{"wakeAgent": true, "data": {"alerts": 2}}\'');
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
expect(skipped).toHaveLength(0);
expect(keep).toHaveLength(1);
expect(JSON.parse(keep[0].content).scriptOutput).toEqual({ alerts: 2 });
});
});
@@ -64,21 +64,26 @@ export async function runScript(script: string, taskId: string): Promise<ScriptR
});
}
/** Why a script gated its task: deliberate wakeAgent=false vs a broken script. */
export type ScriptSkipReason = 'gated' | 'error';
export interface TaskScriptOutcome {
keep: MessageInRow[];
skipped: string[];
skipped: Array<{ id: string; reason: ScriptSkipReason }>;
}
/**
* Run pre-task scripts for any task messages that carry one, serially.
* - Errors / missing output / wakeAgent=false → task id added to `skipped`.
* - Errors / missing output / wakeAgent=false → task id added to `skipped`,
* with the reason. The caller acks these as script-skips (not plain
* completions) so the host can count consecutive failures and back off.
* - wakeAgent=true → content JSON is mutated to carry `scriptOutput`, so the
* formatter renders it into the prompt.
* Non-task messages and tasks without scripts pass through unchanged.
*/
export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<TaskScriptOutcome> {
const keep: MessageInRow[] = [];
const skipped: string[] = [];
const skipped: Array<{ id: string; reason: ScriptSkipReason }> = [];
for (const msg of messages) {
if (msg.kind !== 'task') {
@@ -106,9 +111,9 @@ export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<Tas
touchHeartbeat();
if (!result || !result.wakeAgent) {
const reason = result ? 'wakeAgent=false' : 'script error/no output';
log(`task ${msg.id} skipped: ${reason}`);
skipped.push(msg.id);
const reason: ScriptSkipReason = result ? 'gated' : 'error';
log(`task ${msg.id} skipped: ${reason === 'gated' ? 'wakeAgent=false' : 'script error/no output'}`);
skipped.push({ id: msg.id, reason });
continue;
}
+12 -12
View File
@@ -20,7 +20,7 @@ The entire codebase should be something you can read and understand. One Node.js
### Security Through True Isolation
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
### Built for the Individual User
@@ -47,23 +47,23 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp.
Skills we'd like to see contributed:
### Communication Channels
- `/add-signal` - Add Signal as a channel
- `/add-matrix` - Add Matrix integration
> **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
None currently — Signal and Matrix have since shipped as skills.
> **Note:** Telegram, Slack, Discord, Gmail, Signal, and Matrix skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
---
## Vision
A personal Claude assistant accessible via messaging, with minimal custom code.
A personal AI assistant accessible via messaging, with minimal custom code.
**Core components:**
- **Claude Agent SDK** as the core agent
- **Containers** for isolated agent execution (Linux VMs)
- **Containers** for isolated agent execution (Docker)
- **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need
- **Persistent memory** per conversation and globally
- **Scheduled tasks** that run Claude and can message back
- **Scheduled tasks** executed by the agent, which can message back
- **Web access** for search and browsing
- **Browser automation** via agent-browser
@@ -93,14 +93,14 @@ A personal Claude assistant accessible via messaging, with minimal custom code.
- Sessions auto-compact when context gets too long, preserving critical information
### Container Isolation
- All agents run inside containers (lightweight Linux VMs)
- All agents run inside Docker containers
- Each agent invocation spawns a container with mounted directories
- Containers provide filesystem isolation - agents can only see mounted paths
- Bash access is safe because commands run inside the container, not on the host
- Browser automation via agent-browser with Chromium in the container
### Scheduled Tasks
- Users can ask Claude to schedule recurring or one-time tasks from any group
- Users can ask the agent to schedule recurring or one-time tasks from any group
- Tasks run as full agents in the context of the group that created them
- Tasks have access to all tools including Bash (safe in container)
- Tasks can optionally send messages to their group via `send_message` tool, or complete silently
@@ -134,11 +134,11 @@ A personal Claude assistant accessible via messaging, with minimal custom code.
### Scheduler
- Built-in scheduler runs on the host, spawns containers for task execution
- Custom `nanoclaw` MCP server (inside container) provides scheduling tools
- Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message`
- `ncl tasks` provides scheduling commands
- Commands: `list`, `get`, `create`, `update`, `cancel`, `pause`, `resume`, `delete`, `run`, `append-log`
- Tasks stored in SQLite with run history
- Scheduler loop checks for due tasks every minute
- Tasks execute Claude Agent SDK in containerized group context
- Tasks execute in the agent group's system session
### Web Access
- Built-in WebSearch and WebFetch tools
+471 -434
View File
File diff suppressed because it is too large Load Diff
+3 -20
View File
@@ -579,12 +579,7 @@ NanoClaw has a built-in scheduler that runs tasks as full agents in their group'
```
User: @Andy remind me every Monday at 9am to review the weekly metrics
Claude: [calls mcp__nanoclaw__schedule_task]
{
"prompt": "Send a reminder to review weekly metrics. Be encouraging!",
"schedule_type": "cron",
"schedule_value": "0 9 * * 1"
}
Claude: [runs ncl tasks create --prompt "Send a reminder to review weekly metrics. Be encouraging!" --process-after "2024-02-05T09:00:00" --recurrence "0 9 * * 1"]
Claude: Done! I'll remind you every Monday at 9am.
```
@@ -594,12 +589,7 @@ Claude: Done! I'll remind you every Monday at 9am.
```
User: @Andy at 5pm today, send me a summary of today's emails
Claude: [calls mcp__nanoclaw__schedule_task]
{
"prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.",
"schedule_type": "once",
"schedule_value": "2024-01-31T17:00:00Z"
}
Claude: [runs ncl tasks create --prompt "Search for today's emails, summarize the important ones, and send the summary to the group." --process-after "2024-01-31T17:00:00"]
```
### Managing Tasks
@@ -620,18 +610,11 @@ From main channel:
### NanoClaw MCP (built-in)
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context.
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. Scheduled task management lives in `ncl tasks`, not MCP.
**Available Tools:**
| Tool | Purpose |
|------|---------|
| `schedule_task` | Schedule a recurring or one-time task |
| `list_tasks` | Show tasks (group's tasks, or all if main) |
| `get_task` | Get task details and run history |
| `update_task` | Modify task prompt or schedule |
| `pause_task` | Pause a task |
| `resume_task` | Resume a paused task |
| `cancel_task` | Delete a task |
| `send_message` | Send a message to the group via its channel |
---
+251 -206
View File
@@ -14,37 +14,62 @@ The boundary: the agent-runner decides **what** to send and **what to do** with
## AgentProvider Interface
Provider-wide settings (MCP servers, env, additional directories, model, effort,
assistant name) are passed to the provider **constructor** via `ProviderOptions`, not
per query. `QueryInput` carries only what changes turn to turn: the prompt, the
continuation token to resume, the working directory, and system context to inject.
```typescript
interface AgentProvider {
/** True if the SDK handles slash commands natively and wants them passed
* through raw. When false, the poll-loop formats them like any chat message. */
readonly supportsNativeSlashCommands: boolean;
/** Opt-in: scaffold a persistent memory/ tree at boot. Providers with native
* memory (Claude's CLAUDE.local.md) omit it. Never gated on a provider name. */
readonly usesMemoryScaffold?: boolean;
/** Optional. Called after each completed exchange so providers whose harness
* keeps no on-disk transcript can persist it themselves. Claude (the SDK
* writes its own .jsonl) omits this. */
onExchangeComplete?(exchange: ProviderExchange): void;
/** Start a new query. Returns a handle for streaming input and output. */
query(input: QueryInput): AgentQuery;
/** True if the error means the stored continuation is invalid (missing
* transcript, unknown session) and should be cleared. */
isSessionInvalid(err: unknown): boolean;
/** Optional pre-resume maintenance: given the stored continuation, return a
* reason string to drop it and start fresh (e.g. transcript too large/old to
* cold-resume before the host idle ceiling), or null to keep resuming. */
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
}
interface ProviderOptions {
assistantName?: string;
mcpServers?: Record<string, McpServerConfig>;
env?: Record<string, string | undefined>;
additionalDirectories?: string[];
model?: string; // alias (sonnet/opus/haiku) or full model ID
effort?: string; // low | medium | high | xhigh | max
}
interface QueryInput {
/** Initial prompt (already formatted by agent-runner).
* String for text-only. ContentBlock[] for multimodal (images, PDFs, audio). */
prompt: string | ContentBlock[];
/** Initial prompt, already formatted by the agent-runner into a string. */
prompt: string;
/** Session ID to resume, if any */
sessionId?: string;
/** Opaque continuation token from a previous query. The provider decides
* what it means (session ID, thread ID, or nothing). */
continuation?: string;
/** Resume from a specific point in the session (provider-specific, may be ignored) */
resumeAt?: string;
/** Working directory inside the container */
/** Working directory inside the container. */
cwd: string;
/** MCP server configurations (normalized format — provider translates) */
mcpServers: Record<string, McpServerConfig>;
/** System prompt / developer instructions */
systemPrompt?: string;
/** Environment variables for the SDK process */
env: Record<string, string | undefined>;
/** Additional directories the agent can access */
additionalDirectories?: string[];
/** System context to inject; the provider translates it into whatever its
* SDK expects (preset append, full system prompt, per-turn injection). */
systemContext?: { instructions?: string };
}
interface McpServerConfig {
@@ -54,40 +79,42 @@ interface McpServerConfig {
}
interface AgentQuery {
/** Push a follow-up message into the active query */
/** Push a follow-up message into the active query. */
push(message: string): void;
/** Signal that no more input will be sent */
/** Signal that no more input will be sent. */
end(): void;
/** Output event stream */
/** Output event stream. */
events: AsyncIterable<ProviderEvent>;
/** Force-stop the query (e.g., container shutting down) */
/** Force-stop the query (e.g., container shutting down). */
abort(): void;
}
type ProviderEvent =
| { type: 'init'; sessionId: string }
| { type: 'result'; text: string | null }
| { type: 'init'; continuation: string }
| { type: 'result'; text: string | null; isError?: boolean }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string };
| { type: 'progress'; message: string }
| { type: 'activity' };
```
### What the interface does NOT include
- **Message formatting** — the agent-runner formats messages before passing to the provider. The provider receives a ready-to-send prompt string.
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreCompact, PreToolUse, etc.). Other providers don't need them.
- **Tool allowlists** — Claude uses `allowedTools`. Codex uses `approvalPolicy`. OpenCode uses `permission`. Each provider configures this internally based on the same intent: "allow everything, no prompting."
- **Session persistence**Claude persists sessions to disk automatically. Codex and OpenCode manage their own session state. The agent-runner doesn't control this — it just passes `sessionId` and `resumeAt`.
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreToolUse, PostToolUse, PreCompact). Other providers don't need them.
- **Tool allowlists** — Claude uses `allowedTools` + `disallowedTools`. Other SDKs use their own equivalents. Each provider configures this internally.
- **Session persistence**the agent-runner stores one opaque `continuation` token per provider (see [Session Resume](#session-resume)) and passes it back as `QueryInput.continuation`. What it means is provider-private; Claude persists its own `.jsonl` transcript on disk keyed by the continuation (session ID).
- **Sandbox configuration** — provider-specific. Each provider configures its own sandbox internally.
### Provider event semantics
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `sessionId` for future resume.
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). The agent-runner writes each result to messages_out.
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota', 'auth', 'transport').
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `continuation` and persists it for future resume.
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). `isError` is set when the SDK flagged the turn as an error (e.g. a non-retryable billing error) so the poll-loop still surfaces the text instead of dropping it. The agent-runner writes each result to messages_out.
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota').
- **`progress`** — optional, for logging. The agent-runner logs these but doesn't act on them.
- **`activity`** — a liveness signal. Providers MUST yield it on every underlying SDK event (tool call, thinking, partial message) so the poll-loop's idle timer stays honest during long tool runs.
## Provider Implementations
@@ -97,58 +124,82 @@ Only the `claude` provider ships in trunk. The Codex and OpenCode sections below
Wraps `@anthropic-ai/claude-agent-sdk`'s `query()`.
The provider takes its settings (`mcpServers`, `env`, `additionalDirectories`,
`model`, `effort`, `assistantName`) in its constructor via `ProviderOptions`; `query()`
only reads the per-turn `QueryInput`.
```typescript
class ClaudeProvider implements AgentProvider {
readonly supportsNativeSlashCommands = true;
// ...constructor stores options.mcpServers, .env, .additionalDirectories,
// .model, .effort, .assistantName...
query(input: QueryInput): AgentQuery {
const stream = new MessageStream(); // AsyncIterable<SDKUserMessage>
stream.push(input.prompt);
const sdkQuery = query({
const sdkResult = sdkQuery({
prompt: stream,
options: {
cwd: input.cwd,
resume: input.sessionId,
resumeSessionAt: input.resumeAt,
systemPrompt: input.systemPrompt
? { type: 'preset', preset: 'claude_code', append: input.systemPrompt }
additionalDirectories: this.additionalDirectories,
resume: input.continuation,
pathToClaudeCodeExecutable: '/pnpm/claude',
systemPrompt: input.systemContext?.instructions
? { type: 'preset', preset: 'claude_code', append: input.systemContext.instructions }
: undefined,
mcpServers: input.mcpServers, // already the right shape
additionalDirectories: input.additionalDirectories,
env: input.env,
allowedTools: NANOCLAW_TOOL_ALLOWLIST,
// Base tools plus one `mcp__<server>__*` pattern per registered MCP
// server — without the explicit MCP patterns the SDK's allowedTools
// filter silently drops every MCP namespace.
allowedTools: [...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern)],
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
effort: this.effort,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user', 'local'],
mcpServers: this.mcpServers,
hooks: {
PreCompact: [{ hooks: [preCompactHook] }],
PreToolUse: [{ matcher: 'Bash', hooks: [sanitizeBashHook] }],
PreToolUse: [{ hooks: [preToolUseHook] }],
PostToolUse: [{ hooks: [postToolUseHook] }],
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
},
},
});
let aborted = false;
return {
push: (msg) => stream.push(msg),
end: () => stream.end(),
abort: () => sdkQuery.close(),
events: translateClaudeEvents(sdkQuery),
// Abort doesn't call into the SDK — it flips a flag the event generator
// checks and ends the input stream so the query drains and stops.
abort: () => { aborted = true; stream.end(); },
events: translateEvents(sdkResult, () => aborted),
};
}
}
```
`translateClaudeEvents` is an async generator that maps SDK messages to `ProviderEvent`:
- `message.type === 'system' && message.subtype === 'init'``{ type: 'init', sessionId }`
- `message.type === 'result'``{ type: 'result', text }`
- `message.type === 'system' && message.subtype === 'api_retry'``{ type: 'error', retryable: true }`
- `message.type === 'system' && message.subtype === 'rate_limit_event'``{ type: 'error', retryable: false, classification: 'quota' }`
- `message.type === 'system' && message.subtype === 'task_notification'``{ type: 'progress', message }`
- Everything else → logged, not emitted
`translateEvents` is an async generator that yields `{ type: 'activity' }` for **every**
SDK message (so the idle timer stays honest) and maps recognized messages to `ProviderEvent`:
- `system`/`init``{ type: 'init', continuation: session_id }`
- `result``{ type: 'result', text, isError }``text` is `result.result`, or the joined `result.errors[]` on error subtypes (billing/quota), so the notice still reaches the user
- `system`/`api_retry``{ type: 'error', retryable: true }`
- `system`/`rate_limit_event``{ type: 'error', retryable: false, classification: 'quota' }`
- `system`/`compact_boundary``{ type: 'result', text: 'Context compacted…' }`
- `system`/`task_notification``{ type: 'progress', message }`
- when the `aborted` flag is set → the generator returns immediately
**Claude-specific features preserved inside the provider:**
- `MessageStream` for async iterable input (push-based)
- `resumeSessionAt` for resume at specific message UUID
- PreCompact hook for transcript archiving
- PreToolUse hook for sanitizing bash env vars
- Full tool allowlist
**Claude-specific behavior inside the provider:**
- `MessageStream` for async iterable input (push-based follow-ups)
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
- **PreCompact** hook archives the transcript to `conversations/` before compaction
- `maybeRotateContinuation` drops an oversized/aged transcript (default caps 12 MB / 14 days, both operator-overridable) so a cold container isn't killed reloading days of `.jsonl` before the host idle ceiling; `isSessionInvalid` clears a continuation whose transcript is gone
- `additionalDirectories` for multi-directory access
### Codex Provider
@@ -159,8 +210,8 @@ Wraps `@openai/codex-sdk`.
class CodexProvider implements AgentProvider {
query(input: QueryInput): AgentQuery {
const codex = new Codex(this.buildOptions(input));
const thread = input.sessionId
? codex.resumeThread(input.sessionId, this.threadOptions(input))
const thread = input.continuation
? codex.resumeThread(input.continuation, this.threadOptions(input))
: codex.startThread(this.threadOptions(input));
const abortController = new AbortController();
@@ -188,13 +239,13 @@ class CodexProvider implements AgentProvider {
signal: abortController.signal,
});
let sessionId: string | undefined;
let continuation: string | undefined;
let resultText = '';
for await (const event of streamed.events) {
if (event.type === 'thread.started') {
sessionId = event.thread_id;
yield { type: 'init', sessionId };
continuation = event.thread_id;
yield { type: 'init', continuation };
}
if (event.type === 'item.completed' && event.item.type === 'agent_message') {
resultText = event.item.text || resultText;
@@ -264,7 +315,7 @@ class OpenCodeProvider implements AgentProvider {
private async *run(client, server, stream, input, getPendingFollowUp): AsyncIterable<ProviderEvent> {
const session = await client.session.create();
yield { type: 'init', sessionId: session.data.id };
yield { type: 'init', continuation: session.data.id };
await client.session.promptAsync({
path: { id: session.data.id },
@@ -356,62 +407,58 @@ The agent-runner transforms messages_in rows into a prompt string. The provider
**Routing field stripping:** `platform_id`, `channel_type`, `thread_id` are never included in the prompt. They're stored as context for writing messages_out.
**Single message formatting by kind:**
Every kind renders to a single self-contained XML element. The `id` attribute is the
message's `seq` (the agent-facing message ID it passes to `edit_message` / `add_reaction`).
The `from` attribute is the origin destination name (resolved from the routing fields via
the destination map), so the agent always knows where a message came from — routing fields
themselves are never shown.
- **`chat`** — format into message XML:
- **`chat`** — one `<message>` per row:
```xml
<message sender="John" time="2024-01-01 10:00">
Check this PR
</message>
<message id="5" from="family" sender="John" time="Jan 1, 10:00 AM">Check this PR</message>
```
A reply carries a `reply_to` attribute and an inline `<quoted_message from="…">…</quoted_message>`.
- **`chat-sdk`** — extract fields from serialized Chat SDK message:
- **`chat-sdk`** — same `<message>` shape, fields extracted from the serialized Chat SDK
message. Attachments are appended inline: `[image: screenshot.png — saved to /workspace/…]`
or `[image: screenshot.png (https://signed-url…)]`. Images/PDFs that Claude handles
natively are also passed as content blocks (see Media Handling below).
- **`task`** — a `<task>` element, script output first when present:
```xml
<message sender="John (john@slack)" time="2024-01-01 10:00">
Check this PR
[image: screenshot.png — https://signed-url...]
</message>
```
Attachments are listed inline. Images/PDFs that Claude handles natively are passed as content blocks (see Media Handling below).
- **`task`** — task prompt, optionally with script output:
```
[SCHEDULED TASK]
Script output:
{"data": ...}
<task from="scheduler" time="Jan 1, 9:00 AM">Script output:
{"data": …}
Instructions:
Review open PRs
Review open PRs</task>
```
- **`webhook`** — webhook payload:
```
[WEBHOOK: github/pull_request]
{"action": "opened", "pull_request": {...}}
- **`webhook`** — a `<webhook>` element wrapping the JSON payload:
```xml
<webhook from="github" source="github" event="pull_request">{"action": "opened", …}</webhook>
```
- **`system`** — host action result (response to an earlier system request):
```
[SYSTEM RESPONSE]
Action: register_agent_group
Status: success
Result: {"agent_group_id": "ag-456"}
- **`system`** — host action result, rendered as `<system_response>`:
```xml
<system_response from="host" action="create_agent" status="success">{"agent_group_id": "ag-456"}</system_response>
```
**Batch formatting:** Multiple pending messages are combined into one prompt:
**Batch formatting:** All pending messages are combined into one prompt. The prompt opens
with a self-closing `<context timezone="<IANA>" />` header (so the agent interprets every
timestamp — and every time it schedules — in the user's zone), then the chat messages
concatenated as consecutive `<message>` blocks, then any task/webhook/system elements,
joined by blank lines:
```xml
<context timezone="America/Los_Angeles">
<messages>
<message sender="John" time="10:00">Check this PR</message>
<message sender="Jane" time="10:01">Already on it</message>
</messages>
<context timezone="America/Los_Angeles" />
<message id="2" from="family" sender="John" time="10:00">Check this PR</message>
<message id="4" from="family" sender="Jane" time="10:01">Already on it</message>
```
Mixed kinds (e.g., a chat message + a system response) are combined with clear delimiters. Each section is labeled by kind.
There is **no** outer `<messages>` envelope — an earlier revision wrapped multi-message
batches that way, but the Claude Agent SDK answered the wrapped shape with a synthetic
"No response requested." stub instead of calling the API (#2555). Dropping the wrapper made
the single-message path just the N=1 case of the same concatenation.
**Command detection:** Messages starting with `/` are checked against a command list. Recognized commands bypass formatting and are passed raw to the provider (for Claude's slash command handling) or intercepted by the agent-runner (for NanoClaw-level commands like session reset).
@@ -430,54 +477,70 @@ interface RoutingContext {
When writing messages_out (either from provider results or MCP tool calls), the agent-runner copies this routing context by default. The agent never sees routing fields — it just produces text. The routing is implicit: "respond to whoever sent the message."
MCP tools that target a different destination (e.g., `send_to_agent`, `send_message` with explicit channel) override the routing context for that specific messages_out row.
MCP tools that target a named destination (`send_message` / `send_file` with a `to`
argument) resolve routing through the session's destination map instead of the default
reply context — including agent-to-agent sends, which are just a `to` pointing at an
`agent`-type destination.
### Status Management
The agent-runner manages the `status` and `status_changed` fields on messages_in:
`inbound.db` is a read-only mount inside the container, so the agent-runner never writes
`messages_in`. It tracks processing status in the `processing_ack` table in the
container-owned `outbound.db`; the host reads `processing_ack` and mirrors completion
back onto `messages_in.status`.
```
pending → processing → completed
→ failed (if provider returns error and max retries exhausted)
processing_ack: (no row) → processing → completed
```
- **Pick up:** `UPDATE messages_in SET status = 'processing', status_changed = now(), tries = tries + 1 WHERE id IN (...)`
- **Complete:** `UPDATE messages_in SET status = 'completed', status_changed = now() WHERE id IN (...)`
- **Error:** Agent-runner does NOT set `failed` — it leaves the message as `processing`. The host detects stale processing via `status_changed` and handles retry logic (reset to pending with backoff). This keeps retry policy on the host side.
- **Pick up:** `INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', now())` for each claimed row (`markProcessing`). Pending queries skip any row already present in `processing_ack`.
- **Complete:** same upsert with `status = 'completed'` (`markCompleted`). Every consumed batch ends here — error outcomes included. On a provider error the poll-loop writes an error **chat message** to `messages_out` (so the user sees it), then still acks the batch completed; errors surface as messages, not as an ack status. (A `markFailed` helper exists in `messages-in.ts` but currently has no callers.)
- The host's `syncProcessingAcks` mirrors acked ids onto `messages_in.status = 'completed'`. Its stale/retry policy is driven off the `.heartbeat` file mtime and the `processing_ack` claim timestamps. On startup the agent-runner clears leftover `processing` acks (crash recovery) so orphaned claims re-process.
### MCP Tools
The agent-runner runs an MCP server that exposes NanoClaw tools to the agent. All tools write to the session DB.
**DB path:** The MCP server receives the session DB path via environment variable. It opens a second connection to the same SQLite file (WAL mode allows concurrent access).
The agent-runner runs an MCP server (stdio) that exposes NanoClaw tools to the agent. The
tool modules use the same two-DB connection layer as the rest of the runner
(`container/agent-runner/src/db/connection.ts`): they read the host-written `inbound.db`
at `/workspace/inbound.db` **read-only** (destinations, session routing, question
responses, task lists) and write to the container-owned `outbound.db` at
`/workspace/outbound.db`. There is no shared single-file connection and no WAL — both files
are `journal_mode=DELETE` because WAL's memory-mapped `-shm` file does not stay coherent
across the VirtioFS host↔container mount.
#### send_message
Send a chat message to the current conversation (or a specified destination).
Send a chat message to a named destination. Agents address destinations by name, never by
raw platform/channel/thread IDs — the destination map (`destinations` table in `inbound.db`,
written by the host) resolves the name to routing fields.
```typescript
{
name: 'send_message',
params: {
text: string, // message content
channel?: string, // optional: target channel type (default: reply to origin)
platformId?: string, // optional: target platform ID
threadId?: string, // optional: target thread ID
text: string, // message content (required)
to?: string, // destination name (e.g. "family", "worker-1").
// Optional when the agent has exactly one destination.
}
}
```
Implementation: write a `messages_out` row with `kind: 'chat'`. If channel/platformId/threadId are provided, use those as routing. Otherwise, copy from the current routing context.
Implementation: `resolveRouting(to)` looks up the destination. With no `to`, it defaults to
the session's own reply routing (`session_routing`); if the destination resolves to the same
channel the session is bound to, the session's `thread_id` is preserved so the reply lands
in-thread, otherwise `thread_id` is null. The tool then writes a `messages_out` row with
`kind: 'chat'` and content `{ text }`, and returns the new `seq` as the message id.
#### send_file
Send a file to the current conversation.
Send a file to a named destination (same destination model as `send_message`).
```typescript
{
name: 'send_file',
params: {
path: string, // file path (relative to /workspace/agent/ or absolute)
path: string, // file path (relative to /workspace/agent/ or absolute) (required)
to?: string, // destination name; optional if the agent has one destination
text?: string, // optional accompanying message
filename?: string, // display name (default: basename of path)
}
@@ -485,10 +548,10 @@ Send a file to the current conversation.
```
Implementation:
1. Generate a message ID
2. Create `outbox/{messageId}/` directory
3. Copy the file into the outbox directory
4. Write a `messages_out` row with `files: [filename]` in the content
1. Resolve routing via `resolveRouting(to)` (as `send_message`)
2. Generate a message ID and create `/workspace/outbox/{messageId}/`
3. Copy the file into that outbox directory
4. Write a `messages_out` row (`kind: 'chat'`) with content `{ text, files: [filename] }`
#### send_card
@@ -523,11 +586,11 @@ Send an interactive question and wait for the user's response. This is a **block
```
Implementation:
1. Generate a `questionId`
2. Write a `messages_out` row with `operation: 'ask_question'`, the question, options, and questionId
3. Poll `messages_in` for a row with matching `questionId` in content
4. When found, return the `selectedOption` as the tool result
5. If timeout expires, return a timeout error as the tool result
1. Generate a `questionId` and normalize each option to `{ label, selectedLabel, value }`
2. Write a `messages_out` row with `kind: 'chat-sdk'` and content `{ type: 'ask_question', questionId, title, question, options }`
3. Poll `inbound.db` (read-only) for a pending `messages_in` row whose content carries the matching `questionId` (`findQuestionResponse`), skipping any already in `processing_ack`
4. When found, `markCompleted` the response row (a `processing_ack` write in `outbound.db`) and return its `selectedOption` as the tool result
5. If the deadline passes, return a timeout error as the tool result
The agent's execution is paused at this tool call. The provider's query keeps running (Claude holds the tool call open). The agent-runner polls for the response in a separate loop.
@@ -563,89 +626,63 @@ Add an emoji reaction to a message.
Implementation: write a `messages_out` row with `operation: 'reaction'`.
#### send_to_agent
#### Agent-to-agent sends (no dedicated tool)
Send a message to another agent group.
There is no `send_to_agent` tool. Agents and channels share one destination namespace, so
messaging another agent is just `send_message(to="<agent-name>")` where the named
destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row with
`channel_type: 'agent'` and `platform_id` set to the target agent group id; the host
validates the send and routes it into the target session's `inbound.db`.
#### ncl tasks
Schedule, inspect, and modify one-shot or recurring tasks.
```bash
ncl tasks create --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
ncl tasks list
ncl tasks update <series_id> --prompt "..."
ncl tasks cancel <series_id>
```
Implementation: the host writes `messages_in` task rows into the agent group's system session (`thread_id = system:tasks`). The host sweep wakes that system-session container when a task is due. The task agent chooses its destination at fire time by emitting `<message to="name">...</message>` or using `send_message`.
#### create_agent
Create a long-lived companion sub-agent. The `name` becomes a destination the creating
agent can address. (There is no `register_agent_group` tool — this replaced it.)
```typescript
{
name: 'send_to_agent',
name: 'create_agent',
params: {
agentGroupId: string, // target agent group
text: string, // message content
sessionId?: string, // optional: target specific session
name: string, // human-readable name; also the destination name (required)
instructions?: string, // CLAUDE.md content for the new agent (role, personality)
}
}
```
Implementation: write a `messages_out` row with `channel_type: 'agent'`, `platform_id: agentGroupId`, `thread_id: sessionId`.
Implementation: fire-and-forget. Writes a `messages_out` row with `kind: 'system'`,
`action: 'create_agent'`, `requestId`, `name`, and `instructions`. The container is
untrusted and does not gate itself; the host authorizes by CLI scope — trusted owner groups
(scope `global`) create directly, confined groups require admin approval
(`src/modules/agent-to-agent/create-agent.ts`) — then creates the entity rows and notifies
the agent via a chat message when the agent is ready.
#### schedule_task
#### Self-modification: install_packages, add_mcp_server
Schedule a one-shot or recurring task.
Two fire-and-forget system-action tools let an agent extend its own runtime (both require
admin approval, applied host-side):
```typescript
{
name: 'schedule_task',
params: {
prompt: string, // task prompt
processAfter: string, // ISO timestamp for first run
recurrence?: string, // cron expression (optional)
script?: string, // pre-agent script (optional)
}
}
```
- **`install_packages`** — `{ apt?: string[], npm?: string[], reason?: string }`. Package
names are validated at the tool boundary and re-validated on the host. On approval the
host rebuilds the per-agent image and restarts the container.
- **`add_mcp_server`** — `{ name, command, args?, env? }`. Wires an existing third-party MCP
server into the agent's `container.json`; on approval the host updates the config and
restarts (no rebuild — Bun runs the TS directly).
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts``insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
#### list_tasks
List active scheduled/recurring tasks.
```typescript
{
name: 'list_tasks',
params: {}
}
```
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
#### cancel_task / pause_task / resume_task / update_task
Modify a scheduled task.
```typescript
{
name: 'cancel_task',
params: { taskId: string }
}
// pause_task: set status = 'paused' (new status value for recurring tasks)
// resume_task: set status = 'pending'
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
```
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
#### register_agent_group
Register a new agent group (admin only).
```typescript
{
name: 'register_agent_group',
params: {
name: string,
folder: string,
platformId: string, // messaging group to wire to
channelType: string,
triggerRules?: object,
sessionMode?: 'shared' | 'per-thread',
}
}
```
Implementation: write a `messages_out` row with `kind: 'system'`, `action: 'register_agent_group'`. The host reads, validates admin permission, creates the entity rows in the central DB, and writes a `system` messages_in response.
Both write a `messages_out` row with `kind: 'system'` and the matching `action`, then return
immediately; the host notifies the agent when approval resolves.
### Media Handling
@@ -701,12 +738,20 @@ Archive location: `/workspace/agent/conversations/{date}-{summary}.md`
### Session Resume
The agent-runner tracks `sessionId` and `resumeAt` across queries:
The agent-runner tracks a single opaque `continuation` token per provider:
- `sessionId` — captured from `ProviderEvent { type: 'init' }`. Passed back to `QueryInput.sessionId` on the next query.
- `resumeAt` — Claude-specific (last assistant message UUID). Stored by the agent-runner, passed to `QueryInput.resumeAt`. Providers that don't support this ignore it.
- Captured from `ProviderEvent { type: 'init', continuation }` and persisted to the
`session_state` table in `outbound.db` under the key `continuation:<provider>` (keyed per
provider because a continuation is provider-private — a Claude session id is meaningless to
another provider).
- Passed back as `QueryInput.continuation` on the next query. For Claude that becomes the
SDK `resume` option; the SDK reloads its on-disk `.jsonl` transcript for that session id.
These are ephemeral to the container's lifetime. When the container is killed and restarted, the host passes the stored `sessionId` from the central DB's sessions table. `resumeAt` is lost on container restart (the provider resumes from the end of the session).
Because it lives in the session folder's `outbound.db`, the continuation survives container
teardown and restart — a fresh container reads it back and resumes. `/clear` deletes the row
to start a clean session. Before resuming, `maybeRotateContinuation` may archive and drop an
oversized/aged transcript (so a cold container isn't killed reloading it), and
`isSessionInvalid` clears a continuation whose backing transcript has gone missing.
### Container Startup
@@ -714,7 +759,7 @@ The agent-runner receives configuration via:
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
- **Fixed mount paths:** Host-written `inbound.db` (read-only) at `/workspace/inbound.db` and container-owned `outbound.db` at `/workspace/outbound.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
+4 -2
View File
@@ -13,6 +13,8 @@ interface ChannelSetup {
// Host callbacks
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
// Admin-transport adapters (e.g. CLI) route to an arbitrary channel via this instead of onInbound
onInboundEvent(event: InboundEvent): void;
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
}
@@ -34,7 +36,7 @@ interface ChannelAdapter {
isConnected(): boolean;
// Outbound delivery
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;
@@ -307,7 +309,7 @@ function createWhatsAppChannel(): ChannelAdapter {
**Ask user question:**
```json
{
"operation": "ask_question",
"type": "ask_question",
"questionId": "q-123",
"title": "Failing Test",
"question": "How should we handle the failing test?",
+4 -4
View File
@@ -311,7 +311,6 @@ erDiagram
string name
string folder
string agent_provider
json container_config
}
messaging_groups {
int id
@@ -344,14 +343,15 @@ erDiagram
int messaging_group_id
int agent_group_id
string session_mode
json trigger_rules
string engage_mode "pattern | mention | mention-sticky"
string sender_scope "all | known"
int priority
}
sessions {
int id
int agent_group_id
int messaging_group_id
string sdk_session_id
string thread_id
string status
}
</pre>
@@ -391,7 +391,7 @@ flowchart LR
Container -->|writes · odd seq| Out
Container -->|touch every poll| HB
HostSweep[Host sweep] -->|stat mtime| HB
HostSweep -->|reads processing_ack| In
HostSweep -->|reads processing_ack| Out
</pre>
</div>
</section>
+10 -10
View File
@@ -28,18 +28,18 @@ flowchart TB
Approvals["configureManualApproval<br/>-> pending_approvals"]
end
subgraph Session["Per-Session Container (Docker / Apple Container)"]
subgraph Session["Per-Session Container (Docker)"]
direction TB
PollLoop["Poll Loop<br/>(container/agent-runner)"]
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
Provider["Agent providers<br/>(claude — the only one registered in trunk;<br/>opencode ships via the /add-opencode skill)"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>create_agent, install_packages, add_mcp_server<br/>CLI: ncl tasks"]
Skills["Container Skills<br/>(container/skills/)"]
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>heartbeat file")]
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>delivered<br/>destinations<br/>session_routing")]
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>processing_ack<br/>session_state<br/>container_state<br/>heartbeat file")]
end
subgraph Groups["Agent Group Filesystem (groups/*)"]
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container_config"]
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container.json (materialized from container_configs)"]
end
P1 & P2 & P3 & P4 & P5 --> Bridge
@@ -140,7 +140,6 @@ erDiagram
string name
string folder
string agent_provider
json container_config
}
messaging_groups {
int id
@@ -173,14 +172,15 @@ erDiagram
int messaging_group_id
int agent_group_id
string session_mode "agent-shared | shared | per-thread"
json trigger_rules
string engage_mode "pattern | mention | mention-sticky"
string sender_scope "all | known"
int priority
}
sessions {
int id
int agent_group_id
int messaging_group_id
string sdk_session_id
string thread_id
string status
}
```
@@ -209,7 +209,7 @@ flowchart LR
Container -->|"writes only<br/>(odd seq)"| Out
Container -->|touch every poll| HB
HostSweep[Host sweep] -->|stat mtime| HB
HostSweep -->|reads processing_ack| In
HostSweep -->|reads processing_ack| Out
note1["Each file has exactly ONE writer.<br/>Eliminates SQLite cross-process write contention.<br/>Collision-free seq numbering."]
```
+164 -100
View File
@@ -4,7 +4,17 @@
## Core Idea
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
Each agent session has a **pair** of mounted SQLite DBs. They are the one and only IO
mechanism between host and container. No IPC files, no stdin piping. `inbound.db` carries
host → agent-runner messages (`messages_in`); `outbound.db` carries agent-runner → host
messages (`messages_out`) plus the container's processing acks. Everything is a message.
The split exists so each file has exactly one writer: the host writes `inbound.db` (the
container opens it read-only) and the container writes `outbound.db` (the host opens it
read-only). One writer per file means no cross-process lock contention over the
host↔container mount. Both files run `journal_mode=DELETE`, **not** WAL: WAL's memory-mapped
`-shm` coherency does not propagate across VirtioFS, so a WAL reader in the guest would
freeze on an early snapshot and never see new host writes.
## Two-Level DB
@@ -13,15 +23,18 @@ Each agent session has a mounted SQLite DB. The DB is the one and only IO mechan
- Maps platform IDs → agent groups → sessions
- Channel adapters don't touch this directly — the host does the lookup
**Per-session DB (mounted into container):**
- messages_in (written by host, read by agent-runner)
- messages_out (written by agent-runner, read by host)
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use these two tables
- One DB per session, not per agent group
**Per-session DBs (mounted into container):**
- `inbound.db``messages_in` (written by host, read-only in container) plus host-written
lookup tables the container reads live: `destinations`, `session_routing`, `delivered`
- `outbound.db``messages_out` (written by agent-runner, read by host) plus
`processing_ack`, `session_state`, and `container_state` (all container-owned)
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use
`messages_in` / `messages_out`
- One pair per session, not per agent group
## Agent Groups vs Sessions
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own DB mounted at a known path. Each session = a separate container with the same agent group's filesystem but a different session DB.
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own `inbound.db`/`outbound.db` pair mounted at known paths. Each session = a separate container with the same agent group's filesystem but a different DB pair.
## Message Flow
@@ -30,13 +43,13 @@ Platform event
→ Channel adapter (trigger check, ID extraction)
→ Returns: { platformChannelId, platformThreadId, triggered }
→ Host maps platformChannelId + platformThreadId → agent group + session
→ Host writes message to session's DB
→ Host writes messages_in row to the session's inbound.db
→ Host calls wakeUpAgent(session)
→ Container spins up (or is already running)
→ Agent-runner polls its session DB, finds new messages
→ Agent-runner processes with Claude
→ Agent-runner writes response to session DB
→ Host polls active session DBs for responses
→ Agent-runner polls inbound.db (read-only), finds new messages
→ Agent-runner processes with the configured provider
→ Agent-runner writes response to messages_out in outbound.db
→ Host polls active sessions' outbound.db for responses
→ Host reads response, looks up conversation, delivers through channel adapter
```
@@ -131,7 +144,7 @@ The host is an orchestrator:
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
When a container spins up, the agent-runner immediately starts polling `inbound.db`. Messages are already there waiting.
## Media Handling
@@ -185,50 +198,66 @@ Dedup is the channel adapter's responsibility. Chat SDK handles this internally.
## Session DB Schema
Two tables. JSON blobs for content — schema-free, format varies by `kind`.
Split across the two files. JSON blobs for content — schema-free, format varies by `kind`.
`seq` is a global ordering counter with a **disjoint parity**: the host writes even seqs to
`messages_in`, the container writes odd seqs to `messages_out`. Each side reads the other's
MAX(seq) to pick its next value, so seq is a single monotonic message id across both tables —
which is why the agent-facing message id it returns from `send_message` (and accepts in
`edit_message` / `add_reaction`) is unambiguous.
```sql
-- Host writes, agent-runner reads
-- inbound.db — host writes, container opens read-only
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- even (host-assigned)
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed'
status_changed TEXT, -- ISO timestamp of last status change
status TEXT DEFAULT 'pending', -- host-owned; the host mirrors the container's
-- processing_ack terminal states onto this column
process_after TEXT, -- ISO timestamp. NULL = process immediately.
recurrence TEXT, -- cron expression. NULL = one-shot.
series_id TEXT, -- groups a recurring task's occurrences
tries INTEGER DEFAULT 0, -- number of processing attempts
-- routing (agent-runner copies to messages_out; agent never sees these)
platform_id TEXT,
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = accumulated context (don't wake), 1 = wake
platform_id TEXT, -- routing (stripped before the agent sees content)
channel_type TEXT,
thread_id TEXT,
-- payload (structure depends on kind)
content TEXT NOT NULL -- JSON blob
content TEXT NOT NULL, -- JSON blob (structure depends on kind)
source_session_id TEXT, -- a2a return path: source session that emitted the trigger
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on a container's first poll
);
-- Agent-runner writes, host reads
-- outbound.db — container writes, host opens read-only
CREATE TABLE messages_out (
id TEXT PRIMARY KEY,
in_reply_to TEXT, -- references messages_in.id (optional)
seq INTEGER UNIQUE, -- odd (container-assigned)
in_reply_to TEXT, -- references messages_in.id (optional)
timestamp TEXT NOT NULL,
delivered INTEGER DEFAULT 0,
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
recurrence TEXT, -- cron expression. NULL = one-shot.
-- routing (default: copied from messages_in by agent-runner)
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
platform_id TEXT,
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
recurrence TEXT, -- cron expression. NULL = one-shot.
kind TEXT NOT NULL, -- copied from messages_in by default
platform_id TEXT, -- routing (default: copied from messages_in)
channel_type TEXT,
thread_id TEXT,
-- payload (format matches kind)
content TEXT NOT NULL -- JSON blob
content TEXT NOT NULL -- JSON blob (format matches kind)
);
-- outbound.db — container's processing status (it can't write inbound.db).
-- Host reads this to drive the message lifecycle; stale 'processing' rows are
-- cleared on container startup (crash recovery).
CREATE TABLE processing_ack (
message_id TEXT PRIMARY KEY, -- references messages_in.id
status TEXT NOT NULL, -- 'processing' | 'completed' | 'failed'
status_changed TEXT NOT NULL
);
```
Delivery is tracked host-side in a `delivered` table in `inbound.db` (not a column on
`messages_out`, which the host can't write). `inbound.db` also holds the host-written
`destinations` and `session_routing` tables the container reads live; `outbound.db` also
holds `session_state` (the resume continuation, per provider) and `container_state`
(current tool in flight).
### Scheduling
One-shot and recurring tasks use the same tables — no separate scheduler.
@@ -237,10 +266,10 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
**Recurring:** Same, plus a `recurrence` cron expression. After the host marks a row as handled/delivered, if `recurrence` is set, it inserts a new row with `process_after`/`deliver_after` advanced to the next cron occurrence. Next time is computed from the scheduled time (not wall clock) to prevent drift.
**Host sweep** (every ~60s across all session DBs):
- `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
- `messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)` → stale detection, increment tries, reset to pending with backoff
- `messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())` → deliver
**Host sweep** (every ~60s across all sessions):
- `inbound.db``messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
- A `processing_ack` (in `outbound.db`) whose claim, or the `.heartbeat` mtime, is older than the stale threshold → stale detection, increment tries, reschedule `process_after` with backoff
- `outbound.db` → due `messages_out` rows not yet in the host's `delivered` table (in `inbound.db`) → deliver
- After completing/delivering a row with `recurrence`, insert next occurrence
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
@@ -332,7 +361,7 @@ Two patterns, both handled at the host level:
In both cases, the approval and action execution happen on the host side, not the agent side.
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/access.ts`, `src/user-dm.ts`.
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/modules/permissions/access.ts` and `src/modules/permissions/user-dm.ts` (`ensureUserDm`); the approver-picking primitives live in `src/modules/approvals/primitive.ts`.
**Editing a sent message:**
@@ -409,14 +438,16 @@ This is documented as a pattern, not a built-in feature.
## Design Decisions
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `session.db` and the Claude SDK's `.claude/` directory. The session identity IS the folder — no need to track Claude SDK session IDs.
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `inbound.db`, `outbound.db`, and — when using the claude provider — the SDK's `.claude/` directory. The session identity IS the folder. The SDK's resume token (session id) is persisted in `outbound.db`'s `session_state` table, so a fresh container picks the conversation back up without the host tracking it centrally.
**Container mount structure:**
```
/workspace/ ← mount: session folder (read-write)
.claude/ ← Claude SDK session data (auto-created)
session.db ← session SQLite DB
.claude/ ← Claude SDK session data / transcripts (auto-created)
inbound.db ← host writes, container reads (opened read-only)
outbound.db ← container writes, host reads (opened read-only)
.heartbeat ← container touches this; host watches its mtime
outbox/ ← agent-runner writes outbound files here
agent/ ← mount: agent group folder (nested, read-write)
CLAUDE.md ← agent instructions
@@ -424,11 +455,17 @@ This is documented as a pattern, not a built-in feature.
... working files
```
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace). The session DB is at `/workspace/session.db`.
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace).
This works on both Docker (nested bind mounts) and Apple Container (directory mounts only — no file-level mounts, but nested directory mounts are supported).
The runtime is Docker (`src/container-runtime.ts` hardcodes the `docker` binary); nested bind mounts make this layout straightforward. The layout deliberately sticks to directory mounts (no file-level mounts) so it stays portable to runtimes that only support directory mounts.
**Session DB concurrent access:** The host writes messages_in, the agent-runner writes messages_out. Both access the same SQLite file simultaneously. WAL mode handles this — SQLite allows concurrent readers, and the two sides write to different tables so writer contention is minimal. The host enables WAL mode when creating the session DB.
**Cross-mount DB access:** The two files exist precisely so each has a single writer — the
host writes `inbound.db`, the container writes `outbound.db` — which removes writer
contention across the mount. Both files use `journal_mode=DELETE`, **not** WAL: WAL keeps its
index in a memory-mapped `-shm` file, and VirtioFS does not propagate that mmap coherency
from host to guest, so a WAL reader in the container would freeze on an early snapshot and
silently never see new host writes. Readers that must see fresh host writes promptly (the
`messages_in` poll) open `inbound.db` with `mmap_size = 0` to bypass SQLite's page cache.
**Session management:** Host-managed. The host creates session folders and mounts them. The container only sees its own session folder.
@@ -439,7 +476,7 @@ This works on both Docker (nested bind mounts) and Apple Container (directory mo
3. More messages arrive before container starts → host finds the existing session, writes to the same session DB
4. Container starts, mounts the folder, agent-runner finds messages waiting
The central DB session row creation is the serialization point. No Claude SDK session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
The central DB session row creation is the serialization point. No provider session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
**System actions:** The agent uses MCP tools (register group, reset session, schedule task, etc.). The agent-runner handles these tool calls and writes a structured, deterministic messages_out row with `kind: 'system'`. This is not natural language — it's a programmatic, structured payload that the host processes deterministically. Host validates permissions, executes, and writes the result back as a `system` messages_in row.
@@ -449,7 +486,9 @@ The central DB session row creation is the serialization point. No Claude SDK se
### Output Delivery
NanoClaw does not stream tokens to users. The Claude Agent SDK's `query()` yields complete results. The agent-runner writes one complete message to messages_out per result. The host delivers complete messages to channels.
NanoClaw does not stream tokens to users. The provider's query interface yields complete results, but a result's text is not delivered as-is: the agent-runner parses it for `<message to="name">...</message>` blocks (`dispatchResultText` in poll-loop.ts) and writes one messages_out row per block, addressed to that destination with its thread context resolved per destination. Everything outside a block — including `<internal>...</internal>` — is scratchpad: logged, never sent. A block naming an unknown destination is dropped into the scratchpad log.
If a result produced text but no valid block, the agent-runner pushes a one-time `<system>` nudge into the live turn asking the agent to re-wrap its response. The exception is a non-retryable error result (e.g. a billing error) with no envelope, which is delivered as an error notice instead of being dropped as scratchpad. Mid-turn interim updates go out through the `send_message` MCP tool; the final-text envelope parsing is how a turn's reply reaches the user. The host delivers complete messages_out rows to channels.
Message editing is supported as an explicit operation (agent calls an `edit_message` tool), not as a streaming mechanism.
@@ -457,21 +496,30 @@ Typing indicators: host sets typing when a container is active for a session, cl
### Message Batching
When multiple messages arrive while the container is down, they accumulate as `handled = 0` rows in messages_in. When the container wakes up, the agent-runner queries all unhandled messages and processes them as a batch — multiple messages are formatted into a single `<messages>` XML block.
When multiple messages arrive while the container is down, they accumulate as `status = 'pending'` rows in `messages_in`. When the container wakes up, the agent-runner reads all pending messages (those not yet in `processing_ack`) and processes them as a batch — formatted as a `<context timezone="…" />` header followed by the messages concatenated as consecutive `<message>` blocks. (There is no `<messages>` wrapper element; see [agent-runner-details.md](agent-runner-details.md#message-formatting).)
### Message Lifecycle
```
pending → processing → completed
→ failed (after max retries)
messages_in.status: pending ──────────► completed (mirrored from ack)
└─────────► failed (host-set, retries exhausted)
processing_ack.status: processing → completed
```
- **pending**: Written by host. Ready to be picked up (if `process_after` is null or past).
- **processing**: Agent-runner sets this when it picks up the message. `status_changed` is set to now. Prevents other polls from re-picking the same message.
- **completed**: Agent-runner sets this after successful processing.
- **failed**: Set after max retries exhausted.
Because `inbound.db` is read-only in the container, the agent-runner never mutates
`messages_in.status`. It records lifecycle in `processing_ack` (in `outbound.db`); the host
reads that and mirrors completion back.
**Stale detection**: If a message is `processing` but `status_changed` is too old (e.g., >10 minutes), the host assumes the container crashed. It resets the message to `pending`, increments `tries`, and sets `process_after` with exponential backoff.
- **pending**: Host writes the `messages_in` row. Ready to be picked up (if `process_after` is null or past).
- **processing**: Agent-runner upserts a `processing_ack` row (`status = 'processing'`) when it claims the message. Subsequent polls skip any id already in `processing_ack`, so it isn't re-picked.
- **completed**: Agent-runner sets `processing_ack.status = 'completed'` for **every** consumed batch, error outcomes included — a provider error is surfaced to the user as an error chat message in `messages_out`, then the batch is still acked completed. The host's `syncProcessingAcks` copies it onto `messages_in.status`.
- **failed**: Set by the **host** (sweep's `markMessageFailed`) when retries are exhausted — never by the container.
**Liveness / stale detection**: The container touches a `/workspace/.heartbeat` file rather
than writing the DB. The host sweep watches that mtime (widening its tolerance when
`container_state` shows a long-declared Bash running) to decide a container has crashed, then
increments `tries` and reschedules `process_after` with exponential backoff. On the next
container startup, leftover `processing` acks are cleared so orphaned claims re-process.
### Error Handling and Retries
@@ -595,13 +643,15 @@ src/db/
- **No inline ALTER TABLE.** A migration runner with a `schema_version` table replaces `try { ALTER TABLE } catch { /* exists */ }` blocks. On startup, it checks the current version and applies pending migrations in order. Each migration is a function: `(db: Database) => void`.
- **Skills add migrations.** A skill that needs a new column adds a new numbered migration file. No conflicts with other skills' migrations as long as numbers don't collide (use timestamps or high-enough numbers for skill branches).
**Agent-runner session DB** uses the same pattern but lighter — no migrations needed since session DBs are created fresh by the host:
**Agent-runner session DBs** use the same pattern but lighter — no migrations needed since the DB files are created fresh by the host:
```
container/agent-runner/src/db/
connection.ts ← open session.db at fixed path, WAL mode
messages-in.ts ← read pending, update status
messages-out.ts ← write results, outbox queries
connection.ts ← open inbound.db (read-only) + outbound.db (DELETE mode) at fixed paths
messages-in.ts ← read pending from inbound.db, ack via processing_ack in outbound.db
messages-out.ts ← write results/outbox rows to outbound.db (odd seq)
session-state.ts ← resume continuation, keyed per provider
session-routing.ts ← read the host-written default reply routing
index.ts ← barrel
```
@@ -664,9 +714,13 @@ CREATE TABLE agent_groups (
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
agent_provider TEXT, -- default for sessions (null = system default)
container_config TEXT, -- JSON: { additionalMounts, timeout }
created_at TEXT NOT NULL
);
-- Container config is NOT a column here — it lives in a separate container_configs
-- table (migration 014), keyed by agent_group_id, with columns: provider, model,
-- effort, image_tag, assistant_name, max_messages_per_prompt, cli_scope, and JSON
-- columns skills / mcp_servers / packages_apt / packages_npm / additional_mounts.
-- The host materializes it into /workspace/agent/container.json for the container.
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
@@ -721,16 +775,21 @@ CREATE TABLE user_dms (
PRIMARY KEY (user_id, channel_type)
);
-- Which agent groups handle which messaging groups, with what rules
-- Which agent groups handle which messaging groups, with what rules.
-- The opaque trigger_rules JSON + response_scope enum were replaced (migration
-- 010) by four orthogonal axes:
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
trigger_rules TEXT, -- JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
response_scope TEXT DEFAULT 'all', -- 'all' | 'triggered' | 'allowlisted'
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
created_at TEXT NOT NULL,
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
engage_mode TEXT NOT NULL DEFAULT 'mention', -- 'pattern' | 'mention' | 'mention-sticky'
engage_pattern TEXT, -- regex; required for engage_mode='pattern'
-- ('.' = match every message, the "always" flavor)
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
created_at TEXT NOT NULL,
UNIQUE(messaging_group_id, agent_group_id)
);
@@ -795,7 +854,7 @@ stopped → running → idle → stopped
## Agent-Runner Architecture
The agent-runner is the process inside the container. It mediates between the session DB and the Claude SDK — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
The agent-runner is the process inside the container. It mediates between the session DB and the agent provider — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
### IO Model
@@ -808,50 +867,55 @@ All IO goes through the session DB. No stdin, no stdout markers, no IPC files.
### Poll Loop
1. Query `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`
2. If rows found: set `status = 'processing'`, `status_changed = now()` on each
1. Query `inbound.db` (read-only) for `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`, skipping any id already in `processing_ack`
2. If rows found: upsert `processing_ack` rows with `status = 'processing'` in `outbound.db` (the container can't write `messages_in`)
3. Batch messages into a single prompt (strip routing fields, format by kind)
4. Push into Claude SDK's MessageStream
4. Push into the provider's input stream
5. Process agent output → write `messages_out` rows
6. Set processed messages to `status = 'completed'`
6. Set the processed ids' `processing_ack.status = 'completed'` (the host mirrors that onto `messages_in.status`)
7. Back to step 1. If no messages found, sleep briefly and re-poll (container stays warm for idle timeout)
### Message Formatting by Kind
Agent-runner strips routing fields (`platform_id`, `channel_type`, `thread_id`) before formatting. The agent never sees routing info — it only sees content.
- **`chat`** — format into `<messages>` XML block
- **`chat-sdk`** — extract text, author, attachments from serialized message; format into `<messages>` XML
- **`task`** — format as `[SCHEDULED TASK]` prefix + prompt. Run pre-script if present.
- **`webhook`** — format as `[WEBHOOK: source/event]` + JSON payload
- **`system`** — host action results (e.g., "register_group succeeded"). Format as system context, not chat.
- **`chat`** — format into a `<message id="…" from="…" sender="…" time="…">` element
- **`chat-sdk`** — extract text, author, attachments from serialized message; same `<message>` element
- **`task`** — format as a `<task from="…" time="…">` element (script output first if present). Run pre-script if present.
- **`webhook`** — format as a `<webhook source="…" event="…">` element wrapping the JSON payload
- **`system`** — host action results, formatted as `<system_response action="…" status="…">`, not chat
Mixed batches (e.g., a chat message + a system result both pending) are combined into one prompt with clear delimiters.
### MCP Tools
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, create an agent, self-modify) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
**Core tools:**
**Messaging & interaction:**
| Tool | What it does |
|------|-------------|
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
**New tools:**
| Tool | What it does |
|------|-------------|
| `ask_user_question` | Write `messages_out` with question card. Hold tool call open, poll `messages_in` for response matching `questionId`. Return selection as tool result. |
| `edit_message` | Write `messages_out` with `operation: 'edit'` |
| `send_message` | Resolve `to` (destination name) → routing, write `messages_out` row, `kind: 'chat'`. Omit `to` to reply in place. Also the agent-to-agent path: a `to` naming an `agent`-type destination. |
| `send_file` | Copy file to `outbox/{msg_id}/`, write `messages_out` (`kind: 'chat'`) with filenames, same `to` resolution |
| `send_card` | Write `messages_out`, `kind: 'chat-sdk'`, content `{ type: 'card', … }` |
| `ask_user_question` | Write `messages_out` (`kind: 'chat-sdk'`, `type: 'ask_question'`). Hold tool call open, poll `inbound.db` for the response matching `questionId`. Return selection as tool result. |
| `edit_message` | Write `messages_out` with `operation: 'edit'` (targets the original message's destination) |
| `add_reaction` | Write `messages_out` with `operation: 'reaction'` |
| `send_to_agent` | Write `messages_out` with `channel_type: 'agent'`, `platform_id: '{target}'` |
| `send_card` | Write `messages_out` with card structure |
(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)
**Scheduling**: scheduled-task management is not an MCP surface — it lives on
`ncl tasks` (create/list/get/update/cancel/pause/resume/run/append-log). Due
task rows live in the agent group's system session and are woken by the host
sweep.
**Central-DB / self-modification** (`kind: 'system'` actions; host authorizes, often via admin approval):
| Tool | What it does |
|------|-------------|
| `create_agent` | `action: 'create_agent'` (name + instructions); host creates the agent group (replaces the old `register_agent_group`) |
| `install_packages` | `action: 'install_packages'`; on approval host rebuilds the per-agent image and restarts |
| `add_mcp_server` | `action: 'add_mcp_server'`; on approval host updates `container.json` and restarts |
See [agent-runner-details.md](agent-runner-details.md) for full MCP tool parameter definitions.
@@ -887,11 +951,11 @@ The command lists are hardcoded in the agent-runner. Admin verification happens
The agent-runner processes recurring task messages like any other messages_in row. After the agent-runner marks a recurring message as `completed`, the **host** handles inserting the next occurrence (new messages_in row with `process_after` advanced to next cron time). The agent-runner doesn't manage recurrence — it just processes what it finds.
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking Claude.
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking the provider.
### Agent-to-Agent Messaging
**Outbound:** Agent calls `send_to_agent` tool → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to target session's messages_in.
**Outbound:** Agent calls `send_message(to="<agent-name>")` where the named destination is of type `agent` → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to the target session's `inbound.db` (recording `source_session_id` so the reply routes back to this exact session).
**Inbound:** Messages from other agents arrive as normal `chat` messages_in rows. The content includes `sender` and `senderId` (e.g., `"senderId": "agent:pr-admin"`). No special formatting — the agent sees it as a chat message.
@@ -907,7 +971,7 @@ Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent
- **Approval routing** — how does the host find the admin's DM conversation? What if no DM channel exists? Is the approval list configurable per agent group or global?
- **MCP server lifecycle** — does the MCP server process persist across multiple queries in the same container, or restart each time?
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The session DB is at a fixed mount path. System prompt comes from CLAUDE.md. Provider name comes from env. What else?
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The DB files are at fixed mount paths. System prompt comes from CLAUDE.md. Provider name comes from `container.json` (materialized from the `container_configs` table), not env. What else?
- **Idle detection with pending questions** — when `ask_user_question` is waiting for a response, the container should not be considered idle. Also need to detect when the agent is still working (active tool calls, subagents) and avoid killing the container even if no messages_out have been written recently.
## Related Documents
+3 -3
View File
@@ -33,13 +33,13 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
## Supply chain
- **Host + global CLIs** (pnpm): `minimumReleaseAge: 4320` (3-day hold on new versions), `onlyBuiltDependencies` allowlist for postinstall scripts. See `pnpm-workspace.yaml` and `docs/SECURITY.md`.
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus version-pinned CLIs/Bun itself via Dockerfile ARGs. When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus a version-pinned Bun itself via a Dockerfile ARG (global CLIs are pinned separately in `container/cli-tools.json`). When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
## Image build surface
`container/Dockerfile` is a single-stage build on `node:22-slim`:
- **Pinned ARGs**`BUN_VERSION`, `CLAUDE_CODE_VERSION`, `AGENT_BROWSER_VERSION`, `VERCEL_VERSION`. Bump deliberately in PRs.
- **Pinned ARGs**`BUN_VERSION`, `PNPM_VERSION`, `INSTALL_CJK_FONTS`. Bump deliberately in PRs. Global CLI versions (`@anthropic-ai/claude-code`, `agent-browser`, `vercel`) are pinned separately in `container/cli-tools.json`, not as ARGs.
- **CJK fonts**`ARG INSTALL_CJK_FONTS=false`. `container/build.sh` reads `INSTALL_CJK_FONTS` from `.env` and passes it through. Default build saves ~200MB; opt in when the user works with Chinese/Japanese/Korean content.
- **BuildKit cache mounts**`/var/cache/apt`, `/var/lib/apt`, `/root/.bun/install/cache`, `/root/.cache/pnpm`. Rebuilds where `package.json`/`bun.lock` haven't changed are fast. Requires BuildKit (default on Docker 23+, Apple Container-compat).
- **`tini` as init** — reaps Chromium zombies, forwards signals so in-flight `outbound.db` writes finalize on SIGTERM.
@@ -49,7 +49,7 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
## Session wake (two paths)
1. **Base image ENTRYPOINT** — used for stdin-piped test invocations like the sample in `container/build.sh`: `tini --> entrypoint.sh` captures stdin to `/tmp/input.json`, then `exec bun run src/index.ts`.
2. **Host-spawned session**`src/container-runner.ts` at line ~301 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
2. **Host-spawned session**`src/container-runner.ts` at line ~503 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
Both paths end with Bun running the same source file from `/app/src/index.ts`.
+105 -24
View File
@@ -2,7 +2,7 @@
Complete reference for `data/v2.db`, the host-owned admin-plane database. Start with [db.md](db.md) for the three-DB overview, the map, and the cross-mount rules.
Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (comments only — actual creation runs via migrations in `src/db/migrations/`).
Access layer: `src/db/`. `src/db/schema.ts`'s `SCHEMA` constant is a *reference copy* of the core tables for orientation — it is not exhaustive: several tables (`agent_destinations`, `pending_approvals`, `container_configs`, `agent_message_policies`, `pending_channel_approvals`, and others) exist only in their migration files under `src/db/migrations/`, which remain the actual source of truth for what's created at runtime.
---
@@ -55,20 +55,24 @@ Wiring: which agent group handles which messaging group. Many-to-many — the sa
```sql
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
trigger_rules TEXT,
response_scope TEXT DEFAULT 'all',
session_mode TEXT DEFAULT 'shared',
priority INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
engage_mode TEXT NOT NULL DEFAULT 'mention',
-- 'pattern' | 'mention' | 'mention-sticky'
engage_pattern TEXT, -- regex; required when engage_mode='pattern';
-- '.' means "match every message"
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
session_mode TEXT DEFAULT 'shared',
priority INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(messaging_group_id, agent_group_id)
);
```
- `session_mode`: `shared` (one session per channel), `per-thread` (one per thread), `agent-shared` (one per agent group across all channels).
- `trigger_rules`: JSON; e.g. regex for native channels.
- `engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy`: four orthogonal axes (migration 010) that replaced v1's opaque `trigger_rules` JSON + `response_scope` enum. `engage_mode='pattern'` requires `engage_pattern` (`'.'` matches every message — the "always respond" flavor); `sender_scope='known'` restricts engagement to group members; `ignored_message_policy='accumulate'` keeps ignored messages as context instead of dropping them.
- **Side effect:** creating a wiring must also populate `agent_destinations` — don't mutate one without the other (see §1.10).
### 1.4 `users`
@@ -323,6 +327,71 @@ CREATE TABLE container_configs (
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
### 1.16 `pending_sender_approvals`
In-flight state for the `unknown_sender_policy = 'request_approval'` flow. A row exists while an admin-approval card is outstanding for a first-time sender in a wired messaging group; `UNIQUE(messaging_group_id, sender_identity)` dedups concurrent attempts from the same sender instead of spamming the admin with repeat cards.
```sql
CREATE TABLE pending_sender_approvals (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
sender_identity TEXT NOT NULL, -- namespaced user id (channel_type:handle)
sender_name TEXT,
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
approver_user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', -- added by migration 013
options_json TEXT NOT NULL DEFAULT '[]', -- added by migration 013
UNIQUE(messaging_group_id, sender_identity)
);
```
Deleted on admin approve (after adding the sender as a member) or deny.
- Access layer: `src/modules/permissions/db/pending-sender-approvals.ts`
- **Readers/writers:** `src/modules/permissions/sender-approval.ts`, `src/modules/permissions/index.ts`, `src/db/sessions.ts` (`getAskQuestionRender`), `src/cli/resources/groups.ts`
### 1.17 `pending_channel_approvals`
In-flight state for the unknown-channel registration flow. When a channel with no `messaging_group_agents` wiring receives a mention or DM, the router escalates to the owner; `PRIMARY KEY(messaging_group_id)` gives free in-flight dedup via `INSERT OR IGNORE` — a second mention while a card is pending drops silently.
```sql
CREATE TABLE pending_channel_approvals (
messaging_group_id TEXT PRIMARY KEY REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
-- agent the approved wiring will target (earliest
-- agent_group by created_at, picked at request time)
original_message TEXT NOT NULL, -- JSON of the original InboundEvent
approver_user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', -- added by migration 013
options_json TEXT NOT NULL DEFAULT '[]' -- added by migration 013
);
```
Approve creates the `messaging_group_agents` wiring and replays the triggering event; deny sets `messaging_groups.denied_at` so future messages on that channel drop without re-prompting. Either way, this row is deleted.
- Access layer: `src/modules/permissions/db/pending-channel-approvals.ts`
- **Readers/writers:** `src/modules/permissions/channel-approval.ts`, `src/modules/permissions/index.ts`, `src/router.ts`, `src/db/sessions.ts` (`getAskQuestionRender`), `src/cli/resources/groups.ts`
### 1.18 `agent_message_policies`
Per-message approval gate on an agent-to-agent connection between two agent groups. No row for a `(from, to)` pair means free flow (no approval required); a row names the `approver` who must sign off on each message.
```sql
CREATE TABLE agent_message_policies (
from_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
to_agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
approver TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (from_agent_group_id, to_agent_group_id)
);
```
- Access layer: `src/modules/agent-to-agent/db/agent-message-policies.ts`
- **Readers/writers:** `src/cli/resources/policies.ts`; approved messages create a row in `pending_approvals` (see §1.11) via the a2a send path.
---
## 2. Migration system
@@ -330,21 +399,33 @@ CREATE TABLE container_configs (
Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMigrations()` in `src/db/migrations/index.ts`. It:
1. Creates `schema_version` if absent.
2. Reads `MAX(version)` — call it `current`.
3. For each migration with `version > current`, executes `up(db)` inside a transaction and appends a `schema_version` row.
2. Reads every already-applied `name` from `schema_version` into a `Set` and filters the `migrations` barrel array down to the ones whose `name` isn't in that set — dedup is by **name**, not by the numeric `version` field.
3. Runs each pending migration's `up(db)` inside a transaction, in the barrel array's literal order (which is *not* sorted by `version`), then inserts a `schema_version` row.
4. The `version` column stored in `schema_version` is **not** the migration's own `version` field — it's `COALESCE(MAX(version), 0) + 1`, i.e. an auto-assigned applied-order number computed at insert time. The `version` field on the `Migration` object is just an ordering hint for humans reading the barrel file; it lets module migrations (installed later by skills) pick arbitrary numbers without coordinating with trunk.
| # | File | Introduces |
|---|------|------------|
| 001 | `001-initial.ts` | Core tables: `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, `user_roles`, `agent_group_members`, `user_dms`, `sessions`, `pending_questions` |
| 002 | `002-chat-sdk-state.ts` | `chat_sdk_kv`, `chat_sdk_subscriptions`, `chat_sdk_locks`, `chat_sdk_lists` |
| 003 | `003-pending-approvals.ts` | `pending_approvals` (session-bound + OneCLI fields) |
| 004 | `004-agent-destinations.ts` | `agent_destinations` + backfill from existing `messaging_group_agents` wirings |
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
A few migrations also set `disableForeignKeys: true` (needed for table recreates — SQLite can't relax a table-level `UNIQUE` without DROP+RENAME, which fails FK integrity checks with live child rows). The runner toggles `PRAGMA foreign_keys` around the transaction and runs `PRAGMA foreign_key_check` inside it, snapshotting pre-existing violations so it only fails on violations the migration itself introduced.
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
Several early migrations were later renamed/retired and replaced by "module" files (their original `name` is retained on the new file so already-migrated DBs don't re-run them):
| Ver. | Name (stored in `schema_version`) | File | Introduces |
|---|---|------|------------|
| 1 | `initial-v2-schema` | `001-initial.ts` | Core tables: `agent_groups`, `messaging_groups`, `messaging_group_agents` (with the original `trigger_rules`/`response_scope` columns — see v10), `users`, `user_roles`, `agent_group_members`, `user_dms`, `sessions`, `pending_questions` |
| 2 | `chat-sdk-state` | `002-chat-sdk-state.ts` | `chat_sdk_kv`, `chat_sdk_subscriptions`, `chat_sdk_locks`, `chat_sdk_lists` |
| 3 | `pending-approvals` | `module-approvals-pending-approvals.ts` | `pending_approvals` (session-bound + OneCLI fields) |
| 4 | `agent-destinations` | `module-agent-to-agent-destinations.ts` | `agent_destinations` + backfill from existing `messaging_group_agents` wirings |
| 7 | `pending-approvals-title-options` | `module-approvals-title-options.ts` | Retroactive `ALTER TABLE pending_approvals` add `title`, `options_json` for DBs that ran migration 3 before its `CREATE TABLE` was edited to include those columns |
| 8 | `dropped-messages` | `008-dropped-messages.ts` | `unregistered_senders` |
| 9 | `drop-pending-credentials` | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 10 | `engage-modes` | `010-engage-modes.ts` | `messaging_group_agents`: add `engage_mode`, `engage_pattern`, `sender_scope`, `ignored_message_policy`; backfill from `trigger_rules`/`response_scope`; drop those two legacy columns (see §1.3) |
| 11 | `pending-sender-approvals` | `011-pending-sender-approvals.ts` | `pending_sender_approvals` (see §1.16) |
| 12 | `channel-registration` | `012-channel-registration.ts` | `messaging_groups.denied_at` + `pending_channel_approvals` (see §1.17) |
| 13 | `approval-render-metadata` | `013-approval-render-metadata.ts` | `title`, `options_json` columns on `pending_channel_approvals` and `pending_sender_approvals` |
| 14 | `container-configs` | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 15 | `cli-scope` | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
| 16 | `messaging-group-instance` | `016-messaging-group-instance.ts` | `messaging_groups` gets an `instance` column (adapter-instance dimension); table recreate (`disableForeignKeys: true`) backfills `instance = channel_type` on every existing row and relaxes the `UNIQUE` to `(channel_type, platform_id, instance)` |
| 17 | `agent-message-policies` | `017-agent-message-policies.ts` | `agent_message_policies` (see §1.18) |
| 18 | `approvals-approver-user-id` | `018-approvals-approver-user-id.ts` | `pending_approvals.approver_user_id` — names a single required approver for a2a message-gate policies |
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
Session DB schemas (`INBOUND_SCHEMA`, `OUTBOUND_SCHEMA`) are **not** versioned here. They're `CREATE TABLE IF NOT EXISTS` so new columns land via the session-DB lazy migration helpers (`migrateDeliveredTable()` etc.) when a session file from an older build is reopened. See [db-session.md](db-session.md).
+21 -1
View File
@@ -10,13 +10,15 @@ Schemas live in `src/db/schema.ts` as the `INBOUND_SCHEMA` and `OUTBOUND_SCHEMA`
```
data/v2-sessions/<agent_group_id>/<session_id>/
inbound.db ← host writes, container reads (read-only mount)
inbound.db ← host writes, container reads (read-only open)
outbound.db ← container writes, host reads (read-only open)
.heartbeat ← mtime touched by container (not a DB write)
inbox/<message_id>/ ← user attachments, decoded from inbound message content
outbox/<message_id>/ ← attachments the agent produced
```
The session directory itself is mounted read-write into the container (`src/container-runner.ts`) — read-only is *not* a mount property. The container opens `inbound.db` with `{ readonly: true }` at the SQLite connection layer (`container/agent-runner/src/db/connection.ts`), so the container could technically write to the underlying file via another path, but every code path that touches `inbound.db` from inside the container goes through that read-only handle.
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
@@ -177,6 +179,24 @@ CREATE TABLE session_state (
Access: `container/agent-runner/src/db/session-state.ts`.
### 4.4 `container_state`
Single-row (`id=1`) tool-in-flight tracker. The container records the currently-running tool on `PreToolUse` and clears it on `PostToolUse`/`PostToolUseFailure`; the host reads it during the stale-container sweep to widen its stuck-tolerance window when `Bash` is running with a user-declared `timeout` over the normal threshold, so long-running scripts aren't killed as "stuck".
```sql
CREATE TABLE container_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
current_tool TEXT,
tool_declared_timeout_ms INTEGER,
tool_started_at TEXT,
updated_at TEXT NOT NULL
);
```
- **Writer (container):** `setContainerToolInFlight()` / `clearContainerToolInFlight()` in `container/agent-runner/src/db/connection.ts`, called from the `preToolUseHook` / `postToolUseHook` in `container/agent-runner/src/providers/claude.ts`.
- **Reader (host):** `getContainerState()` in `src/db/session-db.ts`; consumed by the sweep's `bashTimeoutMs()` helper in `src/host-sweep.ts`.
- `CREATE TABLE IF NOT EXISTS` — forward-compatible with `outbound.db` files created before this table existed; `getContainerState()` returns `null` if the table or row is absent.
---
## 5. Schema evolution
+1 -1
View File
@@ -80,7 +80,7 @@ agent_groups (workspace, memory, CLAUDE.md, personality)
↕ many-to-many
messaging_groups (a specific channel/chat/group on a platform)
via
messaging_group_agents (session_mode, trigger_rules, priority)
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority)
```
- **Shared session:** multiple messaging_groups → same agent_group, `session_mode = 'agent-shared'`
+52
View File
@@ -0,0 +1,52 @@
# `ncl tasks` migration
## Detect
If an agent mentions `schedule_task`, `list_tasks`, `update_task`, `cancel_task`, `pause_task`, or `resume_task`, it is using the old scheduling MCP surface.
A subtler symptom of a stale container image: the agent reports a task as scheduled, but `ncl tasks list` shows nothing and the host log has `Unknown system action` — the old image's `schedule_task` call is acknowledged in-container and then dropped by the new host. The fix below (rebuild + restart) resolves it.
## Why
Scheduling moved to `ncl tasks`. New tasks are stored in a per-agent-group system session and run there, so a scheduled task does not wake an existing chat session. When it fires, the agent must choose the delivery destination explicitly.
## Fix
Rebuild and restart agent containers so they load the updated MCP tool list and instructions:
```bash
./container/build.sh
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
On Linux, restart with `systemctl --user restart nanoclaw`.
Use:
```bash
ncl tasks list
ncl tasks create --group <agent_group_id> --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
ncl tasks update --id <series_id> --prompt "..."
ncl tasks cancel --id <series_id>
```
## Verify
Run `ncl tasks list`. New task rows should show a system `session_id`, not the chat session that requested the task.
## Legacy tasks (scheduled before this update)
Tasks created through the old MCP tools live in the **chat session** that created them, not in a per-series system session. They are unaffected by this update: they keep firing and delivering exactly as before. Two things to know:
- An agent's own `ncl tasks list` (group scope) shows only its group's task rows; from the **host**, unscoped `ncl tasks list` enumerates everything, and `--session <id>` narrows to one session — that is how you find and manage legacy rows (`ncl tasks cancel --session <chat_session_id> --all` to clear a chat session's tasks).
- The `messages_in` status enum now includes `cancelled` (cancel marks the row and clears its recurrence rather than deleting it). Custom code that exhaustively switches on task status needs the new arm.
## Rollback
Order matters:
1. Remove tasks created through `ncl tasks` (`ncl tasks list` / `delete`) — they live in per-series system sessions the old code doesn't know about.
2. **Wait one sweep (≤60s)** so the host closes the now-empty task sessions.
3. Then revert the update and rebuild the container image.
Reverting before the task sessions are collected leaves system sessions behind that the old `findSessionByAgentGroup` (which has no system-session exclusion) can resolve as the group's session — mis-routing agent-to-agent messages into a dead task thread.
+2 -2
View File
@@ -10,11 +10,11 @@ Find out what is running and what is required:
```bash
cat versions.json # the sanctioned pin
curl -s http://127.0.0.1:10254/api/health # reports the running gateway version
curl -s http://127.0.0.1:10254/api/health # liveness check; `version` field is typically "unknown", not the gateway version
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:10254/v1/health
```
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's in `.env` as `ONECLI_URL` / `NANOCLAW_ONECLI_API_HOST`).
If the last command prints `404`, the server predates the `/v1` API that `@onecli-sh/sdk` 2.x requires — every SDK call will fail with 404s that look transient but are permanent. If your gateway is remote, substitute its host for `127.0.0.1` (it's `ONECLI_URL` in `.env`; `NANOCLAW_ONECLI_API_HOST` is a setup-time override only, not persisted to `.env`).
Why gateways fall behind: the OneCLI installer's docker-compose tracks the `latest` image tag, but Docker never re-pulls a tag — the server freezes at whatever `latest` meant on install day.
+3 -3
View File
@@ -179,7 +179,7 @@ So during this step:
marker.
The level-2 log still gets an entry (`auth [interactive] → success`
with the method — subscription / oauth-token / api-key). Level-3 captures
with the method — subscription / oauth / api). Level-3 captures
are optional here; mirroring `script -q` output is tricky and the risk of
leaking the token to disk outweighs the debugging value.
@@ -190,10 +190,10 @@ leaking the token to disk outweighs the debugging value.
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. `--uninstall` bypasses bootstrap entirely — it execs setup:auto directly (the flow lives in `setup/uninstall/`), or prints manual-cleanup guidance and exits 1 when the TS toolchain is missing. |
| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). |
| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. |
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
| `setup/logs.ts` | The logging primitives (`step`, `userInput`, `complete`, `stepRawLog`, `reset`). Single source of truth for level 2/3 formatting and file paths. |
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
| `setup/channels/telegram.ts` | Telegram channel flow. Installs the adapter in-process by applying the `/add-telegram` skill (directive engine; SKILL.md is the single source of truth), feeding the collected bot token to the skill's `bot_token` prompt var. |
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. |
## Common pitfalls
+5 -5
View File
@@ -14,7 +14,7 @@ Last updated: 2026-04-09
- Container clears stale `processing_ack` entries on startup (crash recovery)
- Files: `src/db/schema.ts` (INBOUND_SCHEMA + OUTBOUND_SCHEMA), `src/session-manager.ts`, `src/delivery.ts`, `src/host-sweep.ts`, `container/agent-runner/src/db/connection.ts`, `messages-in.ts`, `messages-out.ts`, `poll-loop.ts`, `mcp-tools/scheduling.ts`, `mcp-tools/interactive.ts`
- Container image rebuilt with tsconfig (`container/agent-runner/tsconfig.json`)
- E2E verified: host → Docker container → Claude responds → "E2E works!" ✓
- E2E verified: host → Docker container → agent responds → "E2E works!" ✓
### OneCLI Integration
- `ensureAgent()` call added before `applyContainerConfig()` in `src/container-runner.ts`
@@ -65,11 +65,11 @@ Added `session_mode: 'agent-shared'` for cross-channel shared sessions (e.g. Git
### Entity Model
```
agent_groups (id, name, folder, agent_provider, container_config)
↕ many-to-many
messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy)
agent_groups (id, name, folder, agent_provider)
↕ many-to-many (container runtime config lives in the separate container_configs table)
messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, denied_at)
via
messaging_group_agents (messaging_group_id, agent_group_id, trigger_rules, session_mode, priority)
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority)
users (id, kind, display_name) -- namespaced as "<channel>:<handle>"
user_roles (user_id, role, agent_group_id) -- owner / admin (global or scoped)
-646
View File
@@ -1,646 +0,0 @@
# The skill-engine seam: declare/emit vs. acquire/present
Status: SPEC — approved boundary decision, pre-implementation, review findings folded. Branch: `feat/structured-skill-format` (pre-merge; clean break, no compat shims).
## 1. The boundary rule
> **The engine may DECLARE needs and EMIT events; it may never ACQUIRE input or PRESENT anything.**
`scripts/skill-apply.ts` accumulated interactive-setup-wizard concerns in its core
contract: a `Prompter` with `tell`/`confirm`/`open`, authored presentation attrs
(`gate`, `open:`, `min:`, `error:`, `label:`, `on-fail:`), and a `StepReporter`
shaped around a clack spinner. That couples the deterministic applier to one
consumer (the setup wizard) when there are three:
1. **wizard** — interactive setup (`setup/lib/skill-driver.ts` + `setup/channels/run-channel-skill.ts`, clack UI)
2. **agent-relay** — a coding agent driving a skill conversationally over chat
3. **pipeline** — CI/CD & customer deployments: inputs from env, no human, `operatorMessages` consumed as a "manual steps" report
Declaration & semantics (what a value must look like, what a step is, what the
human must be told) = **core**. Acquisition & presentation (how the value is
collected, how the message is rendered, when to pause) = **consumer**. The
rule binds only the core: a driver may define whatever interaction types it
wants on its side of the seam.
### Invariants (non-negotiable)
- **Prose-primary / oblivious-to-auto-apply**: with `nc:` fences stripped, every SKILL.md reads as a normal skill. Never narrate the engine.
- **Degrade-to-agent**: anything the engine can't do bounces to an `agentTask` — never a crash, never a silent drop.
- **Option A split untouched**: no change to any resolve/wire logic; every `platform_id` / `user_id` byte produced by the **production code paths** is identical. The Option-A test (`setup/channels/run-channel-skill.test.ts:24-70`) keeps its assertion structure, but its fixture credentials MUST be updated to valid-shaped values in the same step that lands validate-at-bind (§4): today's `signing_secret: 's'` fails add-slack's `^[a-fA-F0-9]{16,}$` and `owner_handle: 'U1'` fails `^U[A-Z0-9]{8,}$` (both bypass validation only because inputs bypass it today). The `userId` assertion updates consistently (e.g. `owner_handle: 'U12345678'``'slack:U12345678'`). Byte-parity is a claim about production code, not about test-fixture literals shaped to exploit the removed bypass.
- **Coverage parity**: suite is 680 passed | 1 skipped today. Seam-touching tests are reworked *at the seam*; gate/open attr tests become driver-policy tests proving the parity claims in §5.
- **Never stage/commit** the pre-existing unrelated local changes: `package.json`, `pnpm-lock.yaml`, `src/channels/index.ts`, `src/providers/claude.ts`, `src/providers/index.ts` — nor untracked files this work did not create.
## 2. The new core interface
The entire interaction surface of the engine, after the refactor
(`scripts/skill-apply.ts`):
```ts
// What an nc:prompt declares about the value it needs. Passed to resolveInput
// so a consumer can run its OWN re-ask loop (clack validate, a chat exchange).
export interface InputMeta {
question: string; // the prompt body (verbatim)
secret: boolean; // consumer must mask
validate?: string; // regex source (nc:prompt validate:<re>)
flags?: string; // regex flags (nc:prompt flags:<f>)
normalize?: 'trim' | 'rstrip-slash' | 'lower'; // applied by the ENGINE at bind
}
// Everything the engine emits. `onEvent` is AWAITED before the engine
// proceeds — that ordering guarantee is what lets a consumer implement
// gating (hold the operator event until the human confirms readiness).
export type ApplyEvent =
| { type: 'step-start'; kind: string; line: number; label: string | null }
| { type: 'step-end'; kind: string; line: number; label: string | null;
ok: boolean; durationMs: number; error?: string }
| { type: 'operator'; line: number; text: string };
// text = the rendered, {{var}}-substituted block body;
// line = the directive's opening-fence line (keys driver policy maps)
export interface ApplyOptions {
// Pre-supplied answers (var → value). Checked FIRST. Unchanged.
inputs?: Record<string, string>;
// Replaces Prompter.ask. undefined ⇒ defer (unchanged semantics).
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
// Unchanged.
exec?: (cmd: string) => string | void | Promise<string | void>;
// Unchanged.
execStream?: (cmd: string) => Promise<StepOutcome>;
// Unchanged.
skipEffects?: string[];
// Unchanged.
resolveRemote?: (branch: string) => string;
// Replaces BOTH StepReporter and Prompter.tell. Awaited before proceeding.
onEvent?: (e: ApplyEvent) => void | Promise<void>;
}
```
**Gone from the core entirely**: `Prompter` (ask/tell/confirm/open), `PromptOpts`,
`StepReporter`, `ApplyOptions.prompter`, `ApplyOptions.reporter`.
**`ApplyResult` — unchanged fields**: `applied`, `skipped`, `agentTasks`,
`operatorMessages` (still collected in the result — the pipeline reads them
there; the `operator` *event* is for live rendering), `vars`, `journal`,
`referenceProse`. `fullyApplied()` and `firstFailureHint()` unchanged.
Two adjustments:
- `deferred: string[]` — same field, one new entry form: an input rejected by
validate-at-bind is recorded as `` `<var>: invalid value (does not match validate:<re>)` ``
(see §4). Missing-input entries stay the bare var name; unresolved-`{{var}}`
entries stay the thrown message (`skill-apply.ts:841` today).
- `AgentTask.hint` is **dropped** (it existed only for `on-fail:`; with that attr
gone, hint ≡ prose). `firstFailureHint` reads `prose` directly.
**Derived metadata stays core** (exposure, not authored presentation):
`stepLabel` (heading-derived only — the `label:` attr override at
`skill-apply.ts:458` is removed), `AgentTask` prose/`reason` (proseFor-derived),
`firstFailureHint`, `referenceProse`, `operatorMessages`.
**`stepLabel` null semantics, re-documented.** Today's doc comment
(`skill-apply.ts:73-79`, `:442-446`) frames `label: null` as "the driver should
NOT spin on this" — spinner advice, i.e. presentation smuggled into the core.
The contract wording changes (no payload change): `label: null` means *instant/
cheap, or the step renders its own live operator-facing output* (`effect:step`'s
QR card / pairing code). That is step-cost/interactivity **declaration**; the
event carries `kind` + `line`, so a consumer wanting a different render policy
can derive its own.
**Event ordering contract** (normative):
1. Per directive, `step-start` fires immediately before the mutation, `step-end`
immediately after (or on the failure path) — always balanced. The payload is
today's `StepReporter` payload (`skill-apply.ts:80-83` — it already includes
`line`) unchanged, plus only the discriminating `type` field.
2. For an `nc:operator`, the engine substitutes `{{vars}}`, pushes to
`res.operatorMessages`, then `await onEvent({type:'operator', …})` before
evaluating the next directive. An unresolved `{{var}}` in the body defers the
whole block before any event fires (today's behavior, `skill-apply.ts:787`).
**Once the run is `blocked`** (an earlier bounce), operator directives are
skipped instead — no event, no `operatorMessages` entry, recorded in
`skipped`. Walking the human through steps whose side effects the run has
already gated ("a pairing code is about to appear" → nothing appears) is
actively misleading, and a failed run's manual-steps report must not include
steps predicated on the failure.
3. Every `onEvent` call is awaited; a rejection from `onEvent` is treated like
any other throw at that directive (bounce, not crash). This applies to
operator events too: a consumer that throws from `onEvent` **accepts the
bounce consequence**, including the `blocked` latch cascading over later
side effects. The engine itself never defers/bounces an operator block
(open question 7); consequently a well-behaved driver's handler must never
throw for a *declined* confirm — decline semantics are defined in §5.1.
## 3. Migration table
Every existing hook / attr / caller → destination. "core seam" = survives in
`ApplyOptions`/`ApplyResult`; "driver policy" = reimplemented from document
structure (shared policy module + `setup/lib/skill-driver.ts`, §5); "deleted" =
removed with no replacement syntax.
### Engine hooks
| Today | Where (file:line) | Destination |
|---|---|---|
| `ApplyOptions.inputs` | `scripts/skill-apply.ts:263` | core seam (unchanged, but validated — §4) |
| `Prompter.ask(name, question, secret, validate, opts)` | `scripts/skill-apply.ts:50`, called at `:774` | core seam → `resolveInput(name, meta)` |
| `Prompter.tell(text)` | `scripts/skill-apply.ts:54`, called at `:793` | core seam → `onEvent` `operator` event |
| `Prompter.confirm(msg)` | `scripts/skill-apply.ts:58`, called at `:802` (gate; result discarded — decline proceeds today) | driver policy (natural-barrier gating, §5.1) + driver-owned reuse offer — both via the new `RunSkillOptions.confirm` seam (§5.0) |
| `Prompter.open(url)` | `scripts/skill-apply.ts:63`, called at `:796` | driver policy (URL offer, §5.2) via the new `RunSkillOptions.openUrl` seam (§5.0) |
| `StepReporter.stepStart/stepEnd` | `scripts/skill-apply.ts:80-83`, fired at `:827,:830,:839` | core seam → `onEvent` `step-start`/`step-end` events (payload + balance guarantee identical; only `type` added) |
| `ApplyOptions.prompter` | `scripts/skill-apply.ts:266` | deleted (split into `resolveInput` + `onEvent`) |
| `ApplyOptions.reporter` | `scripts/skill-apply.ts:287` | deleted (folded into `onEvent`) |
| `ApplyOptions.exec` / `execStream` | `scripts/skill-apply.ts:269,:275` | core seam (unchanged) |
| `ApplyOptions.skipEffects` | `scripts/skill-apply.ts:279` | core seam (unchanged) |
| `ApplyOptions.resolveRemote` | `scripts/skill-apply.ts:283` | core seam (unchanged) |
| `PromptOpts` (flags/min/error/normalize) | `scripts/skill-apply.ts:37-42`, built at `:499` (`promptOptsOf`) | type deleted; `flags`/`normalize` move into `InputMeta`; `min`/`error` deleted (§ grammar) |
| `normalizeValue` at bind | `scripts/skill-apply.ts:483`, applied `:779` | core seam (unchanged; now paired with validate-at-bind, §4) |
| `stepLabel` | `scripts/skill-apply.ts:457-474` | core seam, minus the `label:` attr branch (`:458`); null semantics re-documented (§2) |
| `failHint` / `AgentTask.hint` | `scripts/skill-apply.ts:419-430`, `:236`, `:749` | deleted (`on-fail:` gone ⇒ hint ≡ prose; `firstFailureHint` at `:308` reads `prose`) |
| run-health gate (`blocked` latch) | `scripts/skill-apply.ts:744-750,:816` | core seam (unchanged) |
| `when:` guard | `scripts/skill-apply.ts:521-525,:762` | core seam (unchanged) |
| `effect:check` / `effect:step` + terminal-block capture | `scripts/skill-apply.ts:646-667` | core seam (unchanged) |
| multi-field JSON capture + validate-on-capture | `scripts/skill-apply.ts:551-572` | core seam (unchanged) |
| journal / `removeSkill` | `scripts/skill-apply.ts:851-870` | core seam (unchanged) |
| `referenceProse` / `operatorMessages` / `vars` in result | `scripts/skill-apply.ts:239-257` | core seam (unchanged) |
### Authored grammar (`scripts/skill-directives.ts`)
| Attr / syntax | Where | Destination |
|---|---|---|
| `nc:operator open:<url>` | grammar doc `:61-73`; lint `:277`, `:287-291`; engine `:791,:796` | **deleted**. Driver URL-offer policy scans the rendered text (§5.2). URLs must live in the prose. |
| `nc:operator gate` | grammar doc `:68-71`; engine `:802` | **deleted**. Driver natural-barrier policy (§5.1). |
| `nc:prompt min:<n>` | grammar doc `:53-54`; lint `:264-266`; driver enforcement `setup/lib/skill-driver.ts:46` | **deleted**. Authors re-encode as regex, e.g. `min:20``validate:^.{20,}$`. |
| `nc:prompt error:<msg>` | grammar doc `:55`; driver `setup/lib/skill-driver.ts:46-47` | **deleted**. Error text derived from the question prose (§5.3). |
| `nc:run label:<word>` | `scripts/skill-apply.ts:458`; doc-comment mention `:451` | **deleted**. Labels are heading-derived only. |
| `on-fail:<token>` | `scripts/skill-apply.ts:419-430` (no lint rule exists) | **deleted**. Hint is always the surrounding prose. |
| `validate:` + `flags:` (prompt & run-capture) | lint `:249-263,:311-317` | **kept** (data semantics). Prompt validate now enforced at bind (§4). |
| `normalize:trim\|rstrip-slash\|lower` | lint `:267-269`; bind `skill-apply.ts:483` | **kept** (canonical-value semantics). |
| `reuse:<ENV_KEY>` | lint `:270-272`; driver `setup/lib/skill-driver.ts:169-172` | **kept** (binding metadata; consumed only by the driver's reuse offer). |
| `when:`, `effect:check`, `effect:step`, capture forms, journal semantics | various | **kept**, unchanged. |
Lint addition: `validate()` gains errors for the six removed attrs
(`operator open:/gate`, `prompt min:/error:`, any-directive `label:`/`on-fail:`)
so stale authorship fails loudly instead of silently no-oping. Lint also gains a
**warning** for an unguarded `nc:operator` immediately followed by `when:`-guarded
directives spanning more than one branch value (the static gate policy cannot
know which branch runs — see §5.1 and open question 1).
### Authored skills carrying removed attrs (strip + prose check)
| Skill | Line | Change |
|---|---|---|
| `.claude/skills/add-teams/SKILL.md` | `:101` | drop `open:https://portal.azure.com` (URL already in the body — step 1, body line 2; body line 1 is the heading sentence) |
| `.claude/skills/add-teams/SKILL.md` | `:132` | `min:20``validate:^.{20,}$` |
| `.claude/skills/add-teams/SKILL.md` | `:173`, `:203` | drop `gate` (policy reproduces both — §5.1 parity) |
| `.claude/skills/add-telegram/SKILL.md` | `:134` | drop `open:https://t.me/{{bot_username}}` **and fold the URL into the body** (verified: body says "Open @{{bot_username}}" — the URL exists only in the attr today) |
| `.claude/skills/add-discord/SKILL.md` | `:125` | drop `open:https://discord.com/...` **and fold the invite URL into the body** (verified: body says "Open the invite link" — URL only in the attr today) |
No skill uses `error:`, `label:`, or `on-fail:` (grep verified). Re-lint every
touched skill (`pnpm exec tsx scripts/skill-directives.ts <dir>`).
### Prompter / reporter implementers & callers (all migrate — clean break)
| Caller / implementer | Where | Migration |
|---|---|---|
| `clackPrompter` (the wizard Prompter) | `setup/lib/skill-driver.ts:66-120` | becomes the driver's `resolveInput` impl (ask + `?` help-escape + clearOnError + secret masking) — `tell`/`confirm`/`open` dissolve into the `onEvent` handler + the `confirm`/`openUrl` seams (§5) |
| `promptValidator` | `setup/lib/skill-driver.ts:37-50` | driver-side; loses `min`/`error`, gains prose-derived message (§5.3) |
| `spinnerReporter` | `setup/lib/skill-driver.ts:259-274` | folded into the driver's `onEvent` handler (step-start/step-end branch), still built on `startSpinner` (`setup/lib/runner.ts:314`) |
| `runSkill` + `RunSkillOptions` | `setup/lib/skill-driver.ts:286-340` | `prompter?`/`reporter?` options become `resolveInput?`/`onEvent?`; **new** `confirm?`/`openUrl?` options (§5.0); `reuse`, `channel`/`step` (help-escape ctx, `:308-314`), `reuseFromEnv` (`:143-188`, now validate-pre-filtered — §5.4) stay driver-side |
| skill-driver CLI | `setup/lib/skill-driver.ts:343-360` | uses the new defaults; no interface change visible to the operator |
| `runChannelSkill` overrides | `setup/channels/run-channel-skill.ts:122` (`prompter`), `:126` (`reporter`) | override fields renamed to `resolveInput`/`onEvent`; `confirm`/`openUrl` passthroughs added; fail-path (`:133-151`) unchanged |
| `applyProviderSkill` defer-all Prompter | `setup/providers/install.ts:62-66` | delete the stub — omit `resolveInput` entirely (absent ⇒ defer, same semantics) |
| `setup/auto.ts` call sites | `:350` (provider), `:560-572` (channels) | no signature change needed (they pass no prompter/reporter) |
| `setup/provider-auth.ts` | `:53` | unchanged (blockers contract survives) |
| engine test fakes | `scripts/skill-apply.test.ts:39` (`headless`), `:447`, `:505`, `:518`, `:534`, `:545`, `:613`, `:637`, `:1219` | `headless(vals)` becomes `{ resolveInput: async (n) => vals[n] }`; tell/open/confirm fakes become recorded `onEvent` handlers or move to driver-policy tests (§9); any fixture whose `inputs` violate a declared `validate:` updates to valid-shaped values (§4) |
| driver test fakes | `setup/lib/skill-driver.test.ts:73`, `:100` (reporter) | mechanical rewrite to `resolveInput`/`onEvent` |
| driver reuse-offer tests | `setup/lib/skill-driver.test.ts:149`, `:167`, `:202` | NOT a mechanical rewrite — today they queue answers through fake `prompter.confirm`s; they migrate to the new `RunSkillOptions.confirm` seam (§5.0) |
| run-channel-skill test stub prompter | `setup/channels/run-channel-skill.test.ts:95-100` (teams gate/open assertions `:115-124`) | becomes a driver-policy assertion running the **default** `onEvent` policy handler with `confirm`/`openUrl` injected (§5.0 injection semantics) — proving the §5.1/§5.2 parity claims. Fixture `app_password: 'sekret'` → a 20+-char value (§4); Option-A slack fixture (`:41-70`) updates `signing_secret`/`owner_handle` + the `userId` assertion (§1 invariant) |
| `back-nav.ts` `backGate`, `claude-handoff.ts` help-escape | `setup/lib/back-nav.ts:31`, `setup/lib/claude-handoff.ts:82,:164,:182` | untouched (already driver-side) |
## 4. Behavior change: validate + normalize apply to EVERY bound value
Today `validate:` is enforced only by the interactive prompter
(`setup/lib/skill-driver.ts:37-50`); `inputs` bypass it
(documented at `scripts/skill-directives.ts:51`). That is data validation
misfiled as prompt UX. New rule, at the single bind point
(`skill-apply.ts:766-780` region):
1. Resolve the raw value: `inputs[var]` first, else `await resolveInput(var, meta)`.
Both `undefined` ⇒ defer (push bare var name — unchanged).
2. Apply `normalize:` (unchanged, already both-paths — `normalizeValue`, `:483`).
3. **New:** if the prompt carries `validate:` (+ `flags:`), test the *normalized*
value. On mismatch: the var stays **unbound**, and
`` `<var>: invalid value (does not match validate:<re>)` `` is pushed to
`deferred`. Not an agentTask, not a throw — downstream consumers of the var
defer exactly as if the value were never supplied, and `fullyApplied` is
`false`. A pipeline passing a malformed env value fails loudly.
Notes:
- Normalize-then-validate order is normative (a trailing-slash URL is stripped
before the `^https://` check — matches the teams `public_url` authoring).
- An invalid `inputs` value does **not** fall through to `resolveInput` — inputs
win outright, and a caller that pre-supplied a value gets a loud rejection,
never a surprise second acquisition path. The interactive dead-end this could
create for reused `.env` credentials is closed on the driver side instead:
`reuseFromEnv` pre-filters every offer through the prompt's
`normalize`/`validate`/`flags` meta, so a stale credential that no longer
matches the declared shape is **never offered** and the operator is prompted
fresh (§5.4). A caller passing raw `inputs` (pipeline, tests) still fails
loudly — that is the point.
- The interactive re-ask loop moves into the wizard's `resolveInput` (clack
`validate`), so engine-level rejection rarely fires interactively; it is the
backstop for programmatic paths.
- Secret values never appear in the deferred entry (only the var name and the
regex source).
- run-capture `validate:` is unchanged (it already throws → bounces,
`skill-apply.ts:551-572` — a command's output has no human to re-ask).
- **Test-fixture consequence** (part of the step that lands this change): every
in-tree fixture that supplies an `inputs` value violating its prompt's
declared `validate:` must update to a valid-shaped value. Known: the Option-A
slack fixture (`run-channel-skill.test.ts:49``signing_secret`,
`owner_handle`, with the `userId` assertion at `:62` updated consistently)
and the teams deferWire fixture (`:102-107``app_password` vs. the new
`^.{20,}$`). Sweep `scripts/skill-apply.test.ts` fixtures the same way.
## 5. Wizard driver policy (presentation derived from document structure)
### 5.0 Where the policy lives, and the driver's own seams
The policy **logic** is UI-free and shared: a new module
`scripts/skill-policy.ts` beside the parser exports `gatePolicy(md)` (→ map of
operator line → needs-confirm + confirm flavor, §5.1) and `extractOfferUrl(text)`
(§5.2), both built on the shared `parseDirectives`. The wizard driver consumes
it; an agent-relay consumer (§7) imports the same module instead of duplicating
the judgment or dragging in clack. The §9 policy unit tests live at this shared
home.
The wizard driver (`setup/lib/skill-driver.ts`) keeps the clack rendering and
gains two injectable interaction seams on `RunSkillOptions` — these are
*driver* options, allowed by the boundary rule (it binds only the core):
- `confirm?: (message: string) => Promise<boolean>` — used by the reuse offer
(§5.4), the natural-barrier gate (§5.1), and the URL offer (§5.2). Default:
clack `p.confirm`, **TTY-gated exactly like `spinnerReporter`**
(`skill-driver.ts:260`) — non-TTY resolves `true` (proceed), preserving
today's headless-prompter-without-confirm semantics (`skill-apply.ts:802`'s
optional chain). A non-TTY run with full inputs never stalls.
- `openUrl?: (url: string) => Promise<void>` — used by the URL offer. Default:
`setup/lib/browser.ts` `openUrl`, attempted only after a `confirm` yes.
**Injection semantics (normative):** an injected `onEvent` **replaces** the
driver's default policy handler entirely — same rule as today's injected
prompter ("the injector owns its I/O", `skill-driver.ts:311`). Therefore
driver-policy parity tests must run the **default** handler and inject
`confirm`/`openUrl` (the run-channel teams test does exactly this — §3, §9);
injecting `onEvent` to observe policy behavior would only observe itself.
Because the engine awaits `onEvent` (§2), a confirm inside the default handler
blocks the engine — that is the entire gating mechanism.
### 5.1 Natural-barrier gate policy
For each `nc:operator` directive at line L, `gatePolicy` computes
`needsConfirm(L)`:
1. Scan forward through subsequent directives, skipping **only** directives
whose `when:<var>=<value>` guard is **incompatible** with this operator's own
guard — same var, different value. No guard, or an identical guard, is
compatible. (This makes mutually-exclusive branches gate on their *own* next
action: imessage's `when:mode=local` operator at `:111` skips the two
remote-only prompts and gates on the local configure run at `:151`;
whatsapp's `when:auth_method=qr` operator at `:96` skips the pairing-code
operator at `:104` — guard-incompatible — and gates on the qr step at `:114`.)
2. Next compatible directive is another `operator`**no confirm** — the chain's
**last** operator carries the barrier. (Operators are NOT skipped-and-scanned-past:
that would make the earlier block of a chain inherit the later block's barrier
and double-confirm — the exact bug the teams parity table below forbids.)
3. Next compatible directive is a `prompt`**no confirm** (the prompt is the barrier).
4. No such directive (end of document) → **no confirm** (a final handoff block, e.g. teams `:228`).
5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`, `json-merge`) →
**confirm** after rendering. Confirm wording is derived from the barrier's
*flavor* — the next compatible directive's effect: `effect:step`
readiness phrasing (`"Ready? The next step starts immediately."` — the block
describes future action: "a pairing code is about to appear"); anything else
→ completed-work phrasing (`"Done with the steps above? Continue when you're
ready."`). `gatePolicy` returns the flavor with the boolean.
**Decline semantics (normative):** the barrier confirm is a *pause*, not a
branch. A "No"/cancel answer proceeds anyway — matching today's engine, which
discards the gate confirm's result (`skill-apply.ts:802`). The driver's handler
must never throw for a decline (an operator-event throw would bounce + latch
`blocked`, §2.3). A driver MAY upgrade decline to a re-ask loop as pure polish;
it must not abort.
**Known limitation (lint-warned, open question 1):** an *unguarded* operator
followed by guarded directives of more than one branch value keys its barrier
decision off a directive that may be runtime-skipped. No in-tree skill authors
this; the §3 lint warning flags it.
At runtime, on each `operator` event the default handler: renders the clack
note (`p.note(text, 'Do this')`), runs the URL offer (§5.2), then the confirm
if `needsConfirm(line)`.
**Verified parity against today's tree** (re-derived under rules 15 above):
- teams `:80` → prompt `:93`: no confirm. `:101` → prompt `:110`: no confirm.
`:124` → prompt `:132`: no confirm. `:158` → next compatible is **operator**
`:173` (rule 2): **no confirm** — the chain's last block carries the barrier.
`:173``run effect:check` `:186`: **confirm**. `:203``run effect:restart`
`:217`: **confirm**. `:228` → end: no confirm. Exactly the two authored
`gate`s reproduced — behavior identical.
- telegram `:134` (→ `run effect:step` pairing `:142`) **gains** a confirm with
readiness phrasing — this restores the old bespoke flow's readiness pause
that the directive port lost.
- signal `:94` (→ `effect:step` `:105`) and both whatsapp operators (`:96`
guard-skip `:104``effect:step` `:114`; `:104` → guard-skip `:114`
`effect:step` `:117`) gain the same readiness pause before a QR/pairing
appears. whatsapp `:146` → prompt `:155`: no confirm.
- imessage `:111` (`when:mode=local`) → guard-skips `:126`,`:134`,`:137`
`run effect:external` `:151`: **confirm**. `:126` (`when:mode=remote`) →
prompt `:134`: no confirm.
- discord `:125` (→ `run effect:fetch` `:139`, the DM resolve) gains a confirm —
desirable: the DM open fails until the bot is invited (open question 2). Slack's
operators (`:69` → prompt `:80`; `:97` → prompt `:112`) are prompt-followed →
unchanged.
### 5.2 URL offer (replaces `open:`)
On an `operator` event, `extractOfferUrl(text)` scans the **rendered** text for
the first *offerable* URL: matches `/https?:\/\/[^\s)>\]]+/`, then **excludes**
candidates containing `<` or `{{` (template placeholders / unsubstituted vars)
and requires the candidate to parse via `new URL()` with a well-formed host.
Without the exclusion, slack's `:97` block — `https://<your-public-host>/webhook/slack`
— would produce a nonsense "Open https://<your-public-host?" offer (the char
class stops at `>` but not `<`). Slack `:97` is the normative negative fixture.
If an offerable URL is found and the run is interactive:
`confirm("Open <url> in your browser?")` → on yes, `openUrl` (both via the §5.0
seams — TTY-gated, non-TTY skips). Confirm-then-open matches the old bespoke
flows; prose-primary already forces URLs into the text (after the §3 skill
edits), so `open:` was redundant authorship. Order within the handler:
note → URL offer → natural-barrier confirm.
**Full operator-body URL inventory** (the offer scans *every* operator body,
not just ex-`open:` blocks — audited like §5.1):
| Site | URL | Outcome |
|---|---|---|
| teams `:101` (body line 2) | `https://portal.azure.com` | offer — preserves today's `open:` behavior |
| telegram `:134`, discord `:125` (after the §3 fold-into-prose edits) | t.me / invite URL | offer — preserves today's `open:` behavior |
| teams `:158` (body) | `https://portal.azure.com` | **new** offer (no `open:` today) — accepted, same judgment as the discord confirm; open question 3 |
| discord `:70` (body `:72`) | `https://discord.com/developers/applications` | **new** offer — accepted; open question 3 |
| imessage `:126` (body `:128`) | `https://photon.codes` | **new** offer — accepted; open question 3 |
| slack `:97` (body `:99`) | `https://<your-public-host>/webhook/slack` | **excluded** (placeholder) — slack stays offer-free, as today |
| slack `:69` (`api.slack.com/apps`, no scheme), signal `:94` (`sgnl://…`), all other operators | — | no match |
### 5.3 Validation error text (replaces `error:`)
`promptValidator(validate, flags, question)` — on a regex miss the message is
`` `That doesn't match the expected format. ${question}` `` (the full prompt
body, which by authoring convention describes the expected shape, e.g.
"Paste the bot token from BotFather (looks like `123456:ABC-DEF...`)."). No
`min` branch (regex-encoded now), no `error:` override.
### 5.4 Stays driver-side, unchanged in spirit
`clearOnError` secret re-paste, secret masking (`p.password`), the masked reuse
offer (`reuseFromEnv` + its `reuse:` linkage — confirm now via the §5.0 seam,
`skill-driver.ts:327-328` today), the `?` help-escape (channel/step ctx already
threaded via `RunSkillOptions`, `run-channel-skill.ts:130-131`), `backGate`,
spinners (now driven by step events), `hostExec`/`hostExecStream`.
**One behavior addition:** `reuseFromEnv` pre-filters offers through the target
prompt's `normalize`/`validate`/`flags` (parsed from the same directives it
already walks) — an `.env` value that would fail validate-at-bind is silently
not offered, so the operator is prompted fresh instead of hitting a §4
dead-end (`fullyApplied` false → `failWith`). This is the driver-side closure
of the stale-credential path; raw `inputs` remain loud-fail (§4).
## 6. Pipeline consumer contract
- **Inputs from env — convention:** for each prompt var `foo_bar`, read
`NC_INPUT_FOO_BAR` (prefix `NC_INPUT_`, var uppercased). A small helper
`inputsFromEnv(md: string, env = process.env)` (driver-agnostic, may live
beside the engine) parses the skill's prompt vars via `parseDirectives` and
returns the `inputs` record. Var names are case-sensitive in the grammar
(`skill-directives.ts:111`), so uppercasing can collide (`bot_token` vs
`Bot_Token`): `inputsFromEnv` **errors on a collision**. All in-tree vars are
lowercase today; a lint rule requiring lowercase prompt/capture var names is
cheap and makes the mapping bijective (open question 6). No `resolveInput`,
no `onEvent` required (optionally an `onEvent` that logs step events as CI
lines).
- **Run:** `applySkill(skillDir, root, { inputs, exec, execStream?, skipEffects })`
with `skipEffects` per deployment (e.g. `['restart']` when the deploy restarts once).
- **What a pipeline can fully apply (normative boundary):** `inputs` binds
**prompt** vars only — the engine never reads it for `run`/`step` captures
(`skill-apply.ts:773` — prompt branch only), so a pipeline cannot pre-supply
a capture-bound var like `platform_id`. And `effect:step` without a real
`execStream` throws → bounces (`skill-apply.ts:655-656`);
`skipEffects:['step']` avoids the bounce but leaves the step's capture vars
unbound, so downstream consumers defer either way. Consequence: **skills with
an `effect:step` are wizard/relay-only for full application** — today that is
telegram (`:142`), whatsapp (`:114`,`:117`), and signal (`:105`). slack,
discord, teams, and imessage carry no `effect:step` and can go fully green
from env inputs (+ `exec`), with their operator blocks landing in the
manual-steps report. A pipeline-grade `execStream`, or letting `inputs`
pre-bind capture vars (bound var ⇒ capture skipped), would move that
boundary — deliberately out of scope here (open question 10).
- **Consume the result:**
- `res.operatorMessages` → emitted verbatim, numbered, as the "manual steps"
report artifact (the human steps the pipeline cannot do).
- `fullyApplied(res)` gates the job: `false` ⇒ non-zero exit, printing
`res.deferred` (which now includes §4 invalid-input reasons — a malformed
env value is a loud failure) and `firstFailureHint(res)` + each
`agentTask.reason` for real bounces.
- `res.vars` → exported for downstream jobs (e.g. `platform_id` into a wire step).
## 7. Agent-relay consumer sketch
A coding agent driving a skill over chat implements the two seams:
- `resolveInput(name, meta)`: send `meta.question` to the chat; for
`meta.secret`, instruct the user to supply the value out-of-band (or via the
platform's redaction affordance) and never echo it back. Run its own re-ask
loop against `meta.validate`/`meta.flags` conversationally ("that doesn't look
like a bot token — it should start with `xoxb-`"); return the final answer, or
`undefined` if the user says skip (⇒ defer, degrade-to-agent semantics apply
downstream).
- `onEvent(e)`: `operator` → relay the text as a chat message and (because the
engine awaits) hold the return until the user replies "done" when the next
action is side-effecting — importing `gatePolicy` from the shared
`scripts/skill-policy.ts` (§5.0) for the same natural-barrier judgment as the
wizard, with no clack/TTY baggage; or simply always ask.
`step-start`/`step-end` → optional progress messages ("Building… ok, 12s").
- Everything else (`exec`, journal, bounce handling) is identical to the wizard;
the agent reads `agentTasks[].prose` and applies bounced steps itself — which
is exactly the degrade-to-agent path the prose was written for.
## 8. Implementation step plan
Each step must be independently green — `pnpm exec tsc --noEmit` (root) +
`pnpm test` (full vitest suite) + skill lint on every touched skill — and
committable on its own. Core lands before consumers migrate; the old seam is
deleted only after no consumer uses it (transitional coexistence inside the
branch is fine; the *merged* result has no compat layer).
1. **Core: add the new seam (additive) + validate-at-bind.**
`scripts/skill-apply.ts`: add `resolveInput` + `onEvent` + `InputMeta` +
`ApplyEvent`; engine prefers them when present (falls back to
`prompter`/`reporter` if not — temporary); implement validate-at-bind (§4)
and the awaited-event ordering; re-document `stepLabel` null semantics (§2).
**Includes the §4 fixture sweep**: Option-A slack inputs + `userId`
assertion, teams deferWire `app_password`, and any `skill-apply.test.ts`
fixture with shape-violating inputs — updated in this same commit so the
suite is green. New tests: event union payloads + balance,
await-before-proceed ordering (an async `onEvent` that records completion),
`resolveInput` meta contents, validate-at-bind for inputs AND resolveInput
answers (normalize-then-validate, no-fallthrough from invalid inputs to
`resolveInput`, deferred entry format, `fullyApplied` false, secret never in
the entry), onEvent-throw ⇒ bounce (incl. on an operator event).
2. **Shared policy module + wizard driver migration.** New
`scripts/skill-policy.ts`: `gatePolicy(md)` (§5.1 rules incl. operator-chain
termination, guard-compatibility, confirm flavor) + `extractOfferUrl(text)`
(§5.2 incl. placeholder exclusion) — with the parity-table unit tests.
`setup/lib/skill-driver.ts`: `resolveInput` impl (ex-`clackPrompter.ask`,
help-escape intact), default `onEvent` handler (spinner branch from
`spinnerReporter` + operator branch consuming `gatePolicy`/`extractOfferUrl`),
new `confirm`/`openUrl` options with TTY-gated defaults (§5.0), decline =
proceed, `reuseFromEnv` validate-pre-filter (§5.4), §5.3 error text;
`RunSkillOptions.prompter/reporter``resolveInput/onEvent` (+ documented
replacement semantics for injected `onEvent`);
`setup/channels/run-channel-skill.ts` override renames + `confirm`/`openUrl`
passthrough; `setup/providers/install.ts` drops its stub Prompter. Reuse
tests migrate to the `confirm` seam; the teams run-channel test runs the
default handler with injected `confirm`/`openUrl` (§9). After this step no
in-tree caller passes `prompter`/`reporter`.
3. **Core: delete the old seam.** Remove `Prompter`, `PromptOpts`,
`StepReporter`, `ApplyOptions.prompter/reporter`; remove engine handling of
`open:`/`gate` (`:791,:796,:802`), `label:` (`:458`), `on-fail:`+`AgentTask.hint`
(`:419-430,:236`), `min:`/`error:` plumbing (`:499-506`). Rework/delete the
engine tests that asserted removed behavior (§9).
4. **Skills cleanup.** Strip attrs per §3 table; fold the telegram + discord
URLs into their operator prose; teams `min:20``validate:^.{20,}$`.
Re-lint all touched skills; the run-channel teams parity test (now
driver-policy-based from step 2) must still prove both barriers fire and the
portal offer survives the `open:` removal (body URL, §5.2 inventory).
5. **Grammar diet.** `scripts/skill-directives.ts`: drop `min:`/`error:`/`open:`/`gate`
validation + grammar doc; add lint errors rejecting the six removed attrs
and the §3 unguarded-operator/multi-branch warning; rework directive tests.
(Ordered after step 4 so lint never fails on skills still carrying the attrs.)
6. **Sweep + parity audit.** Grep proves zero remaining references to
`Prompter|PromptOpts|StepReporter|on-fail:|label:|min:|error:` (in the seam
sense) and `operator.*(open:|gate)`; container typecheck
(`pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit`) untouched;
full suite count ≥ 680 passed | 1 skipped; Option-A test assertion structure
unmodified (fixture values per §1/§4); production resolve/wire paths
untouched.
## 9. Test-migration notes (per step)
- **Step 1:** all existing tests stay green — the ONLY permitted edits are the
§4 shape-violating input fixtures (Option-A slack `:49`/`:62`, teams deferWire
`:106`, plus any `skill-apply.test.ts` siblings the sweep finds). New
describe blocks in `scripts/skill-apply.test.ts`: `onEvent` events (mirror of
the reporter suite at `:1003-1054`, plus operator events + await-ordering) and
validate-at-bind (extends the PromptOpts suite pattern at `:1189-1242`).
- **Step 2:** `setup/lib/skill-driver.test.ts` fakes (`:73,:100`) rewritten
mechanically to `resolveInput`/`onEvent`; the reuse-offer tests
(`:149,:167,:202`) migrate to the injected `confirm` option (accept /
decline / helper-reuse paths preserved); the `clackPrompter.open`
existence test (`:218-223`) becomes a URL-offer policy test; help-escape tests
(`:225-259`) keep their shape (the escape lives in the driver's `resolveInput`);
`promptValidator` test (`:261-271`) loses min/error, gains prose-derived-message
assertions. **Policy tests at `scripts/skill-policy.ts`'s home** encode the
§5.1 parity table: teams (confirm ×2, **no confirm on the `:158` chain head**
the operator-chain rule's regression fixture), telegram/signal/whatsapp
(readiness-flavor pause), imessage (guard-compatibility), end-of-document;
plus `extractOfferUrl` fixtures: the §5.2 inventory incl. slack's placeholder
as the negative case. `run-channel-skill.test.ts:75-125` (teams) re-asserts
gate-before-manifest + portal-URL-offer by running the **default** `onEvent`
handler with injected `confirm`/`openUrl` (never an injected `onEvent`, which
replaces the policy — §5.0).
- **Step 3:** delete engine tests for removed syntax: `nc:operator open + gate`
(`scripts/skill-apply.test.ts:492-551`), `label:` override + `on-fail:` cases
(`:1064,:1072`, `:1145-1162`); keep their *semantic* siblings (heading labels,
prose hint default, unresolved-var deferral of an operator body). The prompter
threading tests (`:634-645,:1212-1231`) become `InputMeta` assertions.
- **Step 4:** no test-file changes beyond fixtures; skill lint is the gate.
- **Step 5:** `scripts/skill-directives.test.ts`: drop `:268-271` (min) and
`:325-355` (open/gate) as validations-of-supported-syntax; re-add them
inverted (the new lint errors reject the attrs). PromptOpts-parse test
(`:247-261`) drops `min:`.
- **Throughout:** the coverage-parity bar is behavioral, not file-shaped: every
guarantee an old test proved (barrier ordering, open-after-render, balanced
spinner events, hint provenance) must have a successor at its new home.
## 10. Open questions (decisions this spec made that the design left open)
1. **Guard-compatibility in the gate scan (§5.1.1).** The design said "followed
by"; this spec defines *followed by* as skipping `when:`-incompatible
directives so mutually-exclusive branches gate correctly (imessage local,
whatsapp qr/pairing). Alternative (pure document order) would miss imessage's
"stop and wait" and double-gate whatsapp. Two scrutiny points: (a) the
compatibility rule compares only same-var/different-value; different-var
guards are treated as compatible (conservative). (b) An **unguarded**
operator followed by guarded directives of more than one branch value keys
its decision off a directive that may be runtime-skipped (e.g. unguarded
operator → `prompt when:mode=remote``run when:mode=local`: policy says
no-confirm, but at runtime mode=local skips the prompt). No in-tree skill
authors this; §3's lint warning covers it.
2. **Discord gains a confirm** (`add-discord:125``effect:fetch`). The design's
parity list mentioned teams + telegram only; the policy also pauses before
discord's DM resolve. Judged desirable (the fetch fails until the bot is
invited) — but it is a new prompt in an existing flow.
3. **Three new URL offers** (§5.2 inventory): teams `:158` (portal.azure.com),
discord `:70` (developers portal), imessage `:126` (photon.codes) had no
`open:` today and gain an offer because the scan covers every operator body.
Accepted — same judgment as the discord confirm — but they are behavior
changes in existing flows.
4. **Confirm triggers on ALL non-prompt/non-operator directives** (§5.1.5), not
just the engine's dangerous-side-effect set (`restart|step|wire`). Needed for
teams parity (its first gate precedes `effect:check`+`external`). Consequence:
an operator block directly followed by e.g. an `env-set` would also confirm —
no such authoring exists today.
5. **Invalid-input failure shape (§4): deferred, not agentTask.** Chosen because
a bad value is a missing-*valid*-value (re-supply and re-run), not a step an
agent can apply from prose; it also keeps the run-health gate un-tripped so a
re-run with a fixed env var completes. The alternative (bounce) would
cascade-block later side effects. Relatedly: invalid `inputs` do NOT fall
through to `resolveInput` (§4) — the driver's reuse pre-filter (§5.4) is the
interactive recovery path.
6. **Env-input convention `NC_INPUT_<VAR>` (§6)** and that `inputsFromEnv` is a
helper, not an engine feature (inputs stay the only env-agnostic seam). Prefix
bikeshed welcome; the engine never reads `process.env` for inputs.
`inputsFromEnv` errors on an uppercase collision; a lowercase-var lint rule
would make the mapping bijective.
7. **Operator event fires even with no consumer** — with `onEvent` absent the
block is still collected in `operatorMessages` (today's headless behavior,
`skill-apply.test.ts:452-456`). The **engine** never defers/bounces an
operator block on its own; a consumer that *throws* from `onEvent` opts into
the standard bounce path (§2.3) — that is the consumer's choice, not an
engine judgment, and the wizard's default handler never throws (decline =
proceed, §5.1).
8. **`AgentTask.hint` field removal** (vs. keeping it always-equal-to-prose for
result-shape stability). Removal chosen for the clean break; any external
consumer of `hint` (none in-tree) would break.
9. **Teams `min:20` regex** chosen as `^.{20,}$` (any 20+ chars). If the intent
was tighter (Azure secret alphabet), tighten in the skill edit — the seam
doesn't care.
10. **Pipeline boundary for `effect:step` skills (§6).** telegram/whatsapp/signal
cannot go fully green in a pipeline (no human at the QR/pairing step, and
`inputs` cannot pre-bind capture vars). Two possible future escapes — a
pipeline-grade `execStream` contract, or `inputs` pre-binding capture vars
(bound var ⇒ capture skipped) — both deliberately out of scope; the spec'd
behavior is the loud `fullyApplied:false`.
11. **`step-end` on validate-at-bind rejection:** none — prompts never emitted
step events (they are not mutations) and still don't. Only the deferred entry
records the rejection. If wizards want live feedback, their own `resolveInput`
loop already provided it.
## 11. Rejected review findings
- **"`run-channel-skill.ts:122` is `exec`; the `prompter` passthrough is at `:123`"**
(review 1, citation sweep) — rejected: `grep -n` shows `:121 exec: overrides.exec,`
and `:122 prompter: overrides.prompter,`. The spec's `:122` citation was
correct as written. (The same review's other three citation fixes —
`resolveRemote :283`, teams `:101` body-line wording, `label:` doc mention
`:451` — were verified correct and are folded.)
+9 -15
View File
@@ -2,27 +2,21 @@
A **template** is a reusable folder you stamp into a working agent group: it
carries the agent's standing instructions, its MCP tool servers, and its skills,
but **no secrets and no provider**. Point `ncl` (or the setup wizard) at one and
but **no secrets and no provider**. Point `ncl` at one and
you get a configured agent in seconds; you choose the runtime/provider
separately.
Templates are purely additive: no DB migration, no new dependency. **At runtime,
templates are resolved only from a local directory**: `templates/` at the
Templates are purely additive: no DB migration, no new dependency. **Templates
are resolved only from a local directory**: `templates/` at the
project root by default (committed but shipped empty), or whatever
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The setup wizard can also
discover templates from the public registry
`NANOCLAW_TEMPLATES_DIR` points at (a local path only). The public registry
([`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates))
and copy a chosen one into your local `templates/` before stamping.
is a manual copy source — clone or download it yourself and copy the chosen
template into your local `templates/` before stamping.
## Using a template
**During install.** `bash nanoclaw.sh` opens the setup wizard. Choose **Template
setup**, then either **NanoClaw template library** (clones the public registry,
copies the template you pick into your local `templates/`) or **Local templates**
(lists what's already in `templates/`). The normal auth step then picks the
runtime, and the wizard stamps and wires your first agent.
**Anytime, via the CLI:**
**Via the CLI:**
```bash
ncl groups create --template sales/sdr --name "SDR Agent"
@@ -40,8 +34,8 @@ e.g. `sales/sdr` → `templates/sales/sdr`.
For safety the ref must stay inside the templates directory: absolute paths, a
leading `~`, and `../` escapes are rejected. There is no `--source`, no git URL,
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, or via
the setup wizard's library option), then stamp.
and no remote fetch at `ncl` time. Populate `templates/` first (by hand, e.g.
copying from the public registry), then stamp.
`NANOCLAW_TEMPLATES_DIR` may point the library at another **local** directory; it
is never a URL and never changes at runtime.
+1 -1
View File
@@ -80,7 +80,7 @@ Tasks can exist before a session is awake — the host sweep creates/wakes the c
**v2:** OneCLI Agent Vault. A separate local service at `http://127.0.0.1:10254` holds secrets. Agents are *scoped* to specific secrets and the vault injects them into approved API requests as they leave the container. The container never sees the raw secret value.
Gotcha: auto-created agents default to `selective` secret mode — no secrets attached, even if matching secrets exist in the vault. See the "auto-created agents start in selective secret mode" section of the root CLAUDE.md for the fix (`onecli agents set-secret-mode --mode all`).
Note: auto-created agents default to `all` secret mode — every vault secret whose host pattern matches is injected automatically. See the "Secret modes" section of the root CLAUDE.md if you want per-agent control (`onecli agents set-secret-mode --mode selective`).
**What the automated migration does:** copies every v1 `.env` key verbatim into v2 `.env`, never overwriting existing v2 keys. The OneCLI vault migration is a separate step owned by the `/init-onecli` skill, which knows how to pull from `.env`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.38",
"version": "2.1.40",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="208k tokens, 104% of context window">
<title>208k tokens, 104% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="213k tokens, 106% of context window">
<title>213k tokens, 106% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">208k</text>
<text x="71" y="14">208k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">213k</text>
<text x="71" y="14">213k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff
-876
View File
@@ -1,876 +0,0 @@
// The skill application engine — executes `nc:` directives parsed from a SKILL.md.
//
// The agent is always the top-level applier; this engine is the deterministic
// accelerator it delegates to. Anything the engine can't do bounces back to the
// AGENT (which reads the same prose and applies it, the way skills work today) —
// never to the human, and never as a hard abort. The human is in the loop only
// for `prompt` inputs and `operator` instructions — the parts addressed to the
// human (e.g. clicking through the Slack UI), which the agent relays.
//
// Phases (the F2 runtime contract, minimal form):
// 1. parse + validate — lint; a malformed skill never reaches apply
// 2. PLAN — per directive: skip|apply|needs-input|agent — no writes
// 3. acquire inputs — resolve every `prompt` via `inputs` / `resolveInput`
// 4. mutate — copy/append/env-set, journaled + idempotent
// 5. run — build/test/fetch (+ dep install) via injected exec
// Remove is derived from the journal — no hand-written REMOVE.md.
//
// Inputs + `resolveInput` make one engine serve three contexts:
// • programmatic → pass `inputs` (var→value); no resolver, runs through fully
// • setup flow → an interactive `resolveInput` collects anything left
// • recipe rebuild → headless: no answer for a prompt ⇒ it (and its consumers) defer
//
// Usage: pnpm exec tsx scripts/skill-apply.ts <skillDir> # plan (no writes)
import { execSync } from 'node:child_process';
import { readFileSync, existsSync, writeFileSync, appendFileSync, copyFileSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { parseDirectives, promptVar, type Directive } from './skill-directives.js';
// What an `nc:prompt` DECLARES about the value it needs — the core seam's input
// contract, passed to `resolveInput` so a consumer can run its OWN re-ask loop
// (clack validate, a chat exchange). Declaration only: how the value is
// ACQUIRED (a masked TTY prompt, a chat message) is the consumer's business.
export interface InputMeta {
question: string; // the prompt body (verbatim)
secret: boolean; // consumer must mask
validate?: string; // regex source (nc:prompt validate:<re>)
flags?: string; // regex flags (nc:prompt flags:<f>)
normalize?: 'trim' | 'rstrip-slash' | 'lower'; // applied by the ENGINE at bind
}
// Everything the engine EMITS — the core seam's output contract. Every
// `onEvent` call is AWAITED before the engine proceeds; that ordering guarantee
// is what lets a consumer implement gating (hold the operator event until the
// human confirms readiness). For step events, `label` is `stepLabel`'s
// declaration: null means the step is instant/cheap, OR it renders its own live
// operator-facing output (an `effect:step` QR card / pairing code) — a
// step-cost/interactivity declaration, not render advice; the event carries
// `kind` + `line`, so a consumer wanting a different render policy can derive
// its own.
export type ApplyEvent =
| { type: 'step-start'; kind: string; line: number; label: string | null }
| { type: 'step-end'; kind: string; line: number; label: string | null; ok: boolean; durationMs: number; error?: string }
| { type: 'operator'; line: number; text: string };
// operator: text = the rendered, {{var}}-substituted block body;
// line = the directive's opening-fence line (keys driver policy maps)
// The result of a streaming `nc:run effect:step`: the spawn's exit success plus
// the terminal status block's fields, which `capture:<var>=<FIELD>` binds.
export interface StepOutcome {
ok: boolean;
fields: Record<string, string>;
}
export type StepStatus = 'skip' | 'apply' | 'needs-input' | 'agent';
export interface PlanStep {
n: number;
kind: string;
line: number;
status: StepStatus;
detail: string;
}
const read = (p: string) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
const has = (root: string, rel: string) => existsSync(join(root, rel));
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const destOf = (line: string) => (line.includes('->') ? line.split('->')[1].trim() : line.trim());
const srcOf = (line: string) => (line.includes('->') ? line.split('->')[0].trim() : line.trim());
function fileHasLine(root: string, rel: string, line: string): boolean {
return read(join(root, rel))
.split('\n')
.some((l) => l.trim() === line.trim());
}
function pkgHasDep(root: string, name: string): boolean {
try {
const pkg = JSON.parse(read(join(root, 'package.json')) || '{}');
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
} catch {
return false;
}
}
function envKeySet(root: string, key: string): boolean {
return read(join(root, '.env'))
.split('\n')
.some((l) => {
const m = l.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
return m !== null && m[1] === key && m[2].trim().length > 0;
});
}
// Does the array-of-objects JSON at `rel` already contain an element whose
// [key] equals `value`? The idempotency probe for json-merge.
function jsonArrayHasKey(root: string, rel: string, key: string, value: unknown): boolean {
try {
const arr = JSON.parse(read(join(root, rel)) || '[]');
return Array.isArray(arr) && arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value);
} catch {
return false;
}
}
// Per-directive idempotency check + "what it would do". Read-only.
function selfStatus(d: Directive, root: string): { status: StepStatus; detail: string } {
switch (d.kind) {
case 'copy': {
const dests = d.body.map(destOf);
const missing = dests.filter((p) => !has(root, p));
const from = d.attrs['from-branch'] ? `fetch ${String(d.attrs['from-branch'])}` : '';
return missing.length
? { status: 'apply', detail: `${from}copy ${missing.join(', ')} (absent)` }
: { status: 'skip', detail: `${dests.join(', ')} present` };
}
case 'append': {
const to = String(d.attrs.to ?? '');
const line = d.body[0] ?? '';
return fileHasLine(root, to, line)
? { status: 'skip', detail: `${to} already has the line` }
: { status: 'apply', detail: `add to ${to}: ${line}` };
}
case 'dep': {
const missing = d.body.filter((s) => !pkgHasDep(root, s.slice(0, s.lastIndexOf('@'))));
return missing.length
? { status: 'apply', detail: `install ${missing.join(', ')}` }
: { status: 'skip', detail: `${d.body.join(', ')} present` };
}
case 'run':
return { status: 'apply', detail: `${String(d.attrs.effect ?? 'run')}: ${d.body.join(' && ')}` };
case 'env-set': {
const keys = d.body.map((l) => l.split('=')[0].trim());
const missing = keys.filter((k) => !envKeySet(root, k));
return missing.length
? { status: 'apply', detail: `set ${missing.join(', ')} in .env` }
: { status: 'skip', detail: `${keys.join(', ')} already set` };
}
case 'json-merge': {
const into = String(d.attrs.into ?? '');
const key = String(d.attrs.key ?? '');
let value: unknown;
try {
value = (JSON.parse(d.body.join('\n')) as Record<string, unknown>)[key];
} catch {
return { status: 'agent', detail: `nc:json-merge body is not parseable JSON — an agent applies it from the prose` };
}
return jsonArrayHasKey(root, into, key, value)
? { status: 'skip', detail: `${into} already has ${key}=${JSON.stringify(value)}` }
: { status: 'apply', detail: `merge ${key}=${JSON.stringify(value)} into ${into}` };
}
case 'prompt':
return { status: 'needs-input', detail: '' };
case 'operator':
return { status: 'apply', detail: `show operator: ${(d.body[0] ?? '').slice(0, 50)}` };
default:
return { status: 'agent', detail: `no deterministic handler for nc:${d.kind} — an agent applies it from the prose` };
}
}
export function planSkill(skillDir: string, root: string): { steps: PlanStep[]; needsInput: string[]; agentSteps: number } {
const directives = parseDirectives(read(join(skillDir, 'SKILL.md')));
const self = directives.map((d) => ({ d, ...selfStatus(d, root) }));
const consumers = new Map<string, number[]>();
self.forEach(({ d }, i) => {
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) (consumers.get(m[1]) ?? consumers.set(m[1], []).get(m[1])!).push(i);
});
const steps: PlanStep[] = self.map(({ d, status, detail }, i) => {
if (d.kind !== 'prompt') return { n: i + 1, kind: d.kind, line: d.line, status, detail };
const v = promptVar(d) ?? '?';
const tag = `${v}${d.args.includes('secret') ? ' (secret)' : ''}`;
const cons = consumers.get(v) ?? [];
const satisfied = cons.length > 0 && cons.every((j) => self[j].status === 'skip');
return satisfied
? { n: i + 1, kind: d.kind, line: d.line, status: 'skip', detail: `${tag} — consumers already satisfied` }
: { n: i + 1, kind: d.kind, line: d.line, status: 'needs-input', detail: `${tag} → asked during apply` };
});
return {
steps,
needsInput: steps.filter((s) => s.status === 'needs-input').map((s) => s.detail.split(' ')[0]),
agentSteps: steps.filter((s) => s.status === 'agent').length,
};
}
// ---------------------------------------------------------------------------
// Apply (phases 35) + journal-derived remove.
// ---------------------------------------------------------------------------
export type JournalEntry =
| { op: 'wrote'; path: string }
| { op: 'appended'; path: string; line: string }
| { op: 'set-env'; key: string }
| { op: 'json-merge'; path: string; key: string; value: unknown }
| { op: 'ran'; cmd: string; undo?: string };
export interface AgentTask {
kind: string;
line: number;
reason: string;
prose: string; // the surrounding prose the agent reads to apply the step
}
export interface ApplyResult {
applied: string[];
skipped: string[];
deferred: string[]; // prompt vars / blocked consumers with no value yet
agentTasks: AgentTask[]; // bounced to an agent — NOT the human
operatorMessages: string[]; // `nc:operator` bodies to relay to the human operator
// Non-secret resolved values (prompt answers + `run capture:<var>` outputs) so
// a caller can read what the skill produced — e.g. a channel skill resolves
// `owner_handle` + `platform_id`, the setup flow reads them to wire the agent.
vars: Record<string, string>;
journal: JournalEntry[];
// The skill's author-written REFERENCE floor — its `## Alternatives`,
// `## Optional configuration`, and `## Troubleshooting` sections, sliced
// verbatim from the RAW markdown (see `referenceProse`). The driver surfaces
// this beside the agentTasks on a bounce: the same prose a human reader would
// scroll to when a step doesn't apply cleanly. Sliced on the author headings,
// never the resolved {{var}} map, so a resolved {{secret}} can never leak in.
referenceProse: string;
}
export interface ApplyOptions {
// Pre-supplied answers for `prompt` vars (var name → value). Checked FIRST, so
// a caller that has every answer needs no resolver at all and the whole skill
// runs through with no human interaction (fully programmatic apply).
inputs?: Record<string, string>;
// The core input seam: resolve a prompt var the caller didn't pre-supply.
// `meta` carries the declared semantics (question, secret,
// validate/flags/normalize) so a consumer can run its OWN re-ask loop.
// Returning undefined ⇒ defer. Optional — omit it (with full `inputs`) for a
// headless run; a prompt with neither defers.
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
// The core output seam: every engine emission — the step-start/step-end
// brackets and each rendered `nc:operator` block — flows through this one
// handler, and every call is AWAITED before the engine proceeds (that
// ordering is what lets a consumer gate on an operator block). A rejection is
// treated like any other throw at that directive: bounce, never crash — a
// consumer that throws on an operator event accepts the bounce consequence,
// including the `blocked` latch cascading over later side effects. Absent ⇒
// silent; the headless/programmatic apply runs identically.
onEvent?: (e: ApplyEvent) => void | Promise<void>;
// dep/run/branch-fetch; injectable for tests. Returns the command's stdout so
// a `run capture:<var>` can bind it into a {{var}} (the twin of `prompt`).
exec?: (cmd: string) => string | void | Promise<string | void>;
// Streaming exec for `nc:run effect:step`: spawns a long-running, operator-
// interactive step (a pairing code, a QR device-link) that emits
// `=== NANOCLAW SETUP: … ===` status blocks, renders them to the operator live,
// and resolves with the terminal block's fields (bound via capture:<var>=<FIELD>).
// Absent ⇒ a step directive degrades to an agent (runs the step from the prose).
execStream?: (cmd: string) => Promise<StepOutcome>;
// Run effects the CALLER owns and will perform itself — those runs are skipped
// (not executed). e.g. a headless rebuild or a setup that restarts once at the
// end passes ['restart']; applyProviderSkill passes ['build','test'].
skipEffects?: string[];
// Resolve which remote carries a `from-branch` registry branch. Defaults to a
// generic resolver (env override → first remote that has the branch → origin);
// setup injects one that reuses setup/lib/channels-remote.sh for exact parity.
resolveRemote?: (branch: string) => string;
}
/**
* True when a skill applied completely nothing deferred for a missing input and
* nothing bounced to an agent. The check a programmatic caller makes to confirm a
* fully-headless run-through succeeded.
*/
export function fullyApplied(res: ApplyResult): boolean {
return res.deferred.length === 0 && res.agentTasks.length === 0;
}
/**
* The failure diagnosis for the FIRST directive that bounced to an agent, in
* document order: a concise headline (the nearest section heading) plus the
* bounced step's own prose as the hint. The setup driver surfaces this when a
* channel skill doesn't fully apply the prose beside the step that failed
* becomes the operator's failure hint and the Claude-handoff context, instead
* of a generic "couldn't finish" message. Returns undefined when nothing
* bounced (e.g. a headless rebuild only left prompts deferred not a failure).
*/
export function firstFailureHint(res: ApplyResult): { headline: string; hint: string } | undefined {
const first = res.agentTasks[0];
if (!first) return undefined;
const hint = first.prose.trim();
// The concise headline: the nearest `#`-heading the prose carries, stripped of
// its markers; failing that, the first prose line; failing that, the reason.
const lines = first.prose.split('\n').map((l) => l.trim()).filter(Boolean);
const heading = lines.find((l) => l.startsWith('#'));
const headline = heading ? heading.replace(/^#+\s*/, '').trim() : (lines[0] ?? first.reason);
return { headline, hint };
}
// The author-written REFERENCE sections the apply engine ignores entirely:
// `## Alternatives`, `## Optional configuration`, `## Troubleshooting`. Matched
// on the heading text (lowercased), level-2 only.
const REFERENCE_HEADINGS = new Set(['alternatives', 'optional configuration', 'troubleshooting']);
/**
* Slice a skill's reference floor out of its raw markdown the
* `## Alternatives` / `## Optional configuration` / `## Troubleshooting` sections
* the engine never executes. This is the human floor a reader scrolls to (a
* dedicated-number path, optional env knobs, dropped-symptom fixes); the driver
* surfaces it beside the bounced agentTasks so the operator has the same
* reference. Returned VERBATIM from the author text keyed on the headings never
* from the resolved {{var}} map so a resolved {{secret}} can never leak into it
* (a `{{token}}` placeholder, if a reference section ever wrote one, stays a
* literal placeholder). Any stray `nc:` directive fence inside a section is
* dropped: reference prose is plain bash/json/text only an `nc:` block belongs
* under Apply, never here. Fence state is tracked so a `# comment` line inside a
* code block is never mistaken for a markdown heading that would end the slice.
*/
export function referenceProse(md: string): string {
const sections: string[] = [];
let cur: string[] | null = null; // lines of the section being collected, or null
let fence: string | null = null; // open fence's info-string ('' for a bare fence), or null
const keep = (line: string): void => {
// Inside (or toggling) an `nc:` fence ⇒ drop; otherwise collect when capturing.
if (cur && !(fence ?? '').startsWith('nc:')) cur.push(line);
};
for (const line of md.split('\n')) {
if (line.startsWith('```')) {
if (fence === null) {
fence = line.slice(3).trim();
keep(line);
} else {
keep(line); // closing fence — `fence` still holds the opening info-string
fence = null;
}
continue;
}
if (fence !== null) { keep(line); continue; } // fence body
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
const level = h[1].length;
const text = h[2].trim().toLowerCase();
if (level === 2 && REFERENCE_HEADINGS.has(text)) {
if (cur) sections.push(cur.join('\n').trim());
cur = [line]; // open a new reference section
} else if (level <= 2) {
if (cur) { sections.push(cur.join('\n').trim()); cur = null; } // a non-reference h1/h2 closes the slice
} else if (cur) {
cur.push(line); // a subsection (### …) inside a captured reference section
}
continue;
}
if (cur) cur.push(line);
}
if (cur) sections.push(cur.join('\n').trim());
return sections.filter(Boolean).join('\n\n').trim();
}
// A hardcoded `origin` breaks forks where the registry branch lives on
// `upstream`. Generic mirror of channels-remote.sh: explicit override → the
// first remote that actually has the branch → origin.
function defaultResolveRemote(branch: string, root: string): string {
const override = process.env.NANOCLAW_CHANNELS_REMOTE;
if (override) return override;
const cap = (cmd: string): string => {
try {
return execSync(cmd, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
} catch {
return '';
}
};
const remotes = cap('git remote').split('\n').map((s) => s.trim()).filter(Boolean);
const ordered = remotes.includes('origin') ? ['origin', ...remotes.filter((r) => r !== 'origin')] : remotes;
for (const r of ordered) if (cap(`git ls-remote --heads ${r} ${branch}`).trim()) return r;
return 'origin';
}
// The prose an agent reads when a step degrades: nearest heading + the
// paragraph immediately above the directive fence.
function proseFor(md: string, fenceLine1: number): string {
const lines = md.split('\n');
let i = fenceLine1 - 2;
while (i >= 0 && lines[i].trim() === '') i--;
const para: string[] = [];
while (i >= 0 && lines[i].trim() !== '' && !lines[i].startsWith('#')) para.unshift(lines[i--]);
let heading = '';
for (let h = i; h >= 0; h--) if (lines[h].startsWith('#')) { heading = lines[h]; break; }
return [heading, ...para].filter(Boolean).join('\n').trim();
}
// The nearest `#`-prefixed heading above a fence (the same upward scan proseFor
// uses), stripped of its leading `#`s — a concise caption for a step spinner.
function headingAbove(md: string, fenceLine1: number): string {
const lines = md.split('\n');
for (let h = fenceLine1 - 2; h >= 0; h--) {
if (lines[h].startsWith('#')) return lines[h].replace(/^#+\s*/, '').trim();
}
return '';
}
// The run effects worth a spinner — the slow, operator-waits-on-it ones.
// `effect:step` is deliberately absent: it renders its own live operator output
// (a QR card, a pairing code) that a concurrent spinner would clobber, so it
// stays unlabelled (null) like the instant kinds.
const SPIN_EFFECTS = new Set(['build', 'test', 'fetch', 'wire', 'restart', 'external']);
/**
* The human caption a consumer may show for a step. `null` is a DECLARATION,
* not render advice: the step is instant/cheap (a local file copy, an env
* write, a json-merge), or it renders its own live operator-facing output
* (`effect:step`'s QR card / pairing code) the step event still carries
* `kind` + `line`, so a consumer wanting a different render policy can derive
* its own. Labels are HEADING-DERIVED only: the caption is the nearest heading
* above the directive (so a consumer's progress line reads like the section
* it's in), falling back to a kind/effect default.
*/
export function stepLabel(d: Directive, md: string): string | null {
const effect = typeof d.attrs.effect === 'string' ? d.attrs.effect : undefined;
const spins =
d.kind === 'dep' ||
(d.kind === 'copy' && typeof d.attrs['from-branch'] === 'string') ||
(d.kind === 'run' && (effect === undefined || SPIN_EFFECTS.has(effect)));
if (!spins) return null;
const heading = headingAbove(md, d.line);
if (heading) return heading;
if (d.kind === 'dep') return 'Installing dependencies';
if (d.kind === 'copy') return 'Fetching files';
const byEffect: Record<string, string> = {
build: 'Building', test: 'Testing', fetch: 'Fetching',
wire: 'Wiring', restart: 'Restarting', external: 'Running',
};
return (effect && byEffect[effect]) || 'Running';
}
// Deterministic input normalization applied AT BIND to every prompt value —
// `inputs` AND interactive answers alike — driven by `nc:prompt normalize:<how>`:
// trim strip leading/trailing whitespace
// rstrip-slash drop trailing slash(es) — a base URL with no trailing path
// lower lowercase
// Absent/unknown ⇒ a no-op (lint gates the known set). Doing it here, not in the
// consumer, means a programmatic `inputs` value and a typed answer land identically.
// Exported so the driver's reuse-offer pre-filter (§5.4) tests an `.env` value
// against the SAME normalize-then-validate the engine will apply at bind.
export function normalizeValue(value: string, normalize: string | undefined): string {
switch (normalize) {
case 'trim':
return value.trim();
case 'rstrip-slash':
return value.replace(/\/+$/, '');
case 'lower':
return value.toLowerCase();
default:
return value;
}
}
// The engine-applied normalize transforms (see `normalizeValue`) — the set
// InputMeta.normalize narrows to. Lint gates authorship to these; an unknown
// value simply isn't declared in the meta (and normalizeValue no-ops on it).
const NORMALIZE_KINDS: ReadonlySet<string> = new Set(['trim', 'rstrip-slash', 'lower']);
// The InputMeta an `nc:prompt` declares — handed to `resolveInput` so a
// consumer can run its own re-ask loop against the same semantics the engine
// enforces at bind. The attrs live on the directive fence, so they're stripped
// along with the fence when a skill degrades to prose — invisible to the agent.
function inputMetaOf(d: Directive, secret: boolean, validate: string | undefined): InputMeta {
const meta: InputMeta = { question: d.body.join('\n'), secret };
if (validate !== undefined) meta.validate = validate;
if (typeof d.attrs.flags === 'string') meta.flags = d.attrs.flags;
if (typeof d.attrs.normalize === 'string' && NORMALIZE_KINDS.has(d.attrs.normalize)) {
meta.normalize = d.attrs.normalize as InputMeta['normalize'];
}
return meta;
}
function substitute(value: string, vars: Map<string, { value: string; secret: boolean }>): string {
return value.replace(VAR_REF, (_, name) => {
const v = vars.get(name);
if (!v) throw new Error(`unresolved {{${name}}}`);
return v.value;
});
}
// A `when:<var>=<value>` guard: the directive applies only when an earlier
// prompt/capture bound <var> to exactly <value>. Unmet — including the var still
// unresolved (a deferred prompt) — skips the directive, so a guarded prompt is
// skipped, never deferred. This is how a skill expresses mutually-exclusive
// branches (e.g. local vs remote install mode) in plain document order.
function whenMet(when: string, vars: Map<string, { value: string; secret: boolean }>): boolean {
const eq = when.indexOf('=');
if (eq < 1) return true; // malformed → don't block (lint is the gate)
return vars.get(when.slice(0, eq).trim())?.value === when.slice(eq + 1).trim();
}
// Resolve a jq-style dot-path (`.id`, `.owner.id`) into a parsed JSON value.
// A missing/non-object hop yields undefined — the caller coerces that to ''.
function dotPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.replace(/^\./, '').split('.').filter(Boolean)) {
if (cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
// Bind a `run capture:<spec>` from a command's stdout into one or more {{vars}}.
// • bare `capture:var` → binds the trimmed stdout as-is (unchanged).
// • `capture:a=.x,b=.owner.id` → parses the stdout as JSON and binds each var
// to its dot-path, so ONE API call resolves
// several values (the structured twin of the
// effect:step terminal-block capture — those
// two are distinguished by effect: step reads
// the status block, fetch/external read JSON
// stdout). Unparseable JSON throws → the outer
// catch bounces it to an agent.
// An optional `validate:<re>` is enforced against every bound value; a mismatch
// THROWS so the run bounces to an agent — a command's output has no human to
// re-prompt, so an invalid capture is a real failure, not a re-ask.
function bindCapture(
spec: string,
stdout: string,
validate: string | undefined,
vars: Map<string, { value: string; secret: boolean }>,
): void {
const re = validate ? new RegExp(validate) : undefined;
const set = (name: string, value: string): void => {
if (re && !re.test(value)) throw new Error(`captured ${name}="${value}" does not match validate:${validate}`);
vars.set(name, { value, secret: false });
};
if (!spec.includes('=')) {
set(spec, stdout);
return;
}
const json = JSON.parse(stdout) as unknown; // not JSON → throws → outer catch bounces
for (const pair of spec.split(',')) {
const eq = pair.indexOf('=');
if (eq < 1) continue;
set(pair.slice(0, eq).trim(), String(dotPath(json, pair.slice(eq + 1).trim()) ?? ''));
}
}
// The mutating twin of selfStatus. Records what it did to the journal so remove
// is derivable. Throws on failure → caught and bounced to an agent.
async function applyOne(
d: Directive,
ctx: { root: string; skillDir: string; exec: (c: string) => string | void | Promise<string | void>; execStream?: (c: string) => Promise<StepOutcome>; resolveRemote: (b: string) => string; vars: Map<string, { value: string; secret: boolean }>; journal: JournalEntry[] },
): Promise<void> {
const { root, skillDir, exec, vars, journal } = ctx;
switch (d.kind) {
case 'copy':
if (d.attrs['from-branch']) {
const b = String(d.attrs['from-branch']);
const remote = ctx.resolveRemote(b);
await exec(`git fetch ${remote} ${b}`);
for (const l of d.body) await exec(`git show ${remote}/${b}:${srcOf(l)} > ${destOf(l)}`);
} else {
for (const l of d.body) {
const dst = join(root, destOf(l));
mkdirSync(dirname(dst), { recursive: true });
copyFileSync(join(skillDir, srcOf(l)), dst);
}
}
for (const l of d.body) journal.push({ op: 'wrote', path: destOf(l) });
break;
case 'append': {
const to = String(d.attrs.to);
const marker = typeof d.attrs.at === 'string' ? d.attrs.at : undefined;
const target = join(root, to);
if (marker) {
// Insert before the `// <<< <marker>` closing line of a dormant marker
// region, matching that line's indentation. removeSkill still deletes
// by line (position-agnostic), so the journal entry is unchanged.
const close = `<<< ${marker}`;
for (const line of d.body) {
const lines = read(target).split('\n');
const idx = lines.findIndex((l) => l.includes(close));
if (idx === -1) throw new Error(`append marker "${marker}" not found in ${to}`);
const indent = lines[idx].match(/^\s*/)?.[0] ?? '';
lines.splice(idx, 0, indent + line);
writeFileSync(target, lines.join('\n'));
journal.push({ op: 'appended', path: to, line });
}
} else {
for (const line of d.body) {
appendFileSync(target, (read(target).endsWith('\n') || read(target) === '' ? '' : '\n') + line + '\n');
journal.push({ op: 'appended', path: to, line });
}
}
break;
}
case 'dep': {
await exec(`pnpm add ${d.body.join(' ')}`);
const names = d.body.map((s) => s.slice(0, s.lastIndexOf('@'))).join(' ');
journal.push({ op: 'ran', cmd: `pnpm add ${d.body.join(' ')}`, undo: `pnpm remove ${names}` });
break;
}
case 'run': {
// `capture:<var>` binds the command's stdout into a {{var}} — the twin of
// `prompt` (which binds human input). Lets a run resolve a value from an
// API (e.g. Slack conversations.open → the DM channel id) and feed it to a
// later directive, so a flow that validates/resolves stays pure directives.
const capture = typeof d.attrs.capture === 'string' ? d.attrs.capture : undefined;
// A `validate:<re>` shape-guard the stdout capture enforces (see bindCapture).
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
// effect:check runs the body as a shell PREDICATE — a precondition gate
// that mutates NOTHING. It pushes no journal entry and binds no capture: a
// zero exit is a silent pass; a non-zero exit throws → the outer catch
// bounces it to an agent (which reads the prose and decides); an unresolved
// {{var}} throws from substitute first → deferred (like any other run, e.g.
// a headless rebuild before the value is collected). Because a bounce here
// latches `blocked`, a failed precondition gates the dangerous side effects
// (a restart, a pairing/QR step, a wire) that follow — a broken local
// config or an un-registered app never reaches a doomed restart/QR.
if (d.attrs.effect === 'check') {
for (const cmd of d.body) await exec(substitute(cmd, vars));
break;
}
// effect:step runs a long-running, operator-interactive step (a pairing
// code, a QR device-link) through the streaming exec and binds the terminal
// status block's named fields via capture:<var>=<FIELD>[,…] — the structured,
// multi-valued twin of stdout capture. No streaming exec ⇒ throw → an agent
// runs the step from the prose (degrade, not crash).
if (d.attrs.effect === 'step') {
if (!ctx.execStream) throw new Error('effect:step needs a streaming exec — an agent runs the step from the prose');
const { ok, fields } = await ctx.execStream(substitute(d.body.join('\n'), vars));
if (!ok) throw new Error('the step did not complete');
if (capture) {
for (const pair of capture.split(',')) {
const eq = pair.indexOf('=');
if (eq < 1) continue;
vars.set(pair.slice(0, eq).trim(), { value: (fields[pair.slice(eq + 1).trim()] ?? '').trim(), secret: false });
}
}
journal.push({ op: 'ran', cmd: d.body.join('\n') });
break;
}
for (const cmd of d.body) {
// Interpolate prompted {{vars}} the same way env-set does, so a run can
// call `ncl ... {{owner_email}}` to wire from collected input. A command
// with no {{...}} (build/test) is returned unchanged; an unresolved var
// throws → caught → deferred (the prompt hasn't been answered yet).
const out = await exec(substitute(cmd, vars));
// Last command wins for capture (a capture run should be a single command).
// bindCapture binds stdout-as-is OR a multi-field JSON spec, and enforces
// validate:<re> — a mismatch / unparseable JSON throws → bounced to an agent.
if (capture) bindCapture(capture, typeof out === 'string' ? out.trim() : '', validate, vars);
// Journal the ORIGINAL command (placeholders intact) — never the
// substituted form — so a secret interpolated into a run never lands in
// the journal (or a remove replay).
const undo = d.attrs.effect === 'external' && typeof d.attrs.remove === 'string' ? d.attrs.remove : undefined;
journal.push({ op: 'ran', cmd, undo });
}
break;
}
case 'env-set': {
const envPath = join(root, '.env');
for (const entry of d.body) {
const eq = entry.indexOf('=');
const key = entry.slice(0, eq).trim();
const value = substitute(entry.slice(eq + 1).trim(), vars); // throws if a {{var}} is unresolved
if (!envKeySet(root, key)) {
appendFileSync(envPath, (read(envPath).endsWith('\n') || read(envPath) === '' ? '' : '\n') + `${key}=${value}\n`);
journal.push({ op: 'set-env', key });
}
}
break;
}
case 'json-merge': {
const into = String(d.attrs.into);
const key = String(d.attrs.key);
const obj = JSON.parse(d.body.join('\n')) as Record<string, unknown>;
const target = join(root, into);
const arr = JSON.parse(read(target) || '[]') as unknown[];
if (!Array.isArray(arr)) throw new Error(`${into} is not a JSON array`);
const value = obj[key];
// Idempotent: only push when no element already matches on the key.
if (!arr.some((el) => el !== null && typeof el === 'object' && (el as Record<string, unknown>)[key] === value)) {
arr.push(obj);
writeFileSync(target, JSON.stringify(arr, null, 2) + '\n');
journal.push({ op: 'json-merge', path: into, key, value });
}
break;
}
default:
throw new Error(`no handler for nc:${d.kind}`);
}
}
export async function applySkill(skillDir: string, root: string, opts: ApplyOptions): Promise<ApplyResult> {
// Lint (validate()) is the authoring/CI gate, run before a skill ships — NOT
// here. Apply is best-effort: an unknown directive (a typo lint should have
// caught, or one newer than this engine) bounces to an agent, never blocks.
const md = read(join(skillDir, 'SKILL.md'));
const directives = parseDirectives(md);
const exec = opts.exec ?? (() => { throw new Error('no exec provided'); });
const resolveRemote = opts.resolveRemote ?? ((b: string) => defaultResolveRemote(b, root));
const vars = new Map<string, { value: string; secret: boolean }>();
const res: ApplyResult = { applied: [], skipped: [], deferred: [], agentTasks: [], operatorMessages: [], vars: {}, journal: [], referenceProse: referenceProse(md) };
// A run-health gate: once ANY directive bounces to an agent, the skill is no
// longer in a known-good state, so the dangerous side effects below must not
// fire on their own — a live restart, an interactive pairing/QR step, or a wire
// launched after an upstream failure just wastes the operator's time (a doomed
// QR, a restart that loads a bad credential). `blocked` latches on the first
// bounce; a later side-effecting run becomes its own bounce so the agent
// finishes it from the prose once the upstream failure is fixed. A DEFERRED
// prompt (headless rebuild, no answer) is not a failure — it never bounces, so
// `blocked` stays false and a later restart remains runnable.
let blocked = false;
const SIDE_EFFECTS = new Set(['restart', 'step', 'wire']);
const bounce = (d: Directive, reason: string) => {
blocked = true;
res.agentTasks.push({ kind: d.kind, line: d.line, reason, prose: proseFor(md, d.line) });
};
for (const d of directives) {
// Tracks an in-flight step so the catch can always close a matching
// step-end (start/end stay balanced even when applyOne throws — a consumer's
// spinner is never orphaned). Set only after step-start fires.
let inFlight: { label: string | null; at: number } | null = null;
try {
// A `when:<var>=<value>` guard that isn't met skips the directive entirely —
// before prompt (so a guarded prompt is skipped, never deferred), operator,
// and run handling. This is how mutually-exclusive branches coexist in one
// skill while a fully-programmatic apply still completes.
if (typeof d.attrs.when === 'string' && !whenMet(d.attrs.when, vars)) {
res.skipped.push(`${d.kind}: when ${d.attrs.when} not met`);
continue;
}
if (d.kind === 'prompt') {
const v = promptVar(d)!;
const secret = d.args.includes('secret');
const validate = typeof d.attrs.validate === 'string' ? d.attrs.validate : undefined;
const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined;
const normalize = typeof d.attrs.normalize === 'string' ? d.attrs.normalize : undefined;
// Pre-supplied inputs win OUTRIGHT (fully-programmatic apply) — an
// invalid `inputs` value never falls through to a second acquisition
// path (validation below rejects it loudly instead). Otherwise resolve
// via `resolveInput`; still undefined ⇒ defer (headless, no answer).
let val = opts.inputs?.[v];
if (val === undefined) val = await opts.resolveInput?.(v, inputMetaOf(d, secret, validate));
if (val === undefined) { res.deferred.push(v); continue; }
// normalize:<how> binds DETERMINISTICALLY for both inputs and answers, so
// an `inputs` value and a typed one land identically (a trailing slash
// stripped, whitespace trimmed) — see normalizeValue.
const bound = normalizeValue(val, normalize);
// Validate-at-bind: `validate:` (+ `flags:`) is DATA validation, enforced
// on the NORMALIZED value no matter where it came from (normalize-then-
// validate is normative: a trailing slash is stripped before an anchor
// check). On a mismatch the var stays UNBOUND and only the var name +
// regex source land in the deferred entry — never the value, so a secret
// can't leak. Not an agentTask, not a throw: downstream consumers defer
// exactly as if the value were never supplied, `fullyApplied` is false,
// and a pipeline passing a malformed env value fails loudly. The
// interactive re-ask loop lives in the consumer's `resolveInput`; this is
// the backstop for programmatic paths.
if (validate !== undefined && !new RegExp(validate, flags).test(bound)) {
res.deferred.push(`${v}: invalid value (does not match validate:${validate})`);
continue;
}
vars.set(v, { value: bound, secret });
continue;
}
if (d.kind === 'operator') {
// Once the run is blocked, walking the human through further manual
// steps is actively misleading — the side effects those instructions
// lead up to ("a pairing code is about to appear") have already been
// gated. Skip: no event (so a consumer's URL offer / readiness confirm
// never fires), no operatorMessages entry (a failed run's manual-steps
// report must not include steps predicated on the failed one).
if (blocked) {
res.skipped.push('operator: skipped after an earlier failure');
continue;
}
// Always collect the human-facing instructions into the result so a
// programmatic caller can relay/output them. {{vars}} render so a
// resolved value can be shown (throws → deferred if a referenced var is
// unset — the whole block defers before any event fires).
const text = substitute(d.body.join('\n'), vars);
res.operatorMessages.push(text);
// The core seam: emit the rendered block and AWAIT the consumer before
// evaluating the next directive — that ordering is what lets a consumer
// gate (hold the event until the human confirms readiness). The engine
// itself never defers/bounces an operator block; a handler that throws
// opts into the standard bounce path via the outer catch (including
// the `blocked` latch over later side effects).
if (opts.onEvent) await opts.onEvent({ type: 'operator', line: d.line, text });
res.applied.push(`operator: ${(d.body[0] ?? '').slice(0, 50)}`);
continue;
}
// A run whose effect the caller owns (e.g. restart) is skipped here.
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && opts.skipEffects?.includes(d.attrs.effect)) {
res.skipped.push(`run ${d.attrs.effect}: owned by the caller`);
continue;
}
// Run-health gate: after an earlier bounce, never fire a dangerous side
// effect (a live restart, an interactive pairing/QR step, a wire) on its
// own — bounce it too so the agent runs it from the prose once the upstream
// failure is fixed. (A deferred prompt did NOT set `blocked`, so this only
// trips on a real failure, never a headless rebuild's missing input.)
if (d.kind === 'run' && typeof d.attrs.effect === 'string' && SIDE_EFFECTS.has(d.attrs.effect) && blocked) {
bounce(d, 'skipped: an earlier step did not complete — run this from the prose after fixing it');
continue;
}
const st = selfStatus(d, root);
if (st.status === 'agent') { bounce(d, 'no deterministic handler'); continue; }
if (st.status === 'skip') { res.skipped.push(`${d.kind}: ${st.detail}`); continue; }
// Bracket the real mutation with step events so a consumer can render
// progress. `label` null is a step-cost/interactivity declaration (see
// `stepLabel`). `inFlight` is set only after step-start fires; the ok:true
// step-end clears it BEFORE its own (awaited) emission, so a consumer
// throw there never double-closes.
const label = stepLabel(d, md);
if (opts.onEvent) await opts.onEvent({ type: 'step-start', kind: d.kind, line: d.line, label });
inFlight = { label, at: Date.now() };
await applyOne(d, { root, skillDir, exec, execStream: opts.execStream, resolveRemote, vars, journal: res.journal });
const durationMs = Date.now() - inFlight.at;
inFlight = null;
if (opts.onEvent) await opts.onEvent({ type: 'step-end', kind: d.kind, line: d.line, label, ok: true, durationMs });
res.applied.push(`${d.kind}: ${st.detail}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Close the step as failed before classifying — keeps step-start/step-end
// balanced whether the throw becomes a deferred (unresolved input) or a
// bounce (a real failure, handled below). The failure-path close is
// best-effort: a consumer that also throws here can't change the outcome —
// we're already on the failure path.
if (inFlight && opts.onEvent) {
const end = { kind: d.kind, line: d.line, label: inFlight.label, ok: false, durationMs: Date.now() - inFlight.at, error: msg };
try { await opts.onEvent({ type: 'step-end', ...end }); } catch { /* already failing — the close is best-effort */ }
}
if (/unresolved \{\{/.test(msg)) res.deferred.push(msg); // blocked on a prompt input
else bounce(d, `engine could not apply (${msg}) — an agent applies it from the prose`);
}
}
// Surface the non-secret resolved values for a caller to consume.
for (const [k, v] of vars) if (!v.secret) res.vars[k] = v.value;
return res;
}
// Remove is the journal played backwards — no hand-written REMOVE.md.
export async function removeSkill(root: string, journal: JournalEntry[], exec?: (c: string) => void | Promise<void>): Promise<void> {
for (const e of [...journal].reverse()) {
if (e.op === 'wrote') rmSync(join(root, e.path), { force: true });
else if (e.op === 'appended') {
const p = join(root, e.path);
writeFileSync(p, read(p).split('\n').filter((l) => l.trim() !== e.line.trim()).join('\n'));
} else if (e.op === 'set-env') {
const p = join(root, '.env');
writeFileSync(p, read(p).split('\n').filter((l) => !l.startsWith(`${e.key}=`)).join('\n'));
} else if (e.op === 'json-merge') {
const p = join(root, e.path);
const arr = JSON.parse(read(p) || '[]') as unknown[];
if (Array.isArray(arr)) {
writeFileSync(p, JSON.stringify(arr.filter((el) => !(el !== null && typeof el === 'object' && (el as Record<string, unknown>)[e.key] === e.value)), null, 2) + '\n');
}
} else if (e.op === 'ran' && e.undo && exec) {
await exec(e.undo);
}
}
}
// CLI — the planner (no writes)
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
const skillDir = process.argv[2];
if (!skillDir) {
console.error('usage: pnpm exec tsx scripts/skill-apply.ts <skillDir>');
process.exit(2);
}
const root = process.cwd();
const { steps, needsInput, agentSteps } = planSkill(skillDir, root);
console.log(`PLAN ${skillDir} project: ${root}\n`);
const icon: Record<StepStatus, string> = { skip: '✓ skip', apply: '→ apply', 'needs-input': '? human', agent: '↳ agent' };
for (const s of steps) console.log(`${String(s.n).padStart(2)}. ${icon[s.status].padEnd(8)} ${s.kind.padEnd(9)} ${s.detail}`);
console.log(`\nneeds human input: ${needsInput.join(', ') || '(none)'} →agent: ${agentSteps}`);
}
-458
View File
@@ -1,458 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseDirectives, validate, promptVar, resolveChatCoreVersion, lintReferenceFloor, lintGateAmbiguity } from './skill-directives.js';
// Guards the structured-directive format against the converted add-slack skill:
// red if the conversion drifts (a directive dropped/renamed) or the parser breaks.
const slack = readFileSync('.claude/skills/add-slack/SKILL.md', 'utf8');
const directives = parseDirectives(slack);
describe('skill-directives parser, on the converted add-slack', () => {
it('extracts every directive in document order — install, credentials, resolve, restart', () => {
expect(directives.map((d) => d.kind)).toEqual([
'copy', // step 1: adapter + test from the channels branch
'append', // step 2: barrel registration
'dep', // step 3: pinned package
'run', // step 4: build
'run', // step 4: test
'prompt', // credentials: socket vs webhook delivery mode
'operator', // credentials: create-app walkthrough, Socket Mode variant
'operator', // credentials: create-app walkthrough, webhook variant
'prompt', // credentials: capture bot token
'prompt', // credentials: capture app-level token (socket only)
'prompt', // credentials: capture signing secret (webhook only)
'env-set', // credentials: bot token (both modes)
'env-set', // credentials: app token — doubles as the Socket Mode switch
'env-set', // credentials: signing secret (webhook only)
'operator', // credentials: event-delivery walkthrough (webhook only)
'prompt', // resolve: owner member id (owner_handle)
'run', // resolve: validate token (auth.test) — fast-fail before the restart
'run', // resolve: DM channel (conversations.open → capture:platform_id)
'run', // restart: load the adapter + creds once the credential is validated
]);
// The wire (owner role, messaging-group, wiring, /welcome) is NOT in the
// skill — it's the shared init-first-agent, called by the setup flow.
expect(directives.some((d) => d.attrs.effect === 'wire')).toBe(false);
});
it('delineates the human UI steps as nc:operator (not agent prose or a run)', () => {
const ops = directives.filter((d) => d.kind === 'operator');
expect(ops).toHaveLength(3);
expect(ops[0].body.join('\n')).toMatch(/Create the Slack app \(Socket Mode\)/);
expect(ops[0].body.join('\n')).toMatch(/connections:write/);
expect(ops[1].body.join('\n')).toMatch(/Create the Slack app \(webhook delivery\)/);
expect(ops[1].body.join('\n')).toMatch(/Bot Token Scopes/);
expect(ops[2].body.join('\n')).toMatch(/Event Subscriptions/);
// the mode branches are guard-delineated, one per delivery mode
expect(ops[0].attrs.when).toBe('connection=socket');
expect(ops[1].attrs.when).toBe('connection=webhook');
expect(ops[2].attrs.when).toBe('connection=webhook');
});
it('reads copy as a branch fetch with both files', () => {
const copy = directives.find((d) => d.kind === 'copy')!;
expect(copy.attrs['from-branch']).toBe('channels');
expect(copy.body).toEqual(['src/channels/slack.ts', 'src/channels/slack-registration.test.ts']);
});
it('reads the barrel append target and line', () => {
const append = directives.find((d) => d.kind === 'append')!;
expect(append.attrs.to).toBe('src/channels/index.ts');
expect(append.body).toEqual(["import './slack.js';"]);
});
it('reads the dependency pinned exactly', () => {
const dep = directives.find((d) => d.kind === 'dep')!;
expect(dep.body).toEqual(['@chat-adapter/slack@4.29.0']);
});
it('tags the runs with their effects', () => {
expect(directives.filter((d) => d.kind === 'run').map((d) => d.attrs.effect)).toEqual([
'build',
'test',
'fetch', // validate: auth.test — credential checked first
'fetch', // resolve: conversations.open
'restart', // load adapter + creds after the credential is validated, before wiring
]);
});
it('captures prompts into named vars — credentials secret, the mode and handle not', () => {
const prompts = directives.filter((d) => d.kind === 'prompt');
expect(prompts.map(promptVar)).toEqual(['connection', 'bot_token', 'app_token', 'signing_secret', 'owner_handle']);
expect(prompts[0].args).not.toContain('secret'); // connection — a mode choice, not a secret
expect(prompts[1].args).toContain('secret'); // bot_token
expect(prompts[2].args).toContain('secret'); // app_token
expect(prompts[3].args).toContain('secret'); // signing_secret
expect(prompts[4].args).not.toContain('secret'); // owner_handle — a plain id, not a secret
// Each mode's credential is guard-scoped to its branch.
expect(prompts[2].attrs.when).toBe('connection=socket');
expect(prompts[3].attrs.when).toBe('connection=webhook');
// The prompt body is the question; it does not mention env at all.
expect(prompts[1].body.join(' ')).toMatch(/Bot User OAuth Token/);
});
it('resolves the conversation address into capture:platform_id (the wire input)', () => {
const runs = directives.filter((d) => d.kind === 'run');
const resolve = runs.find((d) => d.attrs.capture === 'platform_id')!;
expect(resolve).toBeTruthy();
expect(resolve.body.join(' ')).toMatch(/conversations\.open/);
expect(resolve.body.join(' ')).toMatch(/"slack:" \+ \.channel\.id/); // emits the slack:<id> platform_id
});
it('wires the captured variables into env-set via {{var}} references, one per mode', () => {
const envSets = directives.filter((d) => d.kind === 'env-set');
expect(envSets.map((d) => d.body)).toEqual([
['SLACK_BOT_TOKEN={{bot_token}}'],
['SLACK_APP_TOKEN={{app_token}}'],
['SLACK_SIGNING_SECRET={{signing_secret}}'],
]);
expect(envSets[1].attrs.when).toBe('connection=socket');
expect(envSets[2].attrs.when).toBe('connection=webhook');
});
it('passes validation (well-formed, pinned, every {{var}} captured first)', () => {
expect(validate(directives)).toEqual([]);
});
it('keeps its @chat-adapter pin in sync with our chat core (drift guard)', () => {
const chat = resolveChatCoreVersion(process.cwd());
expect(chat).toMatch(/^\d+\.\d+\.\d+/); // our lockfile resolves a real chat version
expect(validate(directives, { chatVersion: chat })).toEqual([]); // add-slack matches it
});
it('ignores plain (non-nc:) code fences so prose stays the floor', () => {
const withProse = slack + '\n```bash\nrm -rf /\n```\n';
expect(parseDirectives(withProse).map((d) => d.kind)).toEqual(directives.map((d) => d.kind));
});
});
describe('validation catches malformed directives', () => {
it('flags an unpinned dependency and an unknown directive', () => {
const md = ['```nc:dep', '@chat-adapter/slack@latest', '```', '', '```nc:frobnicate', 'x', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /exact semver/.test(p.message))).toBe(true);
expect(problems.some((p) => /unknown directive/.test(p.message))).toBe(true);
});
it('flags an env-set that references a variable no prompt captured', () => {
const md = ['```nc:env-set', 'SLACK_BOT_TOKEN={{bot_token}}', '```'].join('\n');
const problems = validate(parseDirectives(md));
expect(problems.some((p) => /\{\{bot_token\}\} but no earlier nc:prompt/.test(p.message))).toBe(true);
});
it('flags a @chat-adapter pin that does not match the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.27.0', '```'].join('\n');
const problems = validate(parseDirectives(md), { chatVersion: '4.26.0' });
expect(problems.some((p) => /must match the chat package/.test(p.message))).toBe(true);
});
it('accepts a @chat-adapter pin that matches the chat core', () => {
const md = ['```nc:dep', '@chat-adapter/slack@4.26.0', '```'].join('\n');
expect(validate(parseDirectives(md), { chatVersion: '4.26.0' })).toEqual([]);
});
});
describe('json-merge directive', () => {
const codex = ['```nc:json-merge into:container/cli-tools.json key:name', '{ "name": "@openai/codex", "version": "0.138.0" }', '```'].join('\n');
it('parses into/key attrs and the JSON object body', () => {
const [d] = parseDirectives(codex);
expect(d.kind).toBe('json-merge');
expect(d.attrs.into).toBe('container/cli-tools.json');
expect(d.attrs.key).toBe('name');
expect(JSON.parse(d.body.join('\n'))).toEqual({ name: '@openai/codex', version: '0.138.0' });
});
it('passes validation when into + key + a parseable object are all present', () => {
expect(validate(parseDirectives(codex))).toEqual([]);
});
it('flags a missing into:', () => {
const md = ['```nc:json-merge key:name', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires into:/.test(p.message))).toBe(true);
});
it('flags a missing key:', () => {
const md = ['```nc:json-merge into:container/cli-tools.json', '{ "name": "x" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /requires key:/.test(p.message))).toBe(true);
});
it('flags an unparseable body', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ not json', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /parseable JSON object/.test(p.message))).toBe(true);
});
it('flags a body that is an array, not a single object', () => {
const md = ['```nc:json-merge into:f.json key:name', '[{ "name": "x" }]', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /single JSON object/.test(p.message))).toBe(true);
});
it('flags a body missing the match key field', () => {
const md = ['```nc:json-merge into:f.json key:name', '{ "version": "1.0.0" }', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /no "name" field/.test(p.message))).toBe(true);
});
});
describe('append at:<marker> attribute', () => {
it('parses an optional at:<marker> alongside to:', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
const [d] = parseDirectives(md);
expect(d.kind).toBe('append');
expect(d.attrs.to).toBe('setup/index.ts');
expect(d.attrs.at).toBe('nanoclaw:setup-steps');
});
it('still validates an append that carries at: (to + a line are all it needs)', () => {
const md = ['```nc:append to:setup/index.ts at:nanoclaw:setup-steps', " codex: () => import('./codex.js'),", '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
describe('retired directives', () => {
it('flags nc:env-sync with a targeted retirement error, not a generic unknown', () => {
const probs = validate(parseDirectives(['```nc:env-sync', '```'].join('\n')));
expect(probs).toHaveLength(1);
expect(probs[0].message).toMatch(/retired/);
expect(probs[0].message).toMatch(/data\/env\/env/);
});
});
describe('when: guard + multi-field capture', () => {
it('parses when: into attrs and lints a guard whose var an earlier prompt defined', () => {
const md = ['```nc:prompt mode', 'local or remote', '```', '```nc:prompt server_url when:mode=remote', 'url', '```'].join('\n');
const ds = parseDirectives(md);
expect(ds[1].attrs.when).toBe('mode=remote');
expect(validate(ds)).toEqual([]);
});
it('flags a when: guard whose var no earlier prompt/capture defined', () => {
const probs = validate(parseDirectives(['```nc:env-set when:mode=remote', 'X=1', '```'].join('\n')));
expect(probs.some((p) => /when:mode=remote references \{\{mode\}\}/.test(p.message))).toBe(true);
});
it('flags a malformed when: with no =', () => {
const md = ['```nc:prompt mode', 'm', '```', '```nc:env-set when:mode', 'X=1', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /when:mode must be <var>=<value>/.test(p.message))).toBe(true);
});
it('registers each capture:<var>=<FIELD> as defined so downstream {{vars}} pass lint', () => {
const md = [
'```nc:run effect:step capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_ID',
'run the step',
'```',
'```nc:env-set',
'P={{platform_id}}',
'O={{owner_handle}}',
'```',
].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('registers each capture:<var>=<dot-path> (JSON multi-field) var as defined for downstream {{vars}}', () => {
const md = [
'```nc:run capture:application_id=.id,public_key=.verify_key,owner_handle=.owner.id effect:fetch',
'curl -sf https://example/app',
'```',
'```nc:env-set',
'APP={{application_id}}',
'PUB={{public_key}}',
'OWN={{owner_handle}}',
'```',
].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('flags an invalid run capture validate:<re> regex', () => {
const md = ['```nc:run capture:app_id=.id effect:fetch validate:^[', 'curl x', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /run validate:.*is not a valid regex/.test(p.message))).toBe(true);
});
it('accepts a valid run capture validate:<re> regex', () => {
const md = ['```nc:run capture:app_id=.id effect:fetch validate:^\\d+$', 'curl x', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
describe('prompt attrs (flags/normalize/reuse)', () => {
it('parses flags/normalize/reuse into attrs alongside the var + secret flag', () => {
const md = [
'```nc:prompt server_url secret validate:^https?:// flags:i normalize:rstrip-slash reuse:IMESSAGE_SERVER_URL',
'URL?',
'```',
].join('\n');
const [d] = parseDirectives(md);
expect(promptVar(d)).toBe('server_url'); // the var, not `secret`
expect(d.attrs.flags).toBe('i');
expect(d.attrs.normalize).toBe('rstrip-slash');
expect(d.attrs.reuse).toBe('IMESSAGE_SERVER_URL');
expect(validate([d])).toEqual([]); // a well-formed prompt with all attrs lints clean
});
it('accepts validate:<re> combined with flags:i (a case-insensitive regex is still valid)', () => {
const md = ['```nc:prompt u validate:^https?:// flags:i', 'URL?', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
it('flags an unknown normalize: value', () => {
const md = ['```nc:prompt u normalize:uppercase', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /normalize:uppercase must be one of/.test(p.message))).toBe(true);
});
it('flags a reuse: that is not a valid ENV_KEY', () => {
const md = ['```nc:prompt u reuse:not-an-env-key', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /reuse:not-an-env-key must be a valid ENV_KEY/.test(p.message))).toBe(true);
});
it('flags illegal regex flags:', () => {
const md = ['```nc:prompt u validate:^x flags:zzz', 'q', '```'].join('\n');
expect(validate(parseDirectives(md)).some((p) => /is not a valid regex/.test(p.message))).toBe(true);
});
});
// lintReferenceFloor is a WARN-ONLY smell check (never an error, never blocks):
// a credentialed (nc:prompt secret) or interactive (nc:run effect:step) skill
// should ship a ## Troubleshooting section — the human floor when a live step
// misbehaves. It warns when that floor is absent and is silent otherwise.
describe('lintReferenceFloor (warn-only reference floor)', () => {
it('warns when a secret-bearing skill has no ## Troubleshooting', () => {
const md = ['```nc:prompt token secret', 'Paste it.', '```'].join('\n');
const warnings = lintReferenceFloor(md);
expect(warnings).toHaveLength(1);
expect(warnings[0].kind).toBe('reference-floor');
expect(warnings[0].message).toMatch(/## Troubleshooting/);
});
it('warns when an interactive effect:step skill has no ## Troubleshooting', () => {
const md = ['```nc:run effect:step capture:platform_id=PLATFORM_ID', 'pair', '```'].join('\n');
expect(lintReferenceFloor(md)).toHaveLength(1);
});
it('is silent once a ## Troubleshooting section is present', () => {
const md = ['```nc:prompt token secret', 'Paste it.', '```', '', '## Troubleshooting', 'Check the logs.'].join('\n');
expect(lintReferenceFloor(md)).toEqual([]);
});
it('is silent for a skill with no secret prompt or effect:step (no floor expected)', () => {
const md = ['```nc:prompt handle', 'Your handle.', '```', '```nc:env-set', 'H={{handle}}', '```'].join('\n');
expect(lintReferenceFloor(md)).toEqual([]);
});
it('never warns on the real credentialed channel skills — they ship a ## Troubleshooting', () => {
for (const ch of ['add-signal', 'add-whatsapp', 'add-teams']) {
const md = readFileSync(`.claude/skills/${ch}/SKILL.md`, 'utf8');
expect(lintReferenceFloor(md)).toEqual([]);
}
});
});
// Grammar diet: the six removed presentation attrs are hard lint ERRORS so
// stale authorship fails loudly instead of silently no-oping. Each error points
// at the attr's replacement (validate: regex, question prose, body prose,
// document structure, the preceding heading, the surrounding prose).
describe('removed presentation attrs are lint errors', () => {
it('rejects operator open: — the URL belongs in the body prose', () => {
const md = ['```nc:operator open:https://portal.azure.com', 'Visit https://portal.azure.com and click through.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /operator open: was removed — put the URL in the body prose/.test(p.message))).toBe(true);
});
it('rejects the bare operator gate flag — the barrier is structure-derived', () => {
const md = ['```nc:operator gate', 'Finish the UI steps.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /operator gate was removed — the human barrier is derived from document structure/.test(p.message))).toBe(true);
});
it('rejects prompt min: — length is regex-encoded now', () => {
const md = ['```nc:prompt app_password secret min:20', 'Paste the client secret.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /prompt min: was removed — encode the length in validate:/.test(p.message))).toBe(true);
});
it('rejects prompt error: — the miss message derives from the question prose', () => {
const md = ['```nc:prompt token error:bad-token', 'Paste the token.', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /prompt error: was removed — the validation-miss message derives from the question prose/.test(p.message))).toBe(true);
});
it('rejects label: on any directive — labels are heading-derived only', () => {
const md = ['```nc:run effect:build label:build', 'pnpm run build', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /label: was removed — step labels derive from the preceding heading/.test(p.message))).toBe(true);
});
it('rejects on-fail: on any directive — the hint is always the surrounding prose', () => {
const md = ['```nc:run effect:test on-fail:rerun', 'pnpm test', '```'].join('\n');
const probs = validate(parseDirectives(md));
expect(probs.some((p) => /on-fail: was removed — the failure hint is always the surrounding prose/.test(p.message))).toBe(true);
});
it('still accepts a plain operator block (body-only, no attrs)', () => {
const md = ['```nc:prompt bot', 'Bot?', '```', '```nc:operator', 'Open @{{bot}} and press Start.', '```'].join('\n');
expect(validate(parseDirectives(md))).toEqual([]);
});
});
// lintGateAmbiguity is a WARN-ONLY check (never an error, never blocks): an
// UNGUARDED operator followed by when:-guarded directives spanning more than
// one branch value keys its natural-barrier decision off a directive that may
// be runtime-skipped — the static policy cannot know which branch runs.
describe('lintGateAmbiguity (warn-only unguarded-operator/multi-branch)', () => {
const branchy = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator', 'Get ready.', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:run effect:external when:mode=local', './configure.sh', '```',
].join('\n');
it('warns on an unguarded operator followed by guards spanning two branch values', () => {
const warnings = lintGateAmbiguity(parseDirectives(branchy));
expect(warnings).toHaveLength(1);
expect(warnings[0].kind).toBe('gate-ambiguity');
expect(warnings[0].line).toBe(4); // the operator's opening fence
expect(warnings[0].message).toMatch(/mode=remote/);
expect(warnings[0].message).toMatch(/mode=local/);
});
it('the warning is not a validate() error — the skill still lints clean', () => {
expect(validate(parseDirectives(branchy))).toEqual([]);
});
it('is silent when the operator itself is guarded (mutually-exclusive branches gate on their own next action)', () => {
const md = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator when:mode=local', 'Get ready.', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:run effect:external when:mode=local', './configure.sh', '```',
].join('\n');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
});
it('is silent when the following guards all share one branch value', () => {
const md = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator', 'Get ready.', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:prompt api_key when:mode=remote', 'Key?', '```',
].join('\n');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
});
it('stops scanning at the first unguarded directive (it always runs — no ambiguity past it)', () => {
const md = [
'```nc:prompt mode', 'local or remote?', '```',
'```nc:operator', 'Get ready.', '```',
'```nc:run effect:build', 'pnpm run build', '```',
'```nc:prompt server_url when:mode=remote', 'URL?', '```',
'```nc:run effect:external when:mode=local', './configure.sh', '```',
].join('\n');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
});
it('never warns on the in-tree channel skills (none author the pattern)', () => {
for (const ch of ['add-slack', 'add-discord', 'add-telegram', 'add-teams', 'add-whatsapp', 'add-signal', 'add-imessage']) {
const md = readFileSync(`.claude/skills/${ch}/SKILL.md`, 'utf8');
expect(lintGateAmbiguity(parseDirectives(md))).toEqual([]);
}
});
});
-420
View File
@@ -1,420 +0,0 @@
// Extract `nc:` skill directives embedded in a SKILL.md.
//
// A fenced code block whose info-string starts with `nc:` is a load-bearing
// directive; every other fence (and all prose) is the human floor the parser
// ignores. That is the whole "two readers, one document" property: an agent
// applies the prose, a tool applies the directives, and anything the tool
// can't handle degrades to the prose beside it. This is the seed for both the
// conformance linter and the deterministic applier.
//
// Grammar, derived from add-slack:
//
// ```nc:<directive> <arg>... [key:value]...
// <body line>
// ```
//
// `prompt` only *acquires* a value and binds it to a name; a separate directive
// *applies* it, referenced as `{{name}}`. That keeps "ask the human" decoupled
// from "what you do with the answer" (env, ncl, the OneCLI vault, a file).
//
// copy [from-branch:<b>] body: `PATH` (src==dst) or `SRC -> DST` overwrite
// append to:<file> [at:<marker>] body: line(s) to add skip if present
// dep [manager:pnpm] body: `pkg@<exact-semver>` line(s) reinstall no-op
// run [effect:build|test|fetch|external|wire|restart|step|check] [capture:<spec>] re-runnable
// body: shell command(s). {{vars}} are substituted in. effect:wire runs
// `ncl …` to wire collected input (no undo — the rows it creates are user
// runtime data, not reversed on skill remove). effect:restart restarts the
// service so following `ncl` runs reach it; a caller that owns the restart
// (rebuild, or a setup that restarts once) skips it via ApplyOptions.
// skipEffects. capture:<var> binds the command's stdout into {{var}} (twin
// of prompt) — e.g. resolve an id from an API and feed it to a later step.
// capture:<var>=<dot-path>[,<var2>=<dot-path2>…] parses the stdout as JSON
// and binds each var to its jq-style dot-path (.id, .owner.id), so ONE API
// call resolves several values at once. validate:<re> shape-guards each
// captured value (e.g. validate:^discord:); a mismatch bounces the run to
// an agent (a command's output has no human to re-prompt — unlike prompt).
// effect:step runs a long-running, operator-interactive step (a pairing
// code, a QR device-link) through the streaming exec: its
// `=== NANOCLAW SETUP: … ===` status blocks render to the operator live and
// `capture:<var>=<FIELD>[,<var2>=<FIELD2>…]` binds the terminal block's
// named fields into {{vars}} (multi-valued, structured twin of stdout
// capture). Degrades to an agent when no streaming exec is wired.
// effect:check runs the body as a shell PREDICATE (a precondition gate):
// it mutates nothing (no journal, no capture). A zero exit passes silently;
// a non-zero exit bounces to an agent (degrade, not crash) and, via the
// run-health gate, blocks the dangerous side effects that follow it (a
// restart, a pairing/QR step, a wire). An unresolved {{var}} defers.
// prompt <var> [secret] [validate:<re>] [flags:<re-flags>]
// [normalize:trim|rstrip-slash|lower] [reuse:<ENV_KEY>]
// body: the question → binds {{var}} skip if satisfied
// validate:<re> is a regex enforced AT BIND for EVERY value — `inputs` and
// interactive answers alike (e.g. validate:^xoxb- to require a Slack bot
// token); a mismatch leaves the var unbound and records a deferred entry.
// A minimum length is regex-encoded (e.g. validate:^.{20,}$).
// flags:<re-flags> are regex flags applied to validate (e.g. flags:i → a
// case-insensitive scheme match). normalize:<how> deterministically
// transforms the value AT BIND, before validate — for BOTH `inputs` and
// interactive answers — one of trim | rstrip-slash | lower.
// reuse:<ENV_KEY> lets a re-run offer an existing .env value for a credential
// a HELPER SCRIPT owns (written by effect:external, not nc:env-set) — the
// masked reuse offer the env-set→ENV_KEY inference can't otherwise see.
// operator body: instructions for the human operator output-only
// The SKILL.md is addressed to the coding agent; `operator` delineates the
// parts meant for the HUMAN (e.g. clicking through the Slack UI). Lead it
// with agent-facing prose like "Tell the user:" so the agent relays it;
// the engine renders the body to the operator ({{vars}} substituted in).
// The block carries NO presentation attrs: a URL to visit lives in the
// body prose (a consumer may offer to open it), and whether a consumer
// pauses for confirmation before the next side effect is derived from
// document structure (scripts/skill-policy.ts), never authored here.
// env-set body: `KEY=value` ({{var}} allowed) set-if-absent
// json-merge into:<file> key:<field> body: a JSON object push-if-absent
//
// `append` without `at:` adds to EOF; with `at:<marker>` it inserts before the
// `// <<< <marker>` closing line of a dormant marker region (see setup/index.ts).
// `json-merge` reads an array-of-objects JSON file and pushes the body object
// unless an element already has body[key]===element[key] (idempotent by key).
//
// Any directive may carry `when:<var>=<value>` — a guard evaluated against an
// earlier prompt/capture var. If it doesn't match (including the var being
// unresolved), the directive is skipped — a guarded prompt is skipped, never
// deferred — so one skill can express mutually-exclusive branches (e.g. a local
// vs remote install mode) in document order while still running fully
// programmatically from `inputs`.
//
// Removed presentation attrs — `min:`/`error:` on prompt, `open:`/`gate` on
// operator, `label:`/`on-fail:` on any directive — are lint ERRORS, so stale
// authorship fails loudly instead of silently no-oping. Their jobs moved:
// length checks into validate: regexes, miss messages derive from the question
// prose, URLs live in the operator body, gating and step labels derive from
// document structure (scripts/skill-policy.ts / the preceding heading), and the
// failure hint is always the surrounding prose.
//
// Usage: pnpm exec tsx scripts/skill-directives.ts <SKILL.md>
import { readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
export interface Directive {
kind: string;
args: string[]; // positional bare tokens, e.g. prompt's variable name
attrs: Record<string, string | true>; // key:value tokens
body: string[];
line: number; // 1-based line of the opening fence
}
export interface Problem {
line: number;
kind: string;
message: string;
}
const FENCE = /^```(\S.*)?$/;
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const VAR_REF = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
const KNOWN = new Set(['copy', 'append', 'dep', 'run', 'prompt', 'operator', 'env-set', 'json-merge']);
// Retired directives get a targeted lint error (not just "unknown") so an
// author knows the removal was deliberate and what to do instead.
const RETIRED: Record<string, string> = {
'env-sync': 'nc:env-sync was retired — nothing reads the data/env/env mirror (and it copied live tokens); delete the fence, the adapter reads .env directly',
};
const PROMPT_FLAGS = new Set(['secret']);
export function parseDirectives(markdown: string): Directive[] {
const lines = markdown.split('\n');
const out: Directive[] = [];
let i = 0;
while (i < lines.length) {
const info = lines[i].match(FENCE)?.[1]?.trim();
if (info === undefined) {
i++;
continue;
}
// A fence opens here; consume to its closing fence either way.
let j = i + 1;
const body: string[] = [];
while (j < lines.length && !FENCE.test(lines[j])) {
body.push(lines[j]);
j++;
}
if (info.startsWith('nc:')) {
const [tag, ...rest] = info.split(/\s+/);
const args: string[] = [];
const attrs: Record<string, string | true> = {};
for (const tok of rest) {
const eq = tok.indexOf(':');
if (eq > 0) attrs[tok.slice(0, eq)] = tok.slice(eq + 1);
else args.push(tok);
}
out.push({
kind: tag.slice('nc:'.length),
args,
attrs,
body: body.map((l) => l.trim()).filter(Boolean),
line: i + 1,
});
}
i = j + 1; // skip past the closing fence (directive or plain code block)
}
return out;
}
/** The variable a `prompt` binds (the first positional that isn't a flag). */
export function promptVar(d: Directive): string | undefined {
return d.args.find((a) => !PROMPT_FLAGS.has(a));
}
/**
* The variable name(s) a `run capture:<spec>` binds. `capture:dm_channel`
* `['dm_channel']` (stdout form); `capture:platform_id=PLATFORM_ID,owner=ACCOUNT`
* `['platform_id','owner']` (effect:step field form).
*/
export function captureVars(spec: string): string[] {
if (!spec.includes('=')) return [spec];
return spec
.split(',')
.map((pair) => pair.slice(0, pair.indexOf('=')).trim())
.filter(Boolean);
}
/** `{{var}}` names referenced anywhere in a directive's body. */
function referencedVars(d: Directive): string[] {
const found: string[] = [];
for (const line of d.body) for (const m of line.matchAll(VAR_REF)) found.push(m[1]);
return found;
}
/**
* The resolved `chat` core version from our lockfile the single source of
* truth a `@chat-adapter/*` adapter pin must match (the adapter and the core
* move in lockstep). Reads the root importer's direct `chat` dependency, whose
* `specifier`/`version` pair is unique to importer deps (transitive entries in
* the packages section have no `specifier`). Returns undefined if not found.
*/
export function resolveChatCoreVersion(root: string): string | undefined {
let lock = '';
try {
lock = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
} catch {
return undefined;
}
const m = lock.match(/\n\s+chat:\n\s+specifier:[^\n]*\n\s+version:\s*([0-9][^\s(]*)/);
return m?.[1];
}
export function validate(directives: Directive[], ctx?: { chatVersion?: string }): Problem[] {
const problems: Problem[] = [];
const defined = new Set<string>();
const flag = (d: Directive, message: string) => problems.push({ line: d.line, kind: d.kind, message });
for (const d of directives) {
if (RETIRED[d.kind]) flag(d, RETIRED[d.kind]);
else if (!KNOWN.has(d.kind)) flag(d, `unknown directive nc:${d.kind}`);
switch (d.kind) {
case 'dep':
for (const spec of d.body) {
const at = spec.lastIndexOf('@');
const name = at > 0 ? spec.slice(0, at) : spec;
const version = at > 0 ? spec.slice(at + 1) : '';
if (!EXACT_SEMVER.test(version)) flag(d, `dep "${spec}" must pin an exact semver (no ranges/latest)`);
// A @chat-adapter/* adapter must match the chat core version in our
// lockfile — the family moves together. This catches pin drift (the
// 4.27.0-vs-chat@4.26.0 mismatch) at lint time.
if (ctx?.chatVersion && name.startsWith('@chat-adapter/') && version !== ctx.chatVersion) {
flag(d, `${name} pinned ${version} but our chat core is ${ctx.chatVersion} — a @chat-adapter/* adapter must match the chat package`);
}
}
break;
case 'append':
if (!d.attrs.to) flag(d, 'append requires to:<file>');
if (d.body.length === 0) flag(d, 'append requires a line to add');
break;
case 'copy':
if (d.body.length === 0) flag(d, 'copy requires at least one path');
break;
case 'json-merge': {
if (!d.attrs.into) flag(d, 'json-merge requires into:<json-file>');
if (!d.attrs.key) flag(d, 'json-merge requires key:<field>');
if (d.body.length === 0) {
flag(d, 'json-merge requires a JSON object in its body');
} else {
let obj: unknown;
try {
obj = JSON.parse(d.body.join('\n'));
} catch {
flag(d, 'json-merge body must be a single parseable JSON object');
break;
}
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
flag(d, 'json-merge body must be a single JSON object (not an array or scalar)');
} else if (typeof d.attrs.key === 'string' && !(d.attrs.key in obj)) {
flag(d, `json-merge body has no "${d.attrs.key}" field to match on`);
}
}
break;
}
case 'prompt': {
if (!promptVar(d)) flag(d, 'prompt requires a variable name, e.g. `nc:prompt token`');
if (d.body.length === 0) flag(d, 'prompt requires a question in its body');
const flags = typeof d.attrs.flags === 'string' ? d.attrs.flags : undefined;
if (typeof d.attrs.validate === 'string') {
try {
new RegExp(d.attrs.validate, flags);
} catch {
flag(d, `prompt validate:${d.attrs.validate}${flags ? ` flags:${flags}` : ''} is not a valid regex`);
}
} else if (flags !== undefined) {
// flags without validate: still verify they're legal regex flags.
try {
new RegExp('', flags);
} catch {
flag(d, `prompt flags:${flags} are not valid regex flags`);
}
}
// Removed presentation attrs — reject loudly (they would silently no-op).
if (d.attrs.min !== undefined) {
flag(d, 'prompt min: was removed — encode the length in validate:, e.g. min:20 → validate:^.{20,}$');
}
if (d.attrs.error !== undefined) {
flag(d, 'prompt error: was removed — the validation-miss message derives from the question prose');
}
if (typeof d.attrs.normalize === 'string' && !['trim', 'rstrip-slash', 'lower'].includes(d.attrs.normalize)) {
flag(d, `prompt normalize:${d.attrs.normalize} must be one of trim|rstrip-slash|lower`);
}
if (typeof d.attrs.reuse === 'string' && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(d.attrs.reuse)) {
flag(d, `prompt reuse:${d.attrs.reuse} must be a valid ENV_KEY`);
}
break;
}
case 'operator':
if (d.body.length === 0) flag(d, 'operator requires instructions for the human in its body');
// Removed presentation attrs — reject loudly (they would silently no-op).
if (d.attrs.open !== undefined) {
flag(d, 'operator open: was removed — put the URL in the body prose (a consumer offers to open it)');
}
if (d.args.includes('gate') || d.attrs.gate !== undefined) {
flag(d, 'operator gate was removed — the human barrier is derived from document structure, never authored');
}
break;
}
// Removed on every directive: label: (labels are heading-derived only) and
// on-fail: (the failure hint is always the surrounding prose).
if (d.attrs.label !== undefined) {
flag(d, 'label: was removed — step labels derive from the preceding heading');
}
if (d.attrs['on-fail'] !== undefined) {
flag(d, 'on-fail: was removed — the failure hint is always the surrounding prose');
}
// A consumer can only reference a variable an earlier prompt captured, or an
// earlier `run capture:<var>` bound from a command's output.
for (const ref of referencedVars(d)) {
if (!defined.has(ref)) flag(d, `references {{${ref}}} but no earlier nc:prompt or nc:run capture defined it`);
}
// A `when:<var>=<value>` guard references an earlier-defined var by bare name.
if (typeof d.attrs.when === 'string') {
const eq = d.attrs.when.indexOf('=');
if (eq < 1) {
flag(d, `when:${d.attrs.when} must be <var>=<value>`);
} else {
const wvar = d.attrs.when.slice(0, eq).trim();
if (!defined.has(wvar)) flag(d, `when:${d.attrs.when} references {{${wvar}}} but no earlier nc:prompt or nc:run capture defined it`);
}
}
if (d.kind === 'prompt') {
const v = promptVar(d);
if (v) defined.add(v);
}
// capture:<var> binds stdout; capture:<var>=<FIELD>,… binds step block fields.
if (d.kind === 'run' && typeof d.attrs.capture === 'string') {
for (const v of captureVars(d.attrs.capture)) defined.add(v);
}
// A run's capture validate:<re> (the stdout shape-guard) must be a valid regex.
if (d.kind === 'run' && typeof d.attrs.validate === 'string') {
try {
new RegExp(d.attrs.validate);
} catch {
flag(d, `run validate:${d.attrs.validate} is not a valid regex`);
}
}
}
return problems;
}
/**
* A WARN-ONLY reference-floor check never an error, never blocks a build. A
* credentialed or interactive skill (one that prompts for a `secret`, or runs an
* `nc:run effect:step` a pairing code, a QR device-link) should ship a
* `## Troubleshooting` section: the human floor a reader scrolls to when the live
* step misbehaves. Missing it is a smell, not a failure the skill still applies
* cleanly so this returns a warning the CLI prints but never exits non-zero on,
* keeping it strictly advisory. Returns [] when no secret/step is present (no
* floor is expected) or a `## Troubleshooting` heading already exists.
*/
export function lintReferenceFloor(markdown: string): Problem[] {
const directives = parseDirectives(markdown);
const isFloorBearing = (d: Directive): boolean =>
(d.kind === 'prompt' && d.args.includes('secret')) || (d.kind === 'run' && d.attrs.effect === 'step');
const anchor = directives.find(isFloorBearing);
if (!anchor) return []; // no credential / interactive step ⇒ no floor expected
const hasTroubleshooting = markdown.split('\n').some((l) => /^##\s+Troubleshooting\s*$/.test(l.trim()));
if (hasTroubleshooting) return [];
return [{
line: anchor.line,
kind: 'reference-floor',
message: 'a credentialed/interactive skill should carry a ## Troubleshooting section (the human floor when a live step misbehaves)',
}];
}
/**
* A WARN-ONLY gate-ambiguity check never an error, never blocks a build. The
* driver's natural-barrier policy (scripts/skill-policy.ts) keys an operator's
* pause decision off the next guard-compatible directive; an UNGUARDED operator
* treats every following directive as compatible, so when the directives
* immediately after it are `when:`-guarded and span more than one branch value,
* the static decision keys off a directive that may be runtime-skipped (e.g.
* unguarded operator `prompt when:mode=remote` `run when:mode=local`:
* policy says no-confirm, but at runtime mode=local skips the prompt). No
* in-tree skill authors this; warn so new authorship guards the operator (or
* restructures) instead of getting a silently wrong barrier. The scan stops at
* the first unguarded directive that one always runs, so no ambiguity past it.
*/
export function lintGateAmbiguity(directives: Directive[]): Problem[] {
const problems: Problem[] = [];
for (let i = 0; i < directives.length; i++) {
const d = directives[i];
if (d.kind !== 'operator' || typeof d.attrs.when === 'string') continue;
const branches = new Set<string>();
for (let j = i + 1; j < directives.length; j++) {
const g = directives[j].attrs.when;
if (typeof g !== 'string') break;
branches.add(g);
}
if (branches.size > 1) {
problems.push({
line: d.line,
kind: 'gate-ambiguity',
message: `unguarded nc:operator followed by when:-guarded directives spanning ${branches.size} branch values (${[...branches].join(', ')}) — the barrier decision may key off a runtime-skipped directive; guard the operator or restructure`,
});
}
}
return problems;
}
// CLI
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
let path = process.argv[2];
if (!path) {
console.error('usage: pnpm exec tsx scripts/skill-directives.ts <skillDir|SKILL.md>');
process.exit(2);
}
if (existsSync(path) && statSync(path).isDirectory()) path = join(path, 'SKILL.md');
const md = readFileSync(path, 'utf8');
const directives = parseDirectives(md);
const problems = validate(directives, { chatVersion: resolveChatCoreVersion(process.cwd()) });
// Warnings (gate ambiguity, reference floor) are advisory only — printed for
// the author, never folded into the exit code (a smell, not a gate). Exit
// stays driven solely by `validate` problems.
const warnings = [...lintGateAmbiguity(directives), ...lintReferenceFloor(md)];
for (const w of warnings) console.error(`warning: ${w.kind} (line ${w.line}): ${w.message}`);
console.log(JSON.stringify({ directives, problems, warnings }, null, 2));
process.exit(problems.length ? 1 : 0);
}
-187
View File
@@ -1,187 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { gatePolicy, extractOfferUrl, type GateDecision } from './skill-policy.js';
import { parseDirectives } from './skill-directives.js';
// The parity fixtures are the REAL in-tree channel skills: the policy's whole
// claim is that it reproduces (and deliberately extends — the readiness pauses,
// the discord confirm) the barrier behavior the authored `gate` attrs encoded,
// from document structure alone. Keyed by operator ORDER, not line number, so
// the assertions survive unrelated prose edits.
const loadSkill = (channel: string): string =>
readFileSync(join(process.cwd(), `.claude/skills/add-${channel}/SKILL.md`), 'utf8');
/** The gate decisions for a skill's operator blocks, in document order. */
function decisions(md: string): GateDecision[] {
const gates = gatePolicy(md);
return parseDirectives(md)
.filter((d) => d.kind === 'operator')
.map((d) => gates.get(d.line)!);
}
/** The rendered-ish body (raw, un-substituted) of the nth operator block. */
function operatorBody(md: string, n: number): string {
const ops = parseDirectives(md).filter((d) => d.kind === 'operator');
return ops[n].body.join('\n');
}
describe('gatePolicy — §5.1 parity table (real skills)', () => {
it('teams: exactly the two authored gates — and NO confirm on the operator-chain head', () => {
// Operators in order: prerequisites, portal app, client secret, bot resource
// (chain head), enable-channel (chain tail), sideload, final handoff.
const d = decisions(loadSkill('teams'));
expect(d).toHaveLength(7);
expect(d.map((g) => g.needsConfirm)).toEqual([
false, // → prompt public_url (prompt is the barrier)
false, // → prompt app_id
false, // → prompt app_password
false, // bot-resource block → next compatible is another OPERATOR (rule 2):
// the chain's LAST operator carries the barrier — never double-confirm
true, // enable-channel → run effect:check (the manifest hazard gate)
true, // sideload → run effect:restart
false, // final handoff → end of document
]);
// Both confirms are completed-work flavor (check/restart, not effect:step).
expect(d[4].flavor).toBe('completed');
expect(d[5].flavor).toBe('completed');
});
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
const d = decisions(loadSkill('telegram'));
expect(d.map((g) => g.needsConfirm)).toEqual([false, true]); // BotFather → prompt; pairing → step
expect(d[1].flavor).toBe('readiness'); // "a 4-digit code is about to appear"
});
it('signal: readiness pause before the QR device-link step', () => {
const d = decisions(loadSkill('signal'));
expect(d.map((g) => g.needsConfirm)).toEqual([true]);
expect(d[0].flavor).toBe('readiness');
});
it('whatsapp: each auth-method operator skips the OTHER branch and gates on its own step', () => {
const d = decisions(loadSkill('whatsapp'));
// qr operator skips the pairing-code operator + step (guard-incompatible)
// and gates on the qr effect:step; pairing-code likewise; the shared/dedicated
// block is prompt-followed.
expect(d.map((g) => g.needsConfirm)).toEqual([true, true, false]);
expect(d[0].flavor).toBe('readiness');
expect(d[1].flavor).toBe('readiness');
});
it('imessage: guard-compatibility — the local block skips the remote-only prompts and gates on the local configure run', () => {
const d = decisions(loadSkill('imessage'));
// local (when:mode=local) → skips remote operator + 2 remote prompts →
// run effect:external when:mode=local ⇒ confirm ("stop and wait" restored);
// remote (when:mode=remote) → prompt server_url (identical guard) ⇒ no confirm.
expect(d.map((g) => g.needsConfirm)).toEqual([true, false]);
expect(d[0].flavor).toBe('completed');
});
it('discord: the invite operator gains a confirm before the DM resolve (effect:fetch)', () => {
const d = decisions(loadSkill('discord'));
expect(d.map((g) => g.needsConfirm)).toEqual([false, true]);
expect(d[1].flavor).toBe('completed');
});
it('slack: all three operators are prompt-followed — no confirm, unchanged', () => {
// socket-create (guard-skips its webhook twin, lands on the bot_token
// prompt), webhook-create (bot_token prompt), event-delivery (owner_handle
// prompt in Resolve) — every barrier is a natural prompt barrier.
expect(decisions(loadSkill('slack')).map((g) => g.needsConfirm)).toEqual([false, false, false]);
});
});
describe('gatePolicy — rules on synthetic fixtures', () => {
const fence = (info: string, body: string): string => `\`\`\`${info}\n${body}\n\`\`\`\n`;
it('end of document → no confirm (a final handoff block)', () => {
const md = `# t\n${fence('nc:operator', 'All done — go DM the bot.')}`;
expect(decisions(md)).toEqual([{ needsConfirm: false, flavor: 'completed' }]);
});
it('operator chain: only the LAST operator of a chain carries the barrier', () => {
const md = `# t\n${fence('nc:operator', 'first block')}${fence('nc:operator', 'second block')}${fence('nc:run effect:external', 'do-it')}`;
expect(decisions(md).map((g) => g.needsConfirm)).toEqual([false, true]);
});
it('confirm fires for ALL non-prompt/non-operator barriers — even an env-set', () => {
const md = `# t\n${fence('nc:prompt v', 'Value?')}${fence('nc:operator', 'go get v')}${fence('nc:env-set', 'K={{v}}')}`;
expect(decisions(md)).toEqual([{ needsConfirm: true, flavor: 'completed' }]);
});
it('flavor: an effect:step barrier is a readiness pause, anything else completed-work', () => {
const step = `# t\n${fence('nc:operator', 'a code is about to appear')}${fence('nc:run effect:step capture:x=X', 'pair')}`;
expect(decisions(step)[0]).toEqual({ needsConfirm: true, flavor: 'readiness' });
const run = `# t\n${fence('nc:operator', 'finish the portal steps')}${fence('nc:run effect:check', 'true')}`;
expect(decisions(run)[0]).toEqual({ needsConfirm: true, flavor: 'completed' });
});
it('guard-compatibility: same-var/different-value directives are skipped; different-var guards are compatible', () => {
// Mutually-exclusive branch: the m=a operator skips the m=b prompt+run and
// gates on its OWN run.
const branch =
`# t\n${fence('nc:prompt m', 'mode?')}` +
fence('nc:operator when:m=a', 'branch a steps') +
fence('nc:prompt other when:m=b', 'b only') +
fence('nc:run effect:external when:m=b', 'b-run') +
fence('nc:run effect:external when:m=a', 'a-run');
expect(decisions(branch)).toEqual([{ needsConfirm: true, flavor: 'completed' }]);
// Different-var guard = compatible (conservative): the prompt is the barrier.
const diffVar =
`# t\n${fence('nc:prompt m', 'mode?')}` +
fence('nc:operator when:m=a', 'branch a steps') +
fence('nc:prompt other when:x=y', 'unrelated guard');
expect(decisions(diffVar).map((g) => g.needsConfirm)).toEqual([false]);
});
});
// §5.2 URL-offer inventory — every operator body in the tree, plus the
// normative negative fixture (slack's placeholder URL).
describe('extractOfferUrl — §5.2 inventory', () => {
it('teams: both portal blocks offer a clean https://portal.azure.com (trailing comma stripped)', () => {
const md = loadSkill('teams');
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer)
});
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
const md = loadSkill('slack');
const body = operatorBody(md, 2); // event-delivery block (after the two create-app variants)
expect(body).toContain('https://<your-public-host>/webhook/slack'); // fixture still authored as a placeholder
expect(extractOfferUrl(body)).toBeUndefined(); // slack stays offer-free
});
it('slack :69 — a scheme-less mention (api.slack.com/apps) never matches', () => {
expect(extractOfferUrl(operatorBody(loadSkill('slack'), 0))).toBeUndefined();
});
it('discord: the developers-portal block gains an offer; imessage: photon.codes', () => {
expect(extractOfferUrl(operatorBody(loadSkill('discord'), 0))).toBe('https://discord.com/developers/applications');
expect(extractOfferUrl(operatorBody(loadSkill('imessage'), 1))).toBe('https://photon.codes');
});
it('signal: a non-http scheme (sgnl://…) never matches', () => {
expect(extractOfferUrl(operatorBody(loadSkill('signal'), 0))).toBeUndefined();
});
it('an unsubstituted {{var}} in the URL is excluded; the substituted form offers', () => {
// The telegram/discord shape once their URLs live in the prose: rendered
// text has the var substituted; a defer path could surface the raw form.
expect(extractOfferUrl('Open https://t.me/{{bot_username}} in Telegram now.')).toBeUndefined();
expect(extractOfferUrl('Open https://t.me/my_helper_bot in Telegram now.')).toBe('https://t.me/my_helper_bot');
});
it('strips trailing sentence punctuation and stops at ), ], >', () => {
expect(extractOfferUrl('Visit https://example.com.')).toBe('https://example.com');
expect(extractOfferUrl('(see https://example.com/docs) for details')).toBe('https://example.com/docs');
expect(extractOfferUrl('nothing to open here')).toBeUndefined();
});
it('returns the FIRST offerable URL, skipping earlier excluded candidates', () => {
expect(extractOfferUrl('Set https://<host>/hook then read https://docs.example.com/setup.')).toBe(
'https://docs.example.com/setup',
);
});
});
-130
View File
@@ -1,130 +0,0 @@
// Shared, UI-free driver policy for `nc:operator` blocks — presentation derived
// from DOCUMENT STRUCTURE, never from authored presentation attrs.
//
// The apply engine (scripts/skill-apply.ts) only DECLARES and EMITS: it renders
// an operator block's text and awaits the consumer's `onEvent` before evaluating
// the next directive. Whether that block needs a human BARRIER (a confirm before
// the next side-effecting step runs) and whether its text carries a URL worth
// offering to open are judgments about the skill DOCUMENT — the same judgment
// for every consumer that renders live (the setup wizard, an agent relaying over
// chat). This module is that judgment, built on the shared parser, with no
// clack/TTY baggage — a consumer imports it instead of duplicating the policy.
//
// Two exports:
// • gatePolicy(md) → per operator line: does the block need a confirm
// after rendering, and with which flavor of wording?
// • extractOfferUrl(text) → the first offerable URL in a RENDERED operator
// body (template placeholders excluded), or undefined.
import { parseDirectives, type Directive } from './skill-directives.js';
/**
* The wording flavor a barrier confirm should carry, derived from the barrier
* directive's effect:
* 'readiness' the next step is an `effect:step` (a pairing code, a QR
* device-link): the block describes FUTURE action ("a code is about to
* appear"), so the confirm asks for readiness before it starts.
* 'completed' anything else: the block describes work the human must have
* FINISHED (an Azure app that must exist before the manifest bakes it in),
* so the confirm asks whether the steps above are done.
*/
export type ConfirmFlavor = 'readiness' | 'completed';
export interface GateDecision {
/** Confirm after rendering this operator block? */
needsConfirm: boolean;
/** Wording flavor for the confirm — meaningful only when `needsConfirm`. */
flavor: ConfirmFlavor;
}
// A directive's `when:<var>=<value>` guard, parsed. Malformed (no `=`) ⇒ treated
// as unguarded — conservative, and lint is the authoring gate anyway.
function guardOf(d: Directive): { v: string; value: string } | undefined {
if (typeof d.attrs.when !== 'string') return undefined;
const eq = d.attrs.when.indexOf('=');
if (eq < 1) return undefined;
return { v: d.attrs.when.slice(0, eq).trim(), value: d.attrs.when.slice(eq + 1).trim() };
}
/**
* The natural-barrier gate policy: for each `nc:operator` directive, decide
* whether the consumer should hold for a human confirm after rendering it
* keyed by the directive's opening-fence line (the `line` the engine's operator
* event carries).
*
* Rules (normative the §5.1 seam spec):
* 1. Scan forward through subsequent directives, skipping ONLY those whose
* `when:` guard is INCOMPATIBLE with this operator's own guard same var,
* different value. No guard, or an identical guard, is compatible
* (different-var guards are conservatively compatible too). This makes
* mutually-exclusive branches gate on their OWN next action.
* 2. Next compatible directive is another `operator` no confirm the
* chain's LAST operator carries the barrier. (Operators are NOT skipped-
* and-scanned-past: that would make the earlier block of a chain inherit
* the later block's barrier and double-confirm.)
* 3. Next compatible directive is a `prompt` no confirm (the prompt is the
* barrier the human can't paste a token before doing the steps).
* 4. No such directive (end of document) no confirm (a final handoff block).
* 5. Anything else (`run`, `copy`, `dep`, `append`, `env-set`,
* `json-merge`) confirm, with the flavor derived from that barrier
* directive's effect (`effect:step` readiness, else completed).
*
* Known limitation (lint-warned upstream): an UNGUARDED operator followed by
* guarded directives of more than one branch value keys its decision off a
* directive that may be runtime-skipped. No in-tree skill authors this.
*/
export function gatePolicy(md: string): Map<number, GateDecision> {
const directives = parseDirectives(md);
const out = new Map<number, GateDecision>();
directives.forEach((d, i) => {
if (d.kind !== 'operator') return;
const own = guardOf(d);
let barrier: Directive | undefined;
for (let j = i + 1; j < directives.length; j++) {
const g = guardOf(directives[j]);
if (own && g && own.v === g.v && own.value !== g.value) continue; // incompatible branch — skip
barrier = directives[j];
break;
}
if (!barrier || barrier.kind === 'operator' || barrier.kind === 'prompt') {
out.set(d.line, { needsConfirm: false, flavor: 'completed' });
} else {
out.set(d.line, { needsConfirm: true, flavor: barrier.attrs.effect === 'step' ? 'readiness' : 'completed' });
}
});
return out;
}
// A URL candidate in prose. The char class stops at whitespace, `)`, `>`, `]` —
// deliberately NOT at `<`, so a template placeholder like
// `https://<your-public-host>/webhook/slack` yields a candidate that still
// CONTAINS `<` and gets excluded below (instead of a nonsense truncated offer).
const URL_CANDIDATE = /https?:\/\/[^\s)>\]]+/g;
/**
* The first *offerable* URL in a rendered operator body, or undefined.
*
* A candidate matches `https?://…` up to whitespace/`)`/`>`/`]`, then:
* trailing sentence punctuation is stripped (so "In https://portal.azure.com,
* search" offers the clean URL, not one with a comma glued on);
* candidates containing `<` or `{{` are EXCLUDED template placeholders
* (`https://<your-public-host>/…`) and unsubstituted `{{vars}}` are authored
* shapes, not real destinations;
* the survivor must parse via `new URL()` with a non-empty host.
*
* Scheme-less mentions (`api.slack.com/apps`) and non-http schemes (`sgnl://…`)
* never match the offer is only for pages a browser can open.
*/
export function extractOfferUrl(text: string): string | undefined {
for (const m of text.matchAll(URL_CANDIDATE)) {
const candidate = m[0].replace(/[.,;:!?'"]+$/, '');
if (candidate.includes('<') || candidate.includes('{{')) continue;
try {
if (!new URL(candidate).hostname) continue;
} catch {
continue;
}
return candidate;
}
return undefined;
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
#
# Install the Codex agent provider non-interactively: copy the payload from the
# `providers` branch, wire the three provider barrels, and add the Codex CLI to
# the container manifest (container/cli-tools.json). The image rebuild is the
# caller's job (the setup container step / `./container/build.sh`).
#
# Emits exactly one status block on stdout (ADD_CODEX); all chatty progress
# goes to stderr. Keep in sync with .claude/skills/add-codex/SKILL.md.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with add-codex SKILL.md. This is the canonical Codex CLI pin —
# it lands in container/cli-tools.json (the global-CLI manifest), not the Dockerfile.
CODEX_VERSION="0.138.0"
# Resolve the remote carrying the providers branch (same nanoclaw remote that
# carries channels — handles forks where it isn't `origin`).
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
REMOTE=$(resolve_channels_remote)
BRANCH="${REMOTE}/providers"
# The codex payload — host provider, container runtime, setup module, doctrine.
# Barrels are appended to, not copied.
PAYLOAD_FILES=(
src/providers/codex.ts
src/providers/codex-agents-md.ts
src/providers/codex-registration.test.ts
src/providers/codex-host-contribution.test.ts
src/providers/codex-agents-md.test.ts
container/agent-runner/src/providers/codex.ts
container/agent-runner/src/providers/codex-app-server.ts
container/agent-runner/src/providers/exchange-archive.ts
container/agent-runner/src/providers/exchange-archive.test.ts
container/agent-runner/src/providers/codex-registration.test.ts
container/agent-runner/src/providers/codex.factory.test.ts
container/agent-runner/src/providers/codex.turns.test.ts
container/agent-runner/src/providers/codex-app-server.test.ts
container/agent-runner/src/providers/codex-cli-tools.test.ts
setup/providers/codex.ts
setup/providers/codex.test.ts
setup/providers/codex-registration.test.ts
container/AGENTS.md
)
BARRELS=(
src/providers/index.ts
container/agent-runner/src/providers/index.ts
setup/providers/index.ts
)
ALREADY_INSTALLED=true
emit_status() {
local status=$1 error=${2:-}
echo "=== NANOCLAW SETUP: ADD_CODEX ==="
echo "STATUS: ${status}"
echo "CODEX_VERSION: ${CODEX_VERSION}"
echo "ALREADY_INSTALLED: ${ALREADY_INSTALLED}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-codex] $*" >&2; }
# Idempotent: a complete install has the host provider file, the host barrel
# import, and the Codex CLI in the container manifest. Any missing → (re)install.
need_install() {
[ ! -f src/providers/codex.ts ] && return 0
! grep -q "^import './codex.js';" src/providers/index.ts 2>/dev/null && return 0
! grep -q '@openai/codex' container/cli-tools.json 2>/dev/null && return 0
return 1
}
if need_install; then
ALREADY_INSTALLED=false
log "Fetching providers branch from ${REMOTE}"
git fetch "$REMOTE" providers >&2 2>/dev/null || {
emit_status failed "git fetch ${REMOTE} providers failed"
exit 1
}
log "Copying Codex payload from ${BRANCH}"
for f in "${PAYLOAD_FILES[@]}"; do
mkdir -p "$(dirname "$f")"
git show "${BRANCH}:$f" > "$f" 2>/dev/null || {
emit_status failed "providers branch is missing ${f}"
exit 1
}
done
log "Wiring provider barrels…"
for b in "${BARRELS[@]}"; do
grep -q "^import './codex.js';" "$b" || printf "import './codex.js';\n" >> "$b"
done
log "Adding the Codex CLI to the container manifest (cli-tools.json)…"
# A json-merge: append { name, version } if absent. The Dockerfile installs
# every manifest entry via pinned `pnpm install -g` — no Dockerfile edit, no
# awk surgery. @openai/codex has no native postinstall, so no "onlyBuilt".
MANIFEST=container/cli-tools.json
node -e '
const fs = require("fs");
const [file, name, version] = process.argv.slice(1);
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
if (!tools.some((t) => t.name === name)) {
tools.push({ name, version });
const fmt = (t) =>
" { " +
Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") +
" }";
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
}
' "$MANIFEST" "@openai/codex" "${CODEX_VERSION}" || {
emit_status failed "failed to add @openai/codex to ${MANIFEST}"
exit 1
}
fi
emit_status ok
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
#
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
# PUBLIC_KEY to .env, and restart the service. Non-interactive —
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
# server-invite step live in setup/channels/discord.ts. Credentials come in via
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
#
# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-discord/SKILL.md.
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_DISCORD ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-discord] $*" >&2; }
if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then
emit_status failed "DISCORD_BOT_TOKEN env var not set"
exit 1
fi
if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then
emit_status failed "DISCORD_APPLICATION_ID env var not set"
exit 1
fi
if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then
emit_status failed "DISCORD_PUBLIC_KEY env var not set"
exit 1
fi
need_install() {
[ ! -f src/channels/discord.ts ] && return 0
! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts
# Append self-registration import if missing.
if ! grep -q "^import './discord.js';" src/channels/index.ts; then
echo "import './discord.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates before this point, so bad values here
# would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Discord adapter a moment to finish gateway handshake before
# init-first-agent attempts delivery.
sleep 5
emit_status success
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
#
# Install the iMessage adapter, persist mode/creds to .env,
# and restart the service. Non-interactive — the Full Disk Access walkthrough
# (local mode) and Photon URL/key prompts (remote mode) live in
# setup/channels/imessage.ts. Creds come in via env vars:
# IMESSAGE_LOCAL 'true' | 'false' (required)
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
#
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local mode=${IMESSAGE_LOCAL:-}
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-imessage] $*" >&2; }
# Validate creds based on mode.
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
exit 1
fi
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
exit 1
fi
if [ "$(uname -s)" != "Darwin" ]; then
emit_status failed "local mode requires macOS"
exit 1
fi
else
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
exit 1
fi
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
exit 1
fi
fi
need_install() {
[ ! -f src/channels/imessage.ts ] && return 0
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
# Append self-registration import if missing.
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
echo "import './imessage.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
remove_env() {
local key=$1
if grep -q "^${key}=" .env 2>/dev/null; then
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
fi
}
# Write the canonical keys for the chosen mode, strip the opposite mode's
# keys so stale values can't confuse the adapter's factory.
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
if [ "$IMESSAGE_LOCAL" = "true" ]; then
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
remove_env IMESSAGE_SERVER_URL
remove_env IMESSAGE_API_KEY
else
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
remove_env IMESSAGE_ENABLED
fi
log "Restarting service so the new adapter picks up the creds…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the adapter a moment to open chat.db (local) or handshake with
# Photon (remote) before emitting success.
sleep 3
emit_status success
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
# .env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
#
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-slack/SKILL.md.
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-slack] $*" >&2; }
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
emit_status failed "SLACK_BOT_TOKEN env var not set"
exit 1
fi
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
# SLACK_SIGNING_SECRET. Require at least one.
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
exit 1
fi
need_install() {
[ ! -f src/channels/slack.ts ] && return 0
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
# Append self-registration import if missing.
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
echo "import './slack.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates via auth.test before this point, so
# bad values here would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
fi
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
fi
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Slack adapter a moment to finish starting the webhook listener
# before emitting success.
sleep 3
emit_status success
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
# _TYPE to .env, and restart the service. Non-interactive —
# the operator-facing Azure portal walkthroughs live in
# setup/channels/teams.ts. Credentials come in via env vars:
# TEAMS_APP_ID (required)
# TEAMS_APP_PASSWORD (required — client secret value from Azure)
# TEAMS_APP_TYPE (required — SingleTenant | MultiTenant)
# TEAMS_APP_TENANT_ID (required when type=SingleTenant)
#
# Emits exactly one status block on stdout (ADD_TEAMS) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the
# full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-teams/SKILL.md.
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_TEAMS ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-teams] $*" >&2; }
if [ -z "${TEAMS_APP_ID:-}" ]; then
emit_status failed "TEAMS_APP_ID env var not set"
exit 1
fi
if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then
emit_status failed "TEAMS_APP_PASSWORD env var not set"
exit 1
fi
if [ -z "${TEAMS_APP_TYPE:-}" ]; then
emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)"
exit 1
fi
if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then
emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant"
exit 1
fi
need_install() {
[ ! -f src/channels/teams.ts ] && return 0
! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts
# Append self-registration import if missing.
if ! grep -q "^import './teams.js';" src/channels/index.ts; then
echo "import './teams.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env TEAMS_APP_ID "$TEAMS_APP_ID"
upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD"
upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE"
if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
fi
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Teams adapter a moment to register its webhook before the driver
# continues.
sleep 5
emit_status success
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
#
# Install the Telegram adapter, persist the bot token to .env,
# restart the service, and open the bot's chat page in the local Telegram
# client. Non-interactive — the operator-facing "Create a bot" instructions
# and token paste live in setup/auto.ts. The token comes in via the
# TELEGRAM_BOT_TOKEN env var.
#
# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All
# chatty progress messages go to stderr so setup:auto's raw-log capture
# sees the full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local username=${BOT_USERNAME:-}
echo "=== NANOCLAW SETUP: ADD_TELEGRAM ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$username" ] && echo "BOT_USERNAME: ${username}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-telegram] $*" >&2; }
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
emit_status failed "TELEGRAM_BOT_TOKEN env var not set"
exit 1
fi
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
emit_status failed "token format invalid (expected <digits>:<chars>)"
exit 1
fi
need_install() {
[ ! -f src/channels/telegram.ts ] && return 0
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
# in this list — do not overwrite the local version with the channels copy.
log "Copying adapter files from ${CHANNELS_BRANCH}"
for f in \
src/channels/telegram.ts \
src/channels/telegram-pairing.ts \
src/channels/telegram-pairing.test.ts \
src/channels/telegram-markdown-sanitize.ts \
src/channels/telegram-markdown-sanitize.test.ts
do
git show "${CHANNELS_BRANCH}:$f" > "$f"
done
# Append self-registration import if missing.
if ! grep -q "^import './telegram.js';" src/channels/index.ts; then
echo "import './telegram.js';" >> src/channels/index.ts
fi
# Register pair-telegram step if not already in the STEPS map.
# Uses node (not sed) since sed's in-place + escape semantics differ
# between BSD (macOS) and GNU.
node -e '
const fs = require("fs");
const p = "setup/index.ts";
let s = fs.readFileSync(p, "utf-8");
if (!s.includes("\047pair-telegram\047")) {
s = s.replace(
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
"$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27),"
);
fs.writeFileSync(p, s);
}
'
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist token. auto.ts validates before this point, so a bad token here
# would be an internal bug rather than operator input.
touch .env
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
awk -v tok="$TELEGRAM_BOT_TOKEN" \
'/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
fi
# Look up the bot username (auto.ts already validated; we re-query here so
# standalone invocations still work — BOT_USERNAME is emitted in the status
# block for parent drivers to display).
INFO=$(curl -fsS --max-time 8 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
BOT_USERNAME=""
if echo "$INFO" | grep -q '"ok":true'; then
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
fi
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
# surprise the user. Keeping it out of here means this script stays pure
# non-interactive install.
log "Restarting service so the new adapter picks up the token…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Telegram adapter a moment to finish starting before pair-telegram
# begins polling for the user's code message.
sleep 5
emit_status success
+31 -41
View File
@@ -32,10 +32,15 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
import { runChannelSkill } from './channels/run-channel-skill.js';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
import { runSlackChannel } from './channels/slack.js';
import { runTeamsChannel } from './channels/teams.js';
import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { getSetupProvider, listSetupProviders } from './providers/registry.js';
import { applyProviderSkill } from './providers/install.js';
// Provider payloads self-register their picker entry + auth on import.
import './providers/index.js';
import { brightSelect } from './lib/bright-select.js';
@@ -339,36 +344,26 @@ async function main(): Promise<void> {
let providerEntry = getSetupProvider(agentProvider);
if (agentProvider !== 'claude' && !providerEntry) {
// A non-claude provider picked from the hard-wired list isn't wired in
// this install yet — install it by applying its `/add-<name>` SKILL.md
// in-process via the directive engine (channel style, idempotent:
// self-skips if already installed), rebuild the image (the container step
// already ran, the CLI manifest just changed), then load the payload's
// setup module so it self-registers.
const skillDir = `.claude/skills/add-${agentProvider}`;
const s = p.spinner();
s.start(`Installing ${agentProvider}`);
let blockers: string[];
try {
({ blockers } = await applyProviderSkill(skillDir, process.cwd()));
} catch (err) {
s.stop(`Couldn't install ${agentProvider}.`, 1);
const message = err instanceof Error ? err.message : String(err);
// this install yet — install it via its self-contained script (channel
// style, idempotent: self-skips if already installed), rebuild the image
// (the container step already ran, the Dockerfile just changed), then
// load the payload's setup module so it self-registers.
const install = await runQuietChild(
`add-${agentProvider}`,
'bash',
[`setup/add-${agentProvider}.sh`],
{
running: `Installing ${agentProvider}`,
done: `${agentProvider} installed.`,
},
);
if (!install.ok) {
await fail(
`add-${agentProvider}`,
`Couldn't install ${agentProvider}.`,
message,
);
return; // unreachable — fail() exits — but narrows blockers for TS
}
if (blockers.length) {
s.stop(`Couldn't install ${agentProvider}.`, 1);
await fail(
`add-${agentProvider}`,
`Couldn't install ${agentProvider}.`,
blockers.join('; '),
'See logs/setup-steps/ for details, then retry setup.',
);
}
s.stop(`${agentProvider} installed.`);
p.log.info(brandBody('Rebuilding the container image with the new provider…'));
spawnSync('./container/build.sh', [], { stdio: 'inherit' });
await import(`./providers/${agentProvider}.js`);
@@ -554,24 +549,20 @@ async function main(): Promise<void> {
await resolveDisplayName();
}
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
// Every channel now runs through the SKILL.md-driven flow — the whole
// connect+wire procedure lives in each add-<channel>/SKILL.md. Teams
// defers the wire (its platform_id only exists after the first inbound),
// so it installs + hands off rather than wiring inline.
if (channelChoice === 'telegram') {
result = await runChannelSkill('telegram', displayName!, { offerBack: true });
result = await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
result = await runChannelSkill('discord', displayName!, { offerBack: true });
result = await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
result = await runChannelSkill('whatsapp', displayName!, { offerBack: true });
result = await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
result = await runChannelSkill('signal', displayName!, { offerBack: true });
result = await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
result = await runChannelSkill('teams', displayName!, { deferWire: true, offerBack: true });
result = await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
result = await runChannelSkill('slack', displayName!, { offerBack: true });
result = await runSlackChannel(displayName!);
} else if (channelChoice === 'imessage') {
result = await runChannelSkill('imessage', displayName!, { offerBack: true });
result = await runIMessageChannel(displayName!);
} else if (channelChoice === 'other') {
result = await askOtherChannelName();
} else {
@@ -812,8 +803,7 @@ function sendChatMessage(message: string): Promise<void> {
// Providers offered for install are hard-wired in trunk — an audited control
// surface (no branch enumeration that anyone with write access could extend).
// Codex is the only one offered here; opencode/ollama install via their own
// /add-* skills. Each is installed by applying its `/add-<name>` SKILL.md
// in-process via the directive engine.
// /add-* skills. Each is installed by its self-contained setup/add-<name>.sh.
const INSTALLABLE_PROVIDERS = [
{ value: 'codex', label: 'Codex', hint: 'OpenAI — ChatGPT subscription or API key' },
] as const;
@@ -822,7 +812,7 @@ async function askAgentProviderChoice(): Promise<string> {
const installed = listSetupProviders();
const installedNames = new Set(installed.map((entry) => entry.value));
// Offer the hard-wired installable providers this install hasn't wired yet —
// selecting one applies its `/add-<name>` SKILL.md in-process.
// selecting one installs it via setup/add-<name>.sh.
const available = INSTALLABLE_PROVIDERS.filter((prov) => !installedNames.has(prov.value));
const options = [
...installed.map(({ value, label, hint }) => ({ value, label, hint })),
+528
View File
@@ -0,0 +1,528 @@
/**
* Discord channel flow for setup:auto.
*
* `runDiscordChannel(displayName)` owns the full branch from "do you have a
* bot?" through the welcome DM:
*
* 1. Ask if they have a bot already; walk them through Dev Portal creation
* if not
* 2. Paste the bot token (clack password) format-validated
* 3. GET /users/@me to confirm the token and resolve bot username
* 4. GET /oauth2/applications/@me to derive application_id, verify_key
* (public key), and owner no separate paste needed in the common case
* 5. Confirm owner identity (falls back to a manual user-id prompt with
* Developer Mode instructions if declined or if the app is team-owned)
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
* 9. Ask for the messaging-agent name (defaulting to "Nano")
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
* DM through the normal delivery path
*
* All output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000)
// + Read Message History (0x10000) = 100416.
// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md.
const INVITE_PERMISSIONS = '100416';
interface AppInfo {
applicationId: string;
publicKey: string;
owner: { id: string; username: string } | null;
}
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
const choice = await askHasBotToken();
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
const hasBot = choice === 'yes';
if (!hasBot) {
await walkThroughBotCreation();
}
// Even users who said "yes" often can't find the token on demand — the
// Dev Portal resets it if you don't store it, and people forget which
// app it belongs to. A quick reminder before the paste prompt is cheap.
showTokenLocationReminder(hasBot);
const token = await collectDiscordToken();
const botUsername = await validateDiscordToken(token);
const app = await fetchApplicationInfo(token);
const ownerUserId = await resolveOwnerUserId(app.owner);
// Before inviting: do they have a server to invite into? Walkthrough if
// not — a fresh Discord account without a server makes the invite page a
// dead end.
if (!(await askHasDiscordServer())) {
await walkThroughServerCreation();
}
await promptInviteBot(app.applicationId, botUsername);
const install = await runQuietChild(
'discord-install',
'bash',
['setup/add-discord.sh'],
{
running: `Connecting Discord to @${botUsername}`,
done: 'Discord connected.',
},
{
env: {
DISCORD_BOT_TOKEN: token,
DISCORD_APPLICATION_ID: app.applicationId,
DISCORD_PUBLIC_KEY: app.publicKey,
},
extraFields: {
BOT_USERNAME: botUsername,
APPLICATION_ID: app.applicationId,
},
},
);
if (!install.ok) {
await fail(
'discord-install',
"Couldn't connect Discord.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `discord:@me:${dmChannelId}`;
const role = await askOperatorRole('Discord');
setupLog.userInput('discord_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'discord',
'--user-id', `discord:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to your Discord DMs…`,
done: `${agentName} is ready. Check Discord for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'discord',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
);
}
}
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
{ value: 'no', label: "No, walk me through creating one" },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
return answer as 'yes' | 'no' | 'back';
}
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
' 1. Click "New Application", give it a name (e.g. "NanoClaw")',
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
ensureAnswer(
await p.confirm({
message: "Got your bot token?",
initialValue: true,
}),
);
}
function showTokenLocationReminder(hasExistingBot: boolean): void {
// If we just walked them through creating a bot, they're staring at the
// token. If they came in with an existing one, they may still need a nudge
// to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one.
if (hasExistingBot) {
note(
[
"Where to find your bot token:",
'',
' 1. discord.com/developers/applications → pick your app',
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
' 3. Copy the new token',
].join('\n'),
'Reminder',
);
}
}
async function askHasDiscordServer(): Promise<boolean> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you have a Discord server you can add the bot to?',
options: [
{ value: 'yes', label: 'Yes, I have a server' },
{ value: 'no', label: "No, walk me through creating one" },
],
}),
);
setupLog.userInput('discord_has_server', String(answer));
return answer === 'yes';
}
async function walkThroughServerCreation(): Promise<void> {
// Discord doesn't have a stable deep-link for "create server" so we open
// the web client and rely on the + button being visible. The steps below
// are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me';
note(
[
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
'',
' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
ensureAnswer(
await p.confirm({
message: "Server created?",
initialValue: true,
}),
);
}
async function collectDiscordToken(): Promise<string> {
const existing = readEnvKey('DISCORD_BOT_TOKEN');
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('discord_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
// Discord bot tokens are base64url segments separated by dots.
// Be lenient on length; the real check is /users/@me.
if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) {
return "That doesn't look like a Discord bot token";
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'discord_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function validateDiscordToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
username?: string;
message?: string;
};
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
});
return data.username;
}
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Discord didn't accept that token: ${reason}`, 1);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-validate',
"Discord didn't accept that token.",
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-validate',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function fetchApplicationInfo(token: string): Promise<AppInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Looking up your bot application…');
try {
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
verify_key?: string;
owner?: { id: string; username: string } | null;
team?: unknown;
message?: string;
};
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-app-info',
"Couldn't read your Discord application details.",
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
data.owner && data.owner.id && data.owner.username
? { id: data.owner.id, username: data.owner.username }
: null;
setupLog.step('discord-app-info', 'success', Date.now() - start, {
APPLICATION_ID: data.id,
OWNER_USERNAME: owner?.username ?? '',
TEAM_OWNED: data.team ? 'true' : 'false',
});
return {
applicationId: data.id,
publicKey: data.verify_key,
owner,
};
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-app-info',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveOwnerUserId(
owner: { id: string; username: string } | null,
): Promise<string> {
if (owner) {
const confirmed = ensureAnswer(
await p.confirm({
message: `Is @${owner.username} your Discord account?`,
initialValue: true,
}),
);
if (confirmed === true) {
setupLog.userInput('discord_owner_confirmed', owner.username);
return owner.id;
}
} else {
p.log.info(
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
note(
[
"To get your Discord user ID:",
'',
' 1. Open Discord → Settings (⚙️) → Advanced',
' 2. Turn on "Developer Mode"',
' 3. Right-click your own name/avatar → "Copy User ID"',
].join('\n'),
'Find your Discord user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Discord user ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'User ID is required';
if (!/^\d{17,20}$/.test(t)) {
return "That doesn't look like a Discord user ID (17-20 digits)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('discord_user_id', id);
return id;
}
async function promptInviteBot(
applicationId: string,
botUsername: string,
): Promise<void> {
const url =
`https://discord.com/api/oauth2/authorize` +
`?client_id=${applicationId}` +
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Add bot to a server',
);
await confirmThenOpen(url, 'Press Enter to open the invite page');
ensureAnswer(
await p.confirm({
message: "I've added the bot to a server",
initialValue: true,
}),
);
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${DISCORD_API}/users/@me/channels`, {
method: 'POST',
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-open-dm',
"Couldn't open a DM channel with you.",
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-open-dm',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env bash
# Write the mode-exclusive iMessage `.env` keys, and strip the opposite mode's
# keys so a stale value can't confuse the adapter's factory.
#
# The two iMessage modes use different keys:
# local — IMESSAGE_LOCAL=true, IMESSAGE_ENABLED=true (no server/key)
# remote — IMESSAGE_LOCAL=false, IMESSAGE_SERVER_URL, IMESSAGE_API_KEY
#
# This is an upsert-and-remove: it replaces a key in place if present (else
# appends it) and deletes the other mode's keys. The skill engine's plain
# env write is set-if-absent only — it can neither replace a stale value nor
# delete a key — so that logic lives here, in one script the skill invokes
# once per mode.
#
# bash setup/channels/imessage-configure.sh local
# bash setup/channels/imessage-configure.sh remote "<server-url>" "<api-key>"
set -u
mode="${1:-}"
server_url="${2:-}"
api_key="${3:-}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/../.." && pwd)"
env_file="$root/.env"
# Replace `KEY=...` in place if present, else append it. Mirrors
# setup/environment.ts:upsertEnvKey (set-or-replace, file ends with a newline).
set_key() {
local key="$1" val="$2" tmp found=0
tmp="$(mktemp)"
if [ -f "$env_file" ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"${key}="*) printf '%s=%s\n' "$key" "$val" >> "$tmp"; found=1 ;;
*) printf '%s\n' "$line" >> "$tmp" ;;
esac
done < "$env_file"
fi
[ "$found" -eq 0 ] && printf '%s=%s\n' "$key" "$val" >> "$tmp"
mv "$tmp" "$env_file"
}
# Drop every `KEY=...` line. Mirrors setup/environment.ts removeEnvKey.
remove_key() {
local key="$1" tmp
[ -f "$env_file" ] || return 0
tmp="$(mktemp)"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"${key}="*) ;;
*) printf '%s\n' "$line" >> "$tmp" ;;
esac
done < "$env_file"
mv "$tmp" "$env_file"
}
case "$mode" in
local)
set_key IMESSAGE_LOCAL true
set_key IMESSAGE_ENABLED true
remove_key IMESSAGE_SERVER_URL
remove_key IMESSAGE_API_KEY
;;
remote)
set_key IMESSAGE_LOCAL false
set_key IMESSAGE_SERVER_URL "$server_url"
set_key IMESSAGE_API_KEY "$api_key"
remove_key IMESSAGE_ENABLED
;;
*)
echo "imessage-configure: unknown mode '${mode}' (expected local|remote)" >&2
exit 1
;;
esac
+336
View File
@@ -0,0 +1,336 @@
/**
* iMessage channel flow for setup:auto.
*
* `runIMessageChannel(displayName)` covers both deployment modes:
*
* Local (macOS): the bot runs on this Mac and talks via the signed-in
* iMessage account. Reading chat.db needs Full Disk Access granted to
* the Node binary we open the directory for them so they can drag
* the `node` file into System Settings.
*
* Remote (Photon API): the bot talks to a separate server (Photon)
* that owns an iMessage account on another Mac. Used when this host
* is Linux, or when the operator wants to keep their daily-driver
* Mac's chat history out of the loop.
*
* Flow:
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
* Remote: prompt for Photon server URL + API key
* 3. Ask for the phone or email the operator messages from this is
* the platform-id for first-agent wiring
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
* 5. Wire the agent via scripts/init-first-agent.ts the welcome
* iMessage goes out through the normal delivery path
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'child_process';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
type Mode = 'local' | 'remote';
interface RemoteCreds {
serverUrl: string;
apiKey: string;
}
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
const isMac = os.platform() === 'darwin';
const mode = await askMode(isMac);
if (mode === 'back') return BACK_TO_CHANNEL_SELECTION;
let remoteCreds: RemoteCreds | null = null;
if (mode === 'local') {
if (!isMac) {
await fail(
'imessage',
"Local iMessage mode only works on macOS.",
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
);
}
await walkThroughFullDiskAccess();
} else {
remoteCreds = await collectRemoteCreds();
}
const handle = await askOperatorHandle();
const install = await runQuietChild(
'imessage-install',
'bash',
['setup/add-imessage.sh'],
{
running:
mode === 'local'
? "Connecting the iMessage adapter to this Mac…"
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
done: 'iMessage adapter installed.',
},
{
env:
mode === 'local'
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
: {
IMESSAGE_LOCAL: 'false',
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
IMESSAGE_API_KEY: remoteCreds!.apiKey,
},
extraFields: { MODE: mode },
},
);
if (!install.ok) {
await fail(
'imessage-install',
"Couldn't install the iMessage adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const role = await askOperatorRole('iMessage');
setupLog.userInput('imessage_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'imessage',
'--user-id', handle,
'--platform-id', handle,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to iMessage…`,
done: `${agentName} is ready. Check iMessage for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'imessage',
AGENT_NAME: agentName,
PLATFORM_ID: handle,
MODE: mode,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
);
}
}
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
const baseOptions = isMac
? [
{
value: 'local' as const,
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
];
const choice = ensureAnswer(
await brightSelect<Mode | 'back'>({
message: 'How should iMessage run?',
initialValue: isMac ? 'local' : 'remote',
options: [
...baseOptions,
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
return choice;
}
/**
* Grant Full Disk Access to the Node binary the host runs under without
* it, the adapter can't read chat.db and inbound messages never arrive.
* Opening the containing directory in Finder makes the drag-and-drop
* target obvious; falling back to printing the path keeps us working in
* SSH/headless contexts where `open` is a no-op.
*/
async function walkThroughFullDiskAccess(): Promise<void> {
let nodePath = process.execPath;
try {
// `which node` picks up the user's shell-resolved node, which may differ
// from process.execPath (e.g. they launched setup under a different
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
const which = execSync('which node', { encoding: 'utf-8' }).trim();
if (which) nodePath = which;
} catch {
// fall back to process.execPath
}
const nodeDir = path.dirname(nodePath);
note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
'',
` ${nodePath}`,
'',
' 1. System Settings → Privacy & Security → Full Disk Access',
` 2. Click +, then drag the "node" file from the Finder window`,
' we just opened for you',
' 3. Toggle it on, then come back here',
].join('\n'),
6,
),
'Grant Full Disk Access',
);
try {
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
} catch {
// No Finder (SSH/headless) — user sees the path in the note above.
}
ensureAnswer(
await p.confirm({
message: "Granted Full Disk Access?",
initialValue: true,
}),
);
setupLog.userInput('imessage_fda_confirmed', 'true');
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
const existingKey = readEnvKey('IMESSAGE_API_KEY');
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('imessage_remote_creds', 'reused-existing');
return { serverUrl: existingUrl, apiKey: existingKey };
}
}
note(
[
"Photon is a separate service that owns an iMessage account and",
"exposes it over HTTP. NanoClaw will talk to it via its API.",
'',
' 1. Set up a Photon server: https://photon.codes',
' 2. Copy the server URL and API key from your Photon dashboard',
].join('\n'),
'Remote iMessage via Photon',
);
const urlAnswer = ensureAnswer(
await p.text({
message: 'Photon server URL',
placeholder: 'https://photon.example.com',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'URL is required';
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
return undefined;
},
}),
);
const serverUrl = (urlAnswer as string).trim();
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
clearOnError: true,
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
const apiKey = (keyAnswer as string).trim();
setupLog.userInput('imessage_server_url', serverUrl);
setupLog.userInput(
'imessage_api_key',
`${apiKey.slice(0, 4)}${apiKey.slice(-4)}`,
);
return { serverUrl, apiKey };
}
async function askOperatorHandle(): Promise<string> {
note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
'',
k.dim(' • Phone: start with + and your country code, no spaces or dashes'),
k.dim(' Example: +14155551234 (country code 1, then 4155551234)'),
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
].join('\n'),
'Your iMessage handle',
);
const answer = ensureAnswer(
await p.text({
message: 'Phone number or email',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
const isPhone = /^\+\d{8,15}$/.test(t);
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
if (!isPhone && !isEmail) {
return "Use a +E.164 phone number or an email address";
}
return undefined;
},
}),
);
const handle = (answer as string).trim();
setupLog.userInput('imessage_handle', handle);
return handle;
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
-234
View File
@@ -1,234 +0,0 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runChannelSkill } from './run-channel-skill.js';
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
// instead of opening a real TTY select. Hoisted so the vi.mock factory — which
// runs before imports — can close over it. The existing Option-A tests never
// opt into offerBack (and pass `role` so askOperatorRole's brightSelect isn't
// reached either), so the mock is inert for them.
const bs = vi.hoisted(() => ({ answers: [] as string[] }));
vi.mock('../lib/bright-select.js', async (importActual) => {
const actual = await importActual<typeof import('../lib/bright-select.js')>();
return { ...actual, brightSelect: vi.fn(async () => bs.answers.shift() ?? 'continue') };
});
// Drives the real add-slack skill through the adapter with every side effect
// injected (no real ncl/git/clack/init-first-agent): confirms it runs the skill
// (install + creds + resolve), reads the resolved owner_handle + platform_id from
// the result, and hands them to the shared wire with a composed user-id.
describe('runChannelSkill adapter (Option A)', () => {
it('resolves via the skill, then wires through init-first-agent', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const cmds: string[] = [];
const exec = (c: string): string | void => {
cmds.push(c);
if (c.includes('auth.test')) return '@bot in Acme\n'; // identity capture
// the resolve run: conversations.open piped through jq → "slack:<channel>"
if (c.includes('conversations.open')) return 'slack:D0SLACK\n';
};
const wired: Array<Record<string, unknown>> = [];
await runChannelSkill('slack', 'Bob Smith', {
projectRoot: root,
exec,
resolveRemote: () => 'origin',
agentName: 'Nano',
role: 'owner',
// the secrets + handle a human would supply; the skill resolves platform_id.
// Values are valid-shaped for the prompts' validate: regexes — validate-at-bind
// now enforces them on `inputs` too (they used to bypass validation).
inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' },
wire: (a) => {
wired.push(a);
return true;
},
});
// the channel-specific resolve ran
expect(cmds.some((c) => c.includes('auth.test'))).toBe(true);
expect(cmds.some((c) => c.includes('conversations.open'))).toBe(true);
// ...and the shared wire got the composed user-id + resolved platform_id
expect(wired).toHaveLength(1);
expect(wired[0]).toMatchObject({
channel: 'slack',
userId: 'slack:U12345678', // channel + owner_handle
platformId: 'slack:D0SLACK', // captured from conversations.open
displayName: 'Bob Smith',
agentName: 'Nano',
role: 'owner',
});
// the adapter no longer emits any ncl wiring itself — that's init-first-agent's job
expect(cmds.some((c) => c.startsWith('ncl '))).toBe(false);
});
// Teams' platform_id only exists after the first inbound, so its SKILL.md
// installs + hands off and runChannelSkill is called with deferWire — it must
// run the skill but never reach the shared wire. This is the driver-policy
// parity fixture: it runs the DEFAULT onEvent handler (never an injected
// onEvent, which would replace the policy — §5.0) and injects the
// confirm/openUrl seams to prove both natural barriers fire and the portal
// URL offer survives from the operator prose alone.
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const log: string[] = [];
const opened: string[] = [];
const wired: unknown[] = [];
await runChannelSkill('teams', 'Acme Corp', {
projectRoot: root,
exec: (c) => void log.push(`exec:${c}`),
resolveRemote: () => 'origin',
reuse: false,
deferWire: true,
// The injectable interaction seams — the default handler consults them for
// the URL offer and the natural-barrier confirms, so no real clack confirm
// (which would hang in CI) and no real browser open is reached.
confirm: async (m) => {
log.push(`confirm:${m}`);
return true;
},
openUrl: async (u) => void opened.push(u),
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped
inputs: {
public_url: 'https://acme.example',
app_id: '12345678-1234-1234-1234-123456789abc',
app_type: 'MultiTenant',
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape
},
wire: (a) => {
wired.push(a);
return true;
},
});
// install + manifest ran…
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
// …the Azure portal offer came from the operator BODY text (policy §5.2)…
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest
// build (the manifest-before-the-app hazard fix, now derived from document
// structure instead of an authored gate attr)…
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build'));
expect(firstGate).toBeGreaterThanOrEqual(0);
expect(firstGate).toBeLessThan(manifestAt);
// …but the shared wire was never reached (no owner_handle/platform_id needed)
expect(wired).toHaveLength(0);
});
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
// repo root in tests — same as the real add-slack the test above drives), so a
// bounce-fixture skill is created there and torn down afterward.
const failChannel = 'failtest';
const failSkillDir = join(process.cwd(), '.claude/skills', `add-${failChannel}`);
afterEach(() => rmSync(failSkillDir, { recursive: true, force: true }));
// When the skill doesn't fully apply (a directive bounced to an agent), the
// generic "couldn't finish" message is replaced by the bounced step's OWN
// prose: the section heading becomes fail()'s headline and the surrounding
// prose becomes the dimmed hint (which fail() also forwards to the Claude
// handoff). Asserted via an injected fail spy (the real fail() process.exits).
it('threads the bounced step prose into fail() when the skill does not fully apply', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-fail-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), '');
// A skill whose only directive bounces — the engine has no handler for
// nc:hand-wire, so it degrades to an agent and the run is not fully applied.
mkdirSync(failSkillDir, { recursive: true });
writeFileSync(
join(failSkillDir, 'SKILL.md'),
[
`# add ${failChannel}`,
'',
'## Register the webhook by hand',
'Open the Faily dashboard and paste the webhook URL into the bot settings.',
'```nc:hand-wire',
'register webhook',
'```',
'',
].join('\n'),
);
const failCalls: Array<{ step: string; msg: string; hint?: string }> = [];
const fakeFail = (step: string, msg: string, hint?: string): Promise<never> => {
failCalls.push({ step, msg, hint });
// The real fail() process.exits and never returns; emulate that by aborting
// the flow so control doesn't fall through to the resolve/wire steps.
return Promise.reject(new Error('__failed__'));
};
await expect(
runChannelSkill(failChannel, 'Bob', {
projectRoot: root,
exec: () => {},
resolveRemote: () => 'origin',
agentName: 'Nano',
role: 'owner',
reuse: false,
inputs: {},
fail: fakeFail,
wire: () => true,
}),
).rejects.toThrow('__failed__');
expect(failCalls).toHaveLength(1);
expect(failCalls[0].step).toBe(`${failChannel}-install`);
expect(failCalls[0].msg).toBe('Register the webhook by hand'); // heading → headline
expect(failCalls[0].hint).toContain('Open the Faily dashboard'); // prose → hint
expect(failCalls[0].hint).not.toBe('See logs/setup-steps/ for details, then retry setup.'); // not the generic
});
});
// M5 backGate — the first-prompt "← Back to channel selection" gate. It's a
// brightSelect (mocked above) wrapped in ensureAnswer; on back it returns the
// existing BACK_TO_CHANNEL_SELECTION sentinel that setup/auto.ts already catches.
describe('backGate (first-prompt back-to-channel-selection)', () => {
it('returns the sentinel on back and continue otherwise', async () => {
bs.answers = ['back'];
expect(await backGate('Slack DMs')).toBe(BACK_TO_CHANNEL_SELECTION);
bs.answers = ['continue'];
expect(await backGate('Slack DMs')).toBe('continue');
});
// offerBack runs the gate at the very top — before resolveAgentName/role, the
// skill run, and the wire. Picking back returns the sentinel without touching
// any side effect (no exec, no wire).
it('runChannelSkill with offerBack returns the sentinel before running the skill', async () => {
bs.answers = ['back'];
const cmds: string[] = [];
const wired: unknown[] = [];
const result = await runChannelSkill('slack', 'Bob Smith', {
offerBack: true,
exec: (c) => void cmds.push(c),
resolveRemote: () => 'origin',
agentName: 'Nano',
role: 'owner',
inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' },
wire: (a) => {
wired.push(a);
return true;
},
});
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(cmds).toHaveLength(0); // the skill never ran
expect(wired).toHaveLength(0); // the wire was never reached
});
});
-194
View File
@@ -1,194 +0,0 @@
/**
* Generic channel onboarding for setup:auto the replacement for the bespoke
* per-channel `run<Channel>Channel` flows.
*
* Split of responsibilities (Option A):
* - The channel's SKILL.md owns the channel-specific part: install the adapter,
* collect credentials, and resolve the wire inputs `owner_handle` +
* `platform_id` (e.g. Slack `conversations.open`). The engine surfaces those
* resolved values in `ApplyResult.vars`.
* - This flow owns the shared part: the operator's agent name + role (the
* polish), and the wire itself `scripts/init-first-agent.ts`, which creates
* the agent group, grants the owner role (+ cli_scope=global), creates the
* messaging group + wiring, and sends the `/welcome` system instruction.
*
* So the wire lives in exactly one place (init-first-agent) and is never
* duplicated across channel skills.
*/
import { writeFileSync } from 'node:fs';
import * as p from '@clack/prompts';
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, backGate, type ChannelFlowResult } from '../lib/back-nav.js';
import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { runSkill, type RunSkillOptions } from '../lib/skill-driver.js';
const DEFAULT_AGENT_NAME = 'Nano';
interface WireArgs {
channel: string;
userId: string;
platformId: string;
displayName: string;
agentName: string;
role: OperatorRole;
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) return preset;
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
return (answer as string).trim() || DEFAULT_AGENT_NAME;
}
/** The shared wire: init-first-agent (group + owner role + cli_scope + wiring + /welcome). */
async function initFirstAgent(args: WireArgs): Promise<boolean> {
const res = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', args.channel,
'--user-id', args.userId,
'--platform-id', args.platformId,
'--display-name', args.displayName,
'--agent-name', args.agentName,
'--role', args.role,
],
{ running: `Wiring ${args.agentName} to your ${args.channel} DMs…`, done: 'Agent wired.' },
{ extraFields: { CHANNEL: args.channel, AGENT_NAME: args.agentName, PLATFORM_ID: args.platformId } },
);
return res.ok;
}
export interface ChannelSkillOverrides extends Partial<RunSkillOptions> {
agentName?: string;
role?: OperatorRole;
/** The shared wire; defaults to init-first-agent. Injectable for tests. */
wire?: (args: WireArgs) => Promise<boolean> | boolean;
/**
* Skip the resolve+wire half. For a channel whose platform_id only exists
* after the first inbound (Teams): the SKILL.md installs the adapter and ends
* with an `nc:operator` handoff telling the operator to DM the bot and finish
* wiring later. No owner_handle/platform_id is expected, no agent name/role is
* asked, and init-first-agent is not run.
*/
deferWire?: boolean;
/**
* Offer the "← Back to channel selection" gate as the very first prompt,
* before any side effect (agent-name/role prompts, the skill run, the wire
* and the deferWire/Teams path too). On back, returns the
* `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so
* headless callers (and the existing tests) never see the extra prompt.
*/
offerBack?: boolean;
/** The first-prompt back gate; defaults to back-nav.ts `backGate`. Injectable for tests. */
backGate?: (label: string) => Promise<'continue' | typeof BACK_TO_CHANNEL_SELECTION>;
/** The abort path; defaults to runner.ts `fail` (which exits). Injectable for tests. */
fail?: (stepName: string, msg: string, hint?: string, rawLogPath?: string) => Promise<never>;
}
export async function runChannelSkill(
channel: string,
displayName: string,
overrides: ChannelSkillOverrides = {},
): Promise<ChannelFlowResult> {
// First-prompt back gate — the very first thing, before any side effect
// (agent-name/role prompts, the skill run, the wire; covers deferWire too).
// Opt-in via offerBack so headless callers + existing tests are unaffected.
if (overrides.offerBack) {
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
const gate = await (overrides.backGate ?? backGate)(label);
if (gate === BACK_TO_CHANNEL_SELECTION) return BACK_TO_CHANNEL_SELECTION;
}
const projectRoot = overrides.projectRoot ?? process.cwd();
const failWith = overrides.fail ?? fail;
// The agent name + role are wire inputs — skip the prompts when the wire is deferred.
const agentName = overrides.deferWire ? '' : overrides.agentName ?? (await resolveAgentName());
const role = overrides.deferWire ? undefined : overrides.role ?? (await askOperatorRole(channel));
// Channel-specific: install adapter, collect credentials, resolve the wire
// inputs. The whole channel-specific procedure lives in the SKILL.md.
const res = await runSkill(`.claude/skills/add-${channel}`, {
projectRoot,
exec: overrides.exec,
resolveInput: overrides.resolveInput,
resolveRemote: overrides.resolveRemote,
inputs: overrides.inputs,
skipEffects: overrides.skipEffects,
// undefined ⇒ runSkill's default policy handler (TTY-gated spinner + operator
// note → URL offer → natural-barrier confirm). An injected onEvent replaces
// that policy entirely; inject confirm/openUrl to observe the default policy.
onEvent: overrides.onEvent,
confirm: overrides.confirm,
openUrl: overrides.openUrl,
reuse: overrides.reuse ?? true, // offer to reuse credentials already in .env
// Handoff context for the `?` help-escape: a lone `?` at any of this skill's
// prompts hands the operator off to interactive Claude scoped to this channel.
channel: overrides.channel ?? channel,
step: overrides.step ?? `${channel}-install`,
});
if (!fullyApplied(res)) {
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
// A bounced reason can carry a full stderr dump (a Node stacktrace). The
// terminal gets ONE line per bounce — the first line, which hostExec
// composes as `exit <code>: <first stderr line>` — and the full text goes
// to a raw step log, written only when there's actually more than one line
// to keep (SSF-004; the reference prose is deliberately not dumped either).
let rawLog: string | undefined;
if (res.agentTasks.some((t) => t.reason.includes('\n'))) {
rawLog = setupLog.stepRawLog(`${channel}-install-bounce`);
writeFileSync(rawLog, res.agentTasks.map((t) => `## ${t.kind} (line ${t.line})\n${t.reason}\n`).join('\n'));
}
for (const t of res.agentTasks) {
const lines = t.reason.split('\n').map((l) => l.trim()).filter(Boolean);
const more = lines.length > 1 ? ` (+${lines.length - 1} more lines in ${rawLog})` : '';
p.log.warn(`Needs an agent (${t.kind}): ${lines[0] ?? t.reason}${more}`);
}
// Surface the bounced step's OWN prose as the failure hint + Claude-handoff
// context (fail() dims the hint and forwards it to offerClaudeOnFailure),
// instead of a generic "couldn't finish" message. Only a real bounce yields a
// diagnosis; a purely-deferred run (a missing input) falls back to the generic.
const diag = firstFailureHint(res);
await failWith(
`${channel}-install`,
diag?.headline ?? `Couldn't finish setting up ${channel}.`,
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.',
rawLog,
);
}
// Identity confirmation captured by the skill (e.g. add-slack's auth.test).
if (res.vars.connected_as) p.log.success(`Connected to ${channel} as ${res.vars.connected_as}.`);
// Deferred wire (Teams): the SKILL's operator handoff owns the rest. Done here.
if (overrides.deferWire) return;
const ownerHandle = res.vars.owner_handle;
const platformId = res.vars.platform_id;
if (!ownerHandle || !platformId) {
await failWith(
`${channel}-resolve`,
`Couldn't resolve your ${channel} address.`,
'The skill did not produce owner_handle + platform_id.',
);
}
// Shared wire — the same procedure for every channel. role is defined here:
// it's only undefined in deferWire mode, which returned above.
const wire = overrides.wire ?? initFirstAgent;
const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! });
if (!ok) {
await failWith('init-first-agent', `Couldn't finish connecting ${agentName}.`, 'You can retry later with `/init-first-agent`.');
}
}
+413
View File
@@ -0,0 +1,413 @@
/**
* Signal channel flow for setup:auto.
*
* `runSignalChannel(displayName)` owns the full branch from signal-cli
* presence check through the welcome DM:
*
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
* offer `brew install signal-cli` inline. On Linux, surface the
* GitHub releases URL and bail with an actionable error.
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
* a terminal QR the operator scans from Signal Linked Devices.
* 4. Persist SIGNAL_ACCOUNT to .env.
* 5. Kick the service so the adapter picks up the new credentials.
* 6. Ask operator role + agent name.
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* DM path delivers the greeting through the adapter.
*
* Signal's `link` flow creates a *secondary* device. The phone number
* comes from the primary (the phone that scanned the QR); this host then
* sends/receives as that primary number. No registration of new numbers.
*
* Output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runSignalChannel(displayName: string): Promise<ChannelFlowResult> {
note(
[
"NanoClaw links to Signal as a *secondary* device on your existing",
"phone — no new number needed. Your assistant will send and receive",
"messages as the number on that phone.",
'',
"Here's what's about to happen — no input needed for any of it:",
'',
' 1. Set up signal-cli (auto-installs if missing)',
' 2. Install the Signal adapter',
' 3. Show a QR code — scan it from Signal → Settings → Linked Devices',
' 4. Wire your assistant and send a welcome message',
].join('\n'),
'Set up Signal',
);
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
message: 'Ready to set up Signal?',
options: [
{ value: 'continue', label: 'Continue' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'continue',
}));
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
await ensureSignalCli();
const install = await runQuietChild(
'signal-install',
'bash',
['setup/add-signal.sh'],
{
running: 'Installing the Signal adapter…',
done: 'Signal adapter installed.',
skipped: 'Signal adapter already installed.',
},
);
if (!install.ok) {
await fail(
'signal-install',
"Couldn't install the Signal adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const auth = await runSignalAuth();
if (!auth.ok) {
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
await fail(
'signal-auth',
`Signal link failed (${reason}).`,
reason === 'qr_timeout'
? 'The code expired. Re-run setup to get a fresh one.'
: 'Re-run setup to try again.',
);
}
const account = auth.terminal?.fields.ACCOUNT;
if (!account) {
await fail(
'signal-auth',
'Linked with Signal but couldn\'t read the phone number back.',
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
);
}
writeSignalAccount(account!);
await restartService();
const role = await askOperatorRole('Signal');
setupLog.userInput('signal_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'signal',
'--user-id', account!,
'--platform-id', account!,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to Signal…`,
done: `${agentName} is ready. Check Signal for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'signal',
AGENT_NAME: agentName,
PLATFORM_ID: account!,
ROLE: role,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function ensureSignalCli(): Promise<void> {
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
const probeFor = (): boolean => {
const r = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
return !r.error && r.status === 0;
};
if (probeFor()) return;
note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
"We'll install it for you now — about 30 seconds, one-time only.",
'',
process.platform === 'darwin'
? "On this Mac we'll use Homebrew (no admin password needed)."
: "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.",
].join('\n'),
'Setting up signal-cli',
);
const install = await runQuietChild(
'install-signal-cli',
'bash',
['setup/install-signal-cli.sh'],
{
running: 'Installing signal-cli…',
done: 'signal-cli installed.',
},
);
if (install.ok && probeFor()) return;
const reason = install.terminal?.fields.ERROR;
if (process.platform === 'darwin') {
note(
[
"We couldn't install signal-cli automatically.",
reason === 'homebrew_not_installed'
? ' Reason: Homebrew is not installed.'
: ` Reason: ${reason ?? 'unknown'}.`,
'',
'You can install it manually:',
'',
k.cyan(' brew install signal-cli'),
'',
'Then re-run setup.',
].join('\n'),
"Couldn't install signal-cli",
);
} else {
note(
[
"We couldn't install signal-cli automatically.",
` Reason: ${reason ?? 'unknown'}.`,
'',
'You can install it manually from GitHub:',
'',
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
'',
'Then re-run setup.',
].join('\n'),
"Couldn't install signal-cli",
);
}
await fail(
'install-signal-cli',
'signal-cli is required but the auto-install failed.',
'Install it manually and re-run setup.',
);
}
async function runSignalAuth(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('signal-auth');
const start = Date.now();
const s = p.spinner();
s.start('Starting Signal link…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number): void => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
// Tracks how many lines the QR block occupies so we can wipe it in-place
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
// but we still want to erase the QR from screen once it's served).
let qrLinesPrinted = 0;
const result = await spawnStep(
'signal-auth',
[],
(block: Block) => {
if (block.type === 'SIGNAL_AUTH_QR') {
const qr = block.fields.QR ?? '';
if (!qr) return;
void renderQr(qr).then((lines) => {
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
process.stdout.write(lines.join('\n') + '\n');
qrLinesPrinted = lines.length;
s.start('Waiting for you to scan…');
spinnerActive = true;
});
} else if (block.type === 'SIGNAL_AUTH') {
const status = block.fields.STATUS;
// Wipe the QR block regardless of outcome — it's either scanned
// and useless, or expired and misleading.
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
const account = block.fields.ACCOUNT;
if (status === 'skipped') {
stopSpinner(
account
? `Signal already linked as ${k.cyan(account)}.`
: 'Signal already linked.',
);
} else if (status === 'success') {
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
} else if (status === 'failed') {
const err = block.fields.ERROR ?? 'unknown';
stopSpinner(`Signal link failed: ${err}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('signal-auth', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/**
* Render the raw linking URL as a block-art QR, returned line-by-line so
* the caller can count lines for in-place cleanup. Uses small-mode so the
* code stays scannable on 24-row terminals. If qrcode isn't installed
* (add-signal.sh should have handled it, but we're defensive), fall back
* to the raw URL and ask the user to paste it into an external renderer.
*/
async function renderQr(url: string): Promise<string[]> {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
const caption = k.dim(
' Signal → Settings → Linked Devices → Link New Device → scan.',
);
return [...qrText.trimEnd().split('\n'), '', caption];
} catch {
return [
'Linking URL (render at https://qr.io or similar):',
'',
url,
'',
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
];
}
}
/** Persist SIGNAL_ACCOUNT to .env. */
function writeSignalAccount(account: string): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
contents = contents.replace(
/^SIGNAL_ACCOUNT=.*$/m,
`SIGNAL_ACCOUNT=${account}`,
);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += `SIGNAL_ACCOUNT=${account}\n`;
}
fs.writeFileSync(envPath, contents);
setupLog.userInput('signal_account', account);
}
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your Signal account…');
const start = Date.now();
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
stdio: 'ignore',
});
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
}
}
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop(`Restart may have failed: ${message}`, 1);
setupLog.step('signal-restart', 'failed', Date.now() - start, {
ERROR: message,
});
// Non-fatal — the user can restart manually if init-first-agent fails.
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
+551
View File
@@ -0,0 +1,551 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
*
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
* URL) or a public webhook
* 2. Walk through creating a Slack app (api.slack.com/apps) scopes,
* events, and the mode-specific credential (app-level token for
* Socket Mode, signing secret for webhook)
* 3. Paste the bot token + that credential (clack password prompts)
* 4. Validate via auth.test resolves workspace + bot identity
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
* 6. Ask for the operator's Slack user ID
* 7. conversations.open to get the DM channel ID
* 8. Ask for the messaging-agent name (defaulting to "Nano")
* 9. Wire the agent via scripts/init-first-agent.ts
*
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
* greeting in Slack immediately; inbound replies require webhooks, so the
* post-install note covers that.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { openUrl } from '../lib/browser.js';
import { isHeadless } from '../platform.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo {
teamName: string;
teamId: string;
botName: string;
botUserId: string;
}
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
// credential to collect and which post-install guidance to show.
type SlackMode = 'socket' | 'webhook';
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
const mode = await askSlackMode();
const intro = await walkThroughAppCreation(mode);
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
const token = await collectBotToken();
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
const info = await validateSlackToken(token);
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
if (appToken) env.SLACK_APP_TOKEN = appToken;
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
const install = await runQuietChild(
'slack-install',
'bash',
['setup/add-slack.sh'],
{
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
done: 'Slack adapter installed.',
},
{
env,
extraFields: {
MODE: mode,
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
},
},
);
if (!install.ok) {
await fail(
'slack-install',
"Couldn't connect Slack.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const ownerUserId = await collectSlackUserId();
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `slack:${dmChannelId}`;
const role = await askOperatorRole('Slack');
setupLog.userInput('slack_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'slack',
'--user-id', `slack:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Wiring ${agentName} to your Slack DMs…`,
done: 'Agent wired.',
},
{
extraFields: {
CHANNEL: 'slack',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/init-first-agent` in Claude Code.',
);
}
showPostInstallChecklist(info, mode);
}
async function askSlackMode(): Promise<SlackMode> {
const choice = ensureAnswer(
await brightSelect<SlackMode>({
message: 'How should Slack deliver events to NanoClaw?',
initialValue: 'socket',
options: [
{
value: 'socket',
label: 'Socket Mode',
hint: 'no public URL — recommended for local or behind NAT',
},
{
value: 'webhook',
label: 'Public webhook',
hint: 'needs a public HTTPS Request URL',
},
],
}),
);
setupLog.userInput('slack_mode', String(choice));
return choice;
}
async function walkThroughAppCreation(mode: SlackMode): Promise<'continue' | 'back'> {
const credSteps =
mode === 'socket'
? [
' 4. Basic Information → App-Level Tokens → "Generate Token and',
' Scopes" → add the connections:write scope → copy it (xapp-…)',
' 5. Socket Mode → toggle "Enable Socket Mode" on',
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
]
: [
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
];
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
const linkBlock = isHeadless()
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
: [];
note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
...linkBlock,
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' • im:write, im:history',
' • channels:read, channels:history',
' • groups:read, groups:history',
' • chat:write',
' • users:read',
' • reactions:write',
' • files:read, files:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
...credSteps,
].join('\n'),
'Create a Slack app',
);
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
// Slack app settings" so users can bail out of Slack before we open the
// browser or ask for tokens.
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
message: 'Open Slack app settings in your browser?',
options: [
{ value: 'open', label: 'Open Slack app settings' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'open',
}));
if (choice === 'back') return 'back';
if (!isHeadless()) openUrl(SLACK_APPS_URL);
ensureAnswer(
await p.confirm({
message:
mode === 'socket'
? 'Got your bot token and app-level token?'
: 'Got your bot token and signing secret?',
initialValue: true,
}),
);
return 'continue';
}
async function collectBotToken(): Promise<string> {
const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_bot_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
if (t.length < 24) return "That's shorter than a real Slack bot token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_bot_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function collectSigningSecret(): Promise<string> {
const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_signing_secret', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
// Slack signing secrets are 32-char hex strings, but newer apps
// sometimes emit longer variants — leniently require hex only.
if (!/^[a-f0-9]{16,}$/i.test(t)) {
return 'Signing secrets are a string of hex characters';
}
return undefined;
},
}),
);
const secret = (answer as string).trim();
setupLog.userInput(
'slack_signing_secret',
`${secret.slice(0, 4)}${secret.slice(-4)}`,
);
return secret;
}
async function collectAppToken(): Promise<string> {
const existing = readEnvKey('SLACK_APP_TOKEN');
if (existing && existing.startsWith('xapp-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack app-level token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_app_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack app-level token (Socket Mode)',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'App-level token is required for Socket Mode';
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
if (t.length < 24) return "That's shorter than a real Slack app-level token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_app_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${SLACK_API}/auth.test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const data = (await res.json()) as {
ok?: boolean;
team?: string;
team_id?: string;
user?: string;
user_id?: string;
error?: string;
};
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
teamId: data.team_id ?? '',
botName: data.user,
botUserId: data.user_id ?? '',
};
setupLog.step('slack-validate', 'success', Date.now() - start, {
BOT_NAME: info.botName,
BOT_USER_ID: info.botUserId,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return info;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Slack didn't accept that token: ${reason}`, 1);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'slack-validate',
"Slack didn't accept that token.",
reason === 'invalid_auth' || reason === 'token_revoked'
? 'Copy the token again from OAuth & Permissions and retry setup.'
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-validate',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function collectSlackUserId(): Promise<string> {
note(
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (bottom left)',
' 2. Click "Profile"',
' 3. Click the three dots (⋮) → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Slack member ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Member ID is required';
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
return "That doesn't look like a Slack member ID (starts with U)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('slack_user_id', id);
return id;
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${SLACK_API}/conversations.open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const data = (await res.json()) as {
ok?: boolean;
channel?: { id?: string };
error?: string;
};
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
return data.channel.id;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
if (reason === 'missing_scope') {
await fail(
'slack-open-dm',
"Your Slack app is missing the im:write scope.",
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
);
}
await fail(
'slack-open-dm',
"Couldn't open a DM channel with you.",
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-open-dm',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
if (mode === 'socket') {
note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`Socket Mode is on — ${info.teamName} reaches NanoClaw over an outbound`,
`WebSocket, so there's no public URL to configure.`,
'',
' • Just DM @' + info.botName + ' from Slack — replies flow straight away.',
'',
' • Keep the NanoClaw host running to hold the socket open —',
' Slack does not retry delivery while it is down.',
].join('\n'),
6,
),
'Finish setting up Slack',
);
return;
}
note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
'',
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • Save Changes',
'',
' 3. In your Slack app → Interactivity & Shortcuts:',
' • Toggle "Interactivity" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
].join('\n'),
6,
),
'Finish setting up Slack',
);
}
-44
View File
@@ -1,44 +0,0 @@
/**
* Tiny CLI wrapper around `buildTeamsAppPackage` so the add-teams SKILL.md can
* generate the sideload app package with a single declarative step instead of an
* inline `tsx -e` blob. The whole zip-building logic (manifest + icons, no
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just
* maps a couple of CLI flags onto it and prints the resulting zip path.
*
* Mirrors the bespoke `stepGenerateManifest` in the old setup/channels/teams.ts:
* the short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
* description is "<name> personal assistant powered by NanoClaw.", the website
* URL is the operator's public base URL, and the output lands in data/teams/.
*
* Usage:
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
* --app-id <azure-app-id> --url https://your-domain [--out data/teams]
*/
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
function flag(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
const appId = flag('app-id');
const url = flag('url');
const outDir = flag('out') ?? 'data/teams';
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
if (!appId || !url) {
console.error(
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>]',
);
process.exit(2);
}
const result = buildTeamsAppPackage({
appId,
shortName,
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
websiteUrl: url,
outDir,
});
console.log(`Teams app package: ${result.zipPath}`);
+753
View File
@@ -0,0 +1,753 @@
/**
* Microsoft Teams channel flow for setup:auto.
*
* Teams is the most complex channel NanoClaw supports the Slack/Discord
* "paste a token" shortcut doesn't exist. The operator has to walk through
* ~7 Azure portal steps (app registration, client secret, Azure Bot
* resource, messaging endpoint, Teams channel enable, manifest, sideload).
*
* This driver's job is to make each of those steps as guided as possible
* inside the terminal:
* 1. Print a clack note with the exact sub-steps and the portal URL.
* 2. Ask for the value(s) that step yields (App ID, secret, tenant, etc.).
* 3. At every step boundary, offer `stepGate` a Done / Stuck / Show-again
* select. "Stuck" hands off to interactive Claude with full context.
*
* Text/password prompts also accept `?` as an answer to trigger the handoff,
* so the operator can escape at any paste point without scrolling back to a
* step boundary.
*
* What's deferred (known limitation, instruct user how to finish manually):
* - Wait-for-first-DM to capture the auto-generated Teams platformId.
* Unlike Discord/Telegram, the Teams platform_id is only discoverable
* after the first inbound activity. The driver installs the adapter and
* stops there; the operator DMs the bot, NanoClaw auto-creates the
* messaging group, and they wire an agent via `/manage-channels`.
*/
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import {
isHelpEscape,
offerClaudeHandoff,
validateWithHelpEscape,
type HandoffContext,
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
const AZURE_PORTAL_URL = 'https://portal.azure.com';
interface Collected {
publicUrl?: string;
appId?: string;
tenantId?: string;
appType?: 'SingleTenant' | 'MultiTenant';
appPassword?: string;
agentName?: string;
}
export async function runTeamsChannel(_displayName: string): Promise<ChannelFlowResult> {
const collected: Collected = {};
const completed: string[] = [];
const existingAppId = readEnvKey('TEAMS_APP_ID');
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
if (existingAppId && existingPassword) {
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
options: [
{ value: 'yes', label: 'Yes, use the existing credentials' },
{ value: 'no', label: "No, set up new ones" },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'yes',
}));
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
if (choice === 'yes') {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);
completed.push('Adapter installed and service restarted (reused existing credentials).');
await finishWithHandoff(collected, completed);
return;
}
}
printIntro();
const prereqsResult = await confirmPrereqs({ collected, completed });
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
await stepPublicUrl({ collected, completed });
if (await stepAppRegistration({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepClientSecret({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepAzureBot({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
const manifestResult = await stepGenerateManifest({ collected, completed });
if (
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
=== 'back'
) {
return BACK_TO_CHANNEL_SELECTION;
}
await installAdapter(collected);
completed.push('Adapter installed and service restarted.');
await finishWithHandoff(collected, completed);
}
// ─── step: intro / prereqs ──────────────────────────────────────────────
function printIntro(): void {
note(
[
'Setting up Teams is more involved than the other channels — about',
'7 steps across the Azure portal and Teams admin.',
'',
k.dim("At any prompt you can type '?' and press Enter to hand off"),
k.dim("to Claude interactive mode with your current progress."),
k.dim("You can also pick 'Stuck' at any Done/Stuck/Show-again prompt."),
].join('\n'),
'Microsoft Teams setup',
);
}
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> {
note(
[
'Before we start, confirm you have:',
'',
' • A Microsoft 365 tenant where you can sideload custom apps',
' (free personal Teams does NOT support this — you need a',
' Microsoft 365 Business / EDU / developer tenant)',
' • Teams admin or developer tenant rights',
' • A way to expose an HTTPS endpoint from this machine',
' (ngrok, Cloudflare Tunnel, or a reverse-proxied VPS)',
].join('\n'),
'Prereqs',
);
// Back-aware variant of stepGate — Back is only offered on the very first
// step of the Teams flow so users can bail out before any state is taken.
while (true) {
const choice = ensureAnswer(
await brightSelect<'done' | 'help' | 'reshow' | 'back'>({
message: 'How did that go?',
options: [
{ value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice === 'back') return 'back';
if (choice === 'done') break;
if (choice === 'help') {
await offerHandoff({
step: 'teams-prereqs',
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
args,
});
continue;
}
if (choice === 'reshow') {
return confirmPrereqs(args);
}
}
args.completed.push('Prereqs confirmed.');
return 'continue';
}
// ─── step: public URL ──────────────────────────────────────────────────
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
note(
[
"Azure Bot Service delivers messages to an HTTPS endpoint you",
"control. The endpoint needs to reach this machine's webhook",
"server at /api/webhooks/teams.",
'',
k.dim('Examples:'),
k.dim(' ngrok http 3000 → https://abcd1234.ngrok.io'),
k.dim(' cloudflared tunnel … → https://<tunnel>.trycloudflare.com'),
k.dim(' or a reverse proxy on your own domain'),
'',
"If you don't have a tunnel running yet, start one in another",
"terminal, then come back here.",
].join('\n'),
'Public HTTPS URL',
);
while (true) {
const answer = ensureAnswer(
await p.text({
message: 'Paste your public base URL (e.g. https://abcd1234.ngrok.io)',
placeholder: 'https://…',
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
if (!/^https:\/\/[^\s/]+/.test(t)) {
return 'Must be an https:// URL (Azure rejects http)';
}
return undefined;
}),
}),
);
if (isHelpEscape(answer)) {
await offerHandoff({
step: 'teams-public-url',
stepDescription:
'setting up a public HTTPS tunnel to reach this machine on port 3000',
args,
});
continue;
}
const url = (answer as string).trim().replace(/\/$/, '');
args.collected.publicUrl = url;
setupLog.userInput('teams_public_url', url);
break;
}
args.completed.push(`Public URL: ${args.collected.publicUrl}`);
}
// ─── step: Azure App Registration ──────────────────────────────────────
async function stepAppRegistration(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
[
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
'2. Name it (e.g. "NanoClaw")',
'3. Supported account types: Single tenant (your org only) OR',
' Multi tenant (any Microsoft 365 tenant can add the bot)',
'4. Click Register',
'5. On the Overview page, copy:',
' • Application (client) ID',
' • Directory (tenant) ID',
].join('\n'),
'Step 1 of 6 — Create Azure App Registration',
);
await confirmThenOpen(
AZURE_PORTAL_URL,
'Press Enter to open the Azure portal',
);
args.collected.appType = await askAppType(args);
args.collected.appId = await askUuid(
'Paste the Application (client) ID',
'teams-app-id',
args,
);
if (args.collected.appType === 'SingleTenant') {
args.collected.tenantId = await askUuid(
'Paste the Directory (tenant) ID',
'teams-tenant-id',
args,
);
}
const gate = await stepGate({
stepName: 'teams-app-registration',
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
reshow: () => stepAppRegistration(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push(
`App registered: ${args.collected.appId} (${args.collected.appType})`,
);
return 'continue';
}
async function askAppType(args: {
collected: Collected;
completed: string[];
}): Promise<'SingleTenant' | 'MultiTenant'> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
message: 'Which account type did you pick?',
options: [
{
value: 'SingleTenant',
label: 'Single tenant',
hint: 'your org only — most common for self-host',
},
{
value: 'MultiTenant',
label: 'Multi tenant',
hint: 'any Microsoft 365 tenant can install the bot',
},
{ value: 'help', label: 'Stuck — hand me off to Claude' },
],
}),
);
if (choice === 'help') {
await offerHandoff({
step: 'teams-app-type',
stepDescription: "deciding between Single tenant and Multi tenant for their Azure app",
args,
});
continue;
}
return choice as 'SingleTenant' | 'MultiTenant';
}
}
// ─── step: client secret ───────────────────────────────────────────────
async function stepClientSecret(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
[
`1. In your app registration, open "Certificates & secrets"`,
'2. Click "New client secret"',
' Description: nanoclaw',
' Expires: 180 days (recommended) or longer',
'3. Click Add',
'4. ' + k.yellow('COPY THE VALUE NOW — Azure only shows it once'),
' (the Value column, not the Secret ID)',
].join('\n'),
'Step 2 of 6 — Create a client secret',
);
while (true) {
const answer = ensureAnswer(
await p.password({
message: 'Paste the client secret Value',
clearOnError: true,
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
if (t.length < 20) return "That looks too short — make sure you copied the Value, not the Secret ID";
return undefined;
}),
}),
);
if (isHelpEscape(answer)) {
await offerHandoff({
step: 'teams-client-secret',
stepDescription: 'creating and copying the client secret value from Azure',
args,
});
continue;
}
args.collected.appPassword = (answer as string).trim();
setupLog.userInput(
'teams_client_secret',
`${args.collected.appPassword.slice(0, 4)}${args.collected.appPassword.slice(-4)}`,
);
break;
}
const gate = await stepGate({
stepName: 'teams-client-secret',
stepDescription: 'creating and copying the client secret',
reshow: () => stepClientSecret(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Client secret captured.');
return 'continue';
}
// ─── step: Azure Bot resource ──────────────────────────────────────────
async function stepAzureBot(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
const tenantFlag =
args.collected.appType === 'SingleTenant'
? `--tenant-id ${args.collected.tenantId} `
: '';
const cliCommand =
`az bot create \\\n` +
` --resource-group nanoclaw-rg \\\n` +
` --name nanoclaw-bot \\\n` +
` --app-type ${args.collected.appType} \\\n` +
` --appid ${args.collected.appId} \\\n` +
` ${tenantFlag}--endpoint "${endpoint}"`;
note(
[
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
'',
' • Bot handle: unique name, e.g. nanoclaw-bot',
` • Type of App: ${args.collected.appType}`,
' • Creation type: Use existing app registration',
` • App ID: ${args.collected.appId ?? '<pending>'}`,
...(args.collected.appType === 'SingleTenant'
? [` • App tenant ID: ${args.collected.tenantId ?? '<pending>'}`]
: []),
'',
'After creating, open the bot → Configuration and set:',
` Messaging endpoint: ${k.cyan(endpoint)}`,
'',
k.dim('Or via Azure CLI (if you have az installed):'),
k.dim(cliCommand),
].join('\n'),
'Step 3 of 6 — Create Azure Bot resource',
);
const gate = await stepGate({
stepName: 'teams-azure-bot',
stepDescription:
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
reshow: () => stepAzureBot(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Azure Bot created; messaging endpoint configured.');
return 'continue';
}
// ─── step: enable Teams channel ────────────────────────────────────────
async function stepEnableTeamsChannel(args: {
collected: Collected;
completed: string[];
}): Promise<'continue' | 'back'> {
note(
[
'1. Open your Azure Bot resource → Channels',
'2. Click Microsoft Teams → Accept terms → Apply',
'',
k.dim('CLI alternative:'),
k.dim(' az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot'),
].join('\n'),
'Step 4 of 6 — Enable Teams channel on the bot',
);
const gate = await stepGate({
stepName: 'teams-enable-channel',
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
reshow: () => stepEnableTeamsChannel(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Teams channel enabled on the bot.');
return 'continue';
}
// ─── step: manifest zip ────────────────────────────────────────────────
async function stepGenerateManifest(args: {
collected: Collected;
completed: string[];
}): Promise<{ zipPath: string }> {
if (!args.collected.appId) {
fail(
'teams-manifest',
'Missing Azure App ID.',
"That's an internal bug — open an issue or retry setup.",
);
}
const shortName =
process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
const s = p.spinner();
s.start('Generating your Teams app package…');
try {
const result = buildTeamsAppPackage({
appId: args.collected.appId!,
shortName,
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
websiteUrl: args.collected.publicUrl!,
outDir: MANIFEST_DIR,
});
s.stop(`Package ready: ${k.cyan(shortPath(result.zipPath))}`);
setupLog.step('teams-manifest', 'success', 0, {
ZIP: result.zipPath,
});
args.completed.push(`Generated manifest zip at ${shortPath(result.zipPath)}.`);
return { zipPath: result.zipPath };
} catch (err) {
s.stop("Couldn't build the manifest zip.", 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('teams-manifest', 'failed', 0, { ERROR: message });
fail(
'teams-manifest',
"Couldn't generate the Teams app package.",
'Make sure `zip` is available on your PATH, then retry.',
);
}
}
// ─── step: sideload ────────────────────────────────────────────────────
async function stepSideload(args: {
collected: Collected;
completed: string[];
zipPath: string;
}): Promise<'continue' | 'back'> {
note(
[
'1. Open Microsoft Teams',
'2. Go to Apps → Manage your apps → Upload an app',
'3. Click "Upload a custom app" (or "Upload for me or my teams")',
`4. Select: ${k.cyan(args.zipPath)}`,
'5. Click Add',
'',
k.dim('If "Upload a custom app" is missing, your tenant admin has'),
k.dim('disabled sideloading. Enable it in Teams Admin Center →'),
k.dim('Teams apps → Setup policies → Global → Upload custom apps = On'),
].join('\n'),
'Step 5 of 6 — Sideload the app into Teams',
);
const gate = await stepGate({
stepName: 'teams-sideload',
stepDescription: 'uploading the generated zip into Teams as a custom app',
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
args,
});
if (gate === 'back') return 'back';
args.completed.push('App sideloaded into Teams.');
return 'continue';
}
// ─── step: install adapter ─────────────────────────────────────────────
async function installAdapter(collected: Collected): Promise<void> {
const env: Record<string, string> = {
TEAMS_APP_ID: collected.appId!,
TEAMS_APP_PASSWORD: collected.appPassword!,
TEAMS_APP_TYPE: collected.appType!,
};
if (collected.appType === 'SingleTenant') {
env.TEAMS_APP_TENANT_ID = collected.tenantId!;
}
const install = await runQuietChild(
'teams-install',
'bash',
['setup/add-teams.sh'],
{
running: 'Installing the Teams adapter and restarting the service…',
done: 'Teams adapter installed.',
},
{
env,
extraFields: {
APP_ID: collected.appId!,
APP_TYPE: collected.appType!,
},
},
);
if (!install.ok) {
fail(
'teams-install',
"Couldn't install the Teams adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
}
// ─── post-install: hand off to Claude for the final wiring ────────────
async function finishWithHandoff(
collected: Collected,
completed: string[],
): Promise<void> {
note(
[
'The Teams adapter is live and the service is running.',
'',
"One thing left: your Teams bot's platform ID (which NanoClaw needs",
'to wire to an agent group) only becomes known after you DM the bot',
'for the first time. Claude can walk you through that interactively —',
'watch the logs for your first inbound, find the auto-created',
'messaging group in the DB, run scripts/init-first-agent.ts with',
'the right flags, and verify end-to-end.',
].join('\n'),
'Step 6 of 6 — Finish wiring',
);
const choice = ensureAnswer(
await brightSelect({
message: 'Ready to finish?',
options: [
{
value: 'handoff',
label: 'Hand me off to Claude to walk me through it',
hint: 'recommended',
},
{ value: 'self', label: "I'll do it myself" },
],
}),
);
if (choice === 'self') {
note(
[
' 1. Find your bot in Teams (search by name, or via the sideloaded',
' app) and send it a message ("hi" is fine)',
' 2. Tail ' + k.cyan('logs/nanoclaw.log') + ' for the inbound; the router',
' auto-creates a row in ' + k.cyan('messaging_groups') + ' in data/v2.db',
' 3. Run ' + k.cyan('scripts/init-first-agent.ts') + ' with --channel teams,',
' the discovered platform_id, and your AAD user id, OR use',
' ' + k.cyan('/manage-channels') + ' to wire interactively',
].join('\n'),
'Manual finish',
);
return;
}
await offerClaudeHandoff({
channel: CHANNEL,
step: 'teams-finish-wiring',
stepDescription:
'finishing the Teams wiring: watch for the first inbound, discover the auto-created messaging group in data/v2.db, and run scripts/init-first-agent.ts to wire it to an agent group',
completedSteps: completed,
collectedValues: redactCollected(collected),
files: [
'scripts/init-first-agent.ts',
'src/router.ts',
'src/db/messaging-groups.ts',
'logs/nanoclaw.log',
'.claude/skills/manage-channels/SKILL.md',
],
});
}
// ─── shared step gate ──────────────────────────────────────────────────
async function stepGate(args: {
stepName: string;
stepDescription: string;
reshow: () => Promise<'continue' | 'back'>;
args: { collected: Collected; completed: string[] };
}): Promise<'continue' | 'back'> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
message: 'How did that go?',
options: [
{ value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice === 'done') return 'continue';
if (choice === 'back') return 'back';
if (choice === 'help') {
await offerHandoff({
step: args.stepName,
stepDescription: args.stepDescription,
args: args.args,
});
continue;
}
if (choice === 'reshow') {
return args.reshow();
}
}
}
async function offerHandoff(args: {
step: string;
stepDescription: string;
args: { collected: Collected; completed: string[] };
}): Promise<void> {
const ctx: HandoffContext = {
channel: CHANNEL,
step: args.step,
stepDescription: args.stepDescription,
completedSteps: args.args.completed.slice(),
collectedValues: redactCollected(args.args.collected),
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
};
await offerClaudeHandoff(ctx);
}
function redactCollected(c: Collected): Record<string, string> {
const out: Record<string, string> = {};
if (c.publicUrl) out.publicUrl = c.publicUrl;
if (c.appId) out.appId = c.appId;
if (c.tenantId) out.tenantId = c.tenantId;
if (c.appType) out.appType = c.appType;
if (c.appPassword) {
out.appPassword = `${c.appPassword.slice(0, 4)}${c.appPassword.slice(-4)}`;
}
return out;
}
// ─── shared: UUID paste with help escape ───────────────────────────────
async function askUuid(
message: string,
logKey: string,
args: { collected: Collected; completed: string[] },
): Promise<string> {
while (true) {
const answer = ensureAnswer(
await p.text({
message,
placeholder: '00000000-0000-0000-0000-000000000000',
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(t)) {
return 'Expected a UUID like 00000000-0000-0000-0000-000000000000';
}
return undefined;
}),
}),
);
if (isHelpEscape(answer)) {
await offerHandoff({
step: logKey,
stepDescription: `entering a UUID for ${logKey}`,
args,
});
continue;
}
const value = (answer as string).trim().toLowerCase();
setupLog.userInput(logKey, value);
return value;
}
}
// ─── path helpers ──────────────────────────────────────────────────────
function shortPath(abs: string): string {
const home = os.homedir();
const cwd = process.cwd();
if (abs.startsWith(`${cwd}/`)) return abs.slice(cwd.length + 1);
if (abs.startsWith(`${home}/`)) return `~/${abs.slice(home.length + 1)}`;
return abs;
}
+361
View File
@@ -0,0 +1,361 @@
/**
* Telegram channel flow for setup:auto.
*
* `runTelegramChannel(displayName)` owns the full branch from the
* BotFather instructions through the welcome DM:
*
* 1. BotFather instructions (clack note)
* 2. Paste the bot token (clack password) format-validated
* 3. getMe via the Bot API to resolve the bot's username
* 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve)
* 5. Install the adapter (setup/add-telegram.sh, non-interactive)
* 6. Run the pair-telegram step, rendering code events as clack notes
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
*
* All output obeys the three-level contract: clack UI for the user,
* structured entries in logs/setup.log, full raw output in per-step files
* under logs/setup-steps/. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runTelegramChannel(displayName: string): Promise<ChannelFlowResult> {
const tokenOrBack = await collectTelegramToken();
if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION;
const token = tokenOrBack;
const botUsername = await validateTelegramToken(token);
// Deep-link the user into the bot's chat so they're on the right screen
// by the time pair-telegram prints the code. https://t.me/<bot> works
// everywhere: browsers show an "Open in Telegram" button when the app is
// installed, or the bot's web profile if not. tg://resolve?domain= is
// more direct but silently fails when the scheme isn't registered.
const botUrl = `https://t.me/${botUsername}`;
// Two card variants — auto-open fires only on GUI, so headless users
// need full self-serve instructions inside the card itself, while GUI
// users get a leaner status line plus the auto-open + a single
// combined dim fallback line (URL + mobile alternative) on the
// confirm prompt below.
if (isHeadless()) {
note(
[
`Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`,
'',
`Get started: ${botUrl}`,
'',
`Don't have Telegram installed here? Open it on any device and search for @${botUsername}`,
].join('\n'),
'Open Telegram',
);
} else {
note(
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'Open Telegram',
);
ensureAnswer(
await p.confirm({
message: `Press Enter to open Telegram (must be installed here)\n${k.dim(
`If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`,
)}`,
initialValue: true,
}),
);
openUrl(botUrl);
}
const install = await runQuietChild(
'telegram-install',
'bash',
['setup/add-telegram.sh'],
{
running: `Connecting Telegram to @${botUsername}`,
done: 'Telegram connected.',
},
{
env: { TELEGRAM_BOT_TOKEN: token },
extraFields: { BOT_USERNAME: botUsername },
},
);
if (!install.ok) {
await fail(
'telegram-install',
"Couldn't connect Telegram.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const pair = await runPairTelegram();
if (!pair.ok) {
await fail(
'pair-telegram',
"Couldn't pair with Telegram.",
'Re-run setup to try again.',
);
}
const platformId = pair.terminal?.fields.PLATFORM_ID;
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
if (!platformId || !pairedUserId) {
await fail(
'pair-telegram',
'Pairing completed but came back incomplete.',
'Re-run setup to try again.',
);
}
const role = await askOperatorRole('Telegram');
setupLog.userInput('telegram_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'telegram',
'--user-id', pairedUserId,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to your Telegram chat…`,
done: `${agentName} is ready. Check Telegram for a welcome message.`,
},
{
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function collectTelegramToken(): Promise<string | 'back'> {
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
options: [
{ value: 'yes', label: 'Yes, use the existing token' },
{ value: 'no', label: 'No, paste a new one' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'yes',
}));
if (choice === 'back') return 'back';
if (choice === 'yes') {
setupLog.userInput('telegram_token', 'reused-existing');
return existing;
}
// 'no' falls through to the paste flow below
}
note(
[
"Your assistant talks to you through a Telegram bot you create.",
"Here's how:",
'',
" 1. Open Telegram and message @BotFather — Telegram's official bot for creating and managing bots",
' 2. Send /newbot and follow the prompts',
' 3. Copy the token it gives you (it looks like <digits>:<chars>)',
'',
k.dim('Planning to add your assistant to group chats? In @BotFather:'),
k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'),
].join('\n'),
'Set up your Telegram bot',
);
// Back-aware gate before the password prompt — `p.password` doesn't
// accept extra options, so we offer Back as a separate brightSelect
// immediately after the BotFather instructions and before the paste.
const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({
message: 'Ready to paste your bot token?',
options: [
{ value: 'continue', label: 'Yes, paste it on the next prompt' },
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'continue',
}));
if (proceed === 'back') return 'back';
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
if (!v || !v.trim()) return "Token is required";
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
return "That doesn't look right. It should be <digits>:<chars>";
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'telegram_token',
`${token.slice(0, 12)}${token.slice(-4)}`,
);
return token;
}
async function validateTelegramToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
const data = (await res.json()) as {
ok?: boolean;
result?: { username?: string; id?: number };
description?: string;
};
if (data.ok && data.result?.username) {
const username = data.result.username;
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
});
return username;
}
const reason = data.description ?? 'token rejected by Telegram';
s.stop(`Telegram didn't accept that token: ${reason}`, 1);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'telegram-validate',
"Telegram didn't accept that token.",
'Copy the token again from @BotFather and try setup once more.',
);
} catch (err) {
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'telegram-validate',
"Couldn't reach Telegram.",
'Check your internet connection and retry setup.',
);
}
}
async function runPairTelegram(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('pair-telegram');
const start = Date.now();
const s = p.spinner();
s.start('Generating a secret code for your bot…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number) => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
const result = await spawnStep(
'pair-telegram',
['--intent', 'main'],
(block: Block) => {
if (block.type === 'PAIR_TELEGRAM_CODE') {
const reason = block.fields.REASON ?? 'initial';
if (reason === 'initial') {
stopSpinner('Your secret code is ready.');
} else {
stopSpinner("Old code expired. Here's a fresh one.");
}
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
s.start(fitToWidth('Waiting for the correct code…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
stopSpinner('Telegram paired.');
} else {
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
// Safety net: if the child died without emitting a terminal block, make
// sure we don't leave the spinner running.
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Pairing ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('pair-telegram', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
function formatCodeCard(code: string): string {
const spaced = code.split('').join(' ');
return [
'',
` ${brandBold(spaced)}`,
'',
k.dim(' Send this code to your bot from Telegram.'),
].join('\n');
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
+473
View File
@@ -0,0 +1,473 @@
/**
* WhatsApp (community/Baileys) channel flow for setup:auto.
*
* `runWhatsAppChannel(displayName)` owns the full branch from auth-method
* picker through the welcome DM:
*
* 1. Ask how to authenticate (QR code in terminal, default, or pairing code)
* 2. If pairing-code: collect the phone number
* 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
* 4. Run the whatsapp-auth step, rendering status blocks as clack UI:
* - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art
* inside a clack note. On rotation we clear the previous QR in-place
* via ANSI escapes so the terminal doesn't fill up with stale codes.
* - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card.
* 5. Read store/auth/creds.json extract the authenticated (bot) phone
* 6. Kick the service so the adapter picks up the new credentials
* 7. Ask the operator for the phone they'll chat from (defaults to the
* authed number). Different number dedicated mode also writes
* ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed
* 8. Ask for the messaging-agent name (defaulting to "Nano")
* 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* DM path delivers the greeting through the adapter
*
* All output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
type AuthMethod = 'qr' | 'pairing-code';
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
const method = await askAuthMethod();
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
const install = await runQuietChild(
'whatsapp-install',
'bash',
['setup/add-whatsapp.sh'],
{
running: 'Installing the WhatsApp adapter…',
done: 'WhatsApp adapter installed.',
skipped: 'WhatsApp adapter already installed.',
},
);
if (!install.ok) {
fail(
'whatsapp-install',
"Couldn't install the WhatsApp adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const auth = await runWhatsAppAuth(method, phone);
if (!auth.ok) {
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
fail(
'whatsapp-auth',
`WhatsApp authentication failed (${reason}).`,
reason === 'qr_timeout' || reason === 'timeout'
? 'The code expired. Re-run setup to get a fresh one.'
: 'Re-run setup to try again.',
);
}
const botPhone = readAuthedPhone();
if (!botPhone) {
fail(
'whatsapp-auth',
"Authenticated but couldn't read your WhatsApp number from the saved credentials.",
'Re-run setup to try again.',
);
}
await restartService();
const chatPhone = await askChatPhone(botPhone);
const isDedicated = chatPhone !== botPhone;
if (isDedicated) {
writeAssistantHasOwnNumber();
}
const role = await askOperatorRole('WhatsApp');
setupLog.userInput('whatsapp_role', role);
const agentName = await resolveAgentName();
const platformId = `${chatPhone}@s.whatsapp.net`;
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'whatsapp',
'--user-id', platformId,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to WhatsApp…`,
done: isDedicated
? `${agentName} is ready. Check WhatsApp for a welcome message.`
: `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`,
},
{
extraFields: {
CHANNEL: 'whatsapp',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
MODE: isDedicated ? 'dedicated' : 'shared',
ROLE: role,
},
},
);
if (!init.ok) {
fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to authenticate with WhatsApp?',
options: [
{
value: 'qr',
label: 'Scan a QR code in this terminal',
hint: 'recommended',
},
{
value: 'pairing-code',
label: 'Enter a pairing code on your phone',
hint: 'no camera needed',
},
{
value: 'back',
label: '← Back to channel selection',
},
],
}),
) as AuthMethod | 'back';
if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice);
return choice;
}
async function askPhoneNumber(): Promise<string> {
note(
[
"Enter your phone number the way WhatsApp expects it:",
'',
' • Digits only — no +, spaces, or dashes',
' • Country code first, then the rest of the number',
'',
k.dim('Example: 14155551234 (country code 1, then 4155551234)'),
].join('\n'),
'Your phone number',
);
const answer = ensureAnswer(
await p.text({
message: 'Phone number',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Phone number is required';
if (!/^\d{8,15}$/.test(t)) {
return "That doesn't look right. Digits only, country code included.";
}
return undefined;
},
}),
);
const phone = (answer as string).trim();
setupLog.userInput('whatsapp_phone', phone);
return phone;
}
async function runWhatsAppAuth(
method: AuthMethod,
phone: string | undefined,
): Promise<StepResult & { rawLog: string; durationMs: number }> {
const rawLog = setupLog.stepRawLog('whatsapp-auth');
const start = Date.now();
const s = p.spinner();
s.start('Starting WhatsApp authentication…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number) => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
// Tracks the QR render so we can overwrite it in-place on rotation. null
// before the first QR is printed.
let qrLinesPrinted = 0;
const extra =
method === 'pairing-code' && phone
? ['--method', 'pairing-code', '--phone', phone]
: ['--method', 'qr'];
const result = await spawnStep(
'whatsapp-auth',
extra,
(block: Block) => {
if (block.type === 'WHATSAPP_AUTH_QR') {
const qr = block.fields.QR ?? '';
if (!qr) return;
// Fire-and-forget — await inside spawnStep's sync onBlock is fine
// since spawnStep's own logic keeps running in parallel.
void renderQr(qr).then((lines) => {
if (qrLinesPrinted === 0) {
stopSpinner('QR code ready — scan with WhatsApp.');
} else {
// Cursor up N lines + clear from there to end of screen. Wipes
// the previous QR + caption so the new one renders in place.
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
}
process.stdout.write(lines.join('\n') + '\n');
qrLinesPrinted = lines.length;
});
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
const code = block.fields.CODE ?? '????';
stopSpinner('Your pairing code is ready.');
note(formatPairingCard(code), 'Pairing code');
s.start('Waiting for you to enter the code…');
spinnerActive = true;
} else if (block.type === 'WHATSAPP_AUTH') {
const status = block.fields.STATUS;
if (status === 'skipped') {
stopSpinner('WhatsApp is already authenticated.');
} else if (status === 'success') {
// Erase the QR block if one was on screen — it's served its purpose.
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
// In QR flow the spinner was stopped when the first QR landed.
// Fall back to a plain success line so the user sees confirmation.
if (spinnerActive) {
stopSpinner('WhatsApp linked.');
} else {
p.log.success(brandBody('WhatsApp linked.'));
}
} else if (status === 'failed') {
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
const err = block.fields.ERROR ?? 'unknown';
if (spinnerActive) {
stopSpinner(`Authentication failed: ${err}`, 1);
} else {
p.log.error(`Authentication failed: ${err}`);
}
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
// Safety net — if the step died without emitting a terminal block, don't
// leave the spinner running.
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Authentication ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('whatsapp-auth', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/**
* Render the raw QR string to an array of terminal lines (block-art QR +
* a caption). Returned as an array so the caller can count lines for the
* in-place rewrite on rotation. Uses the small-mode QR to keep the height
* manageable on 24-row terminals.
*/
async function renderQr(qr: string): Promise<string[]> {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
const caption = k.dim(
' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.',
);
return [...qrText.trimEnd().split('\n'), '', caption];
} catch {
return ['QR code (raw): ' + qr];
}
}
function formatPairingCard(code: string): string {
// WhatsApp pairing codes are 8 characters; render with two-wide gap so the
// digits read clearly in the terminal.
const spaced = code.split('').join(' ');
return [
'',
` ${brandBold(spaced)}`,
'',
k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'),
k.dim(' → "Link with phone number instead" → enter this code.'),
k.dim(' It expires in ~60 seconds.'),
].join('\n');
}
/**
* Pull the authenticated WhatsApp phone out of store/auth/creds.json.
* `creds.me.id` looks like `14155551234:<device>@s.whatsapp.net` we want
* just the leading digit run.
*/
function readAuthedPhone(): string {
try {
const raw = fs.readFileSync(AUTH_CREDS_PATH, 'utf-8');
const creds = JSON.parse(raw) as { me?: { id?: string } };
const id = creds.me?.id;
if (!id) return '';
return id.split(':')[0].split('@')[0];
} catch {
return '';
}
}
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your WhatsApp credentials…');
const start = Date.now();
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync(
'systemctl',
['--user', 'restart', unit],
{ stdio: 'ignore' },
);
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], {
stdio: 'ignore',
});
}
}
// Give the adapter a moment to reconnect before init-first-agent's
// welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop(`Restart may have failed: ${message}`, 1);
setupLog.step('whatsapp-restart', 'failed', Date.now() - start, {
ERROR: message,
});
// Non-fatal — the user can restart manually if init-first-agent fails.
}
}
async function askChatPhone(authedPhone: string): Promise<string> {
note(
[
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
'',
"What's the phone number you'll chat with your agent from?",
'',
k.dim(
'Same number = messages will land in your "You" / self-chat on WhatsApp\n' +
"(you won't be able to reply to yourself — use a different number for a\n" +
'two-way chat).',
),
].join('\n'),
'Your chat number',
);
const answer = ensureAnswer(
await p.text({
message: 'Your personal phone number',
placeholder: authedPhone,
defaultValue: authedPhone,
validate: (v) => {
const t = (v ?? authedPhone).trim();
if (!/^\d{8,15}$/.test(t)) {
return 'Digits only, country code included.';
}
return undefined;
},
}),
);
const phone = ((answer as string) || authedPhone).trim();
setupLog.userInput('whatsapp_chat_phone', phone);
return phone;
}
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env. */
function writeAssistantHasOwnNumber(): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) {
contents = contents.replace(
/^ASSISTANT_HAS_OWN_NUMBER=.*$/m,
'ASSISTANT_HAS_OWN_NUMBER=true',
);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
}
fs.writeFileSync(envPath, contents);
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
-2
View File
@@ -25,8 +25,6 @@ const STEPS: Record<
auth: () => import('./auth.js'),
'provider-auth': () => import('./provider-auth.js'),
'cli-agent': () => import('./cli-agent.js'),
// >>> nanoclaw:setup-steps
// <<< nanoclaw:setup-steps
};
async function main(): Promise<void> {
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-discord — bundles the preflight + install commands
# from the /add-discord skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Discord adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/discord package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
needs_install=false
[[ -f src/channels/discord.ts ]] || needs_install=true
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
echo "STEP: register-import"
if ! grep -q "import './discord.js';" src/channels/index.ts; then
printf "import './discord.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/discord@4.29.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-gchat — bundles the preflight + install commands
# from the /add-gchat skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Google Chat adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/gchat package;
# builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_GCHAT ==="
needs_install=false
[[ -f src/channels/gchat.ts ]] || needs_install=true
grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/gchat"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
echo "STEP: register-import"
if ! grep -q "import './gchat.js';" src/channels/index.ts; then
printf "import './gchat.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/gchat@4.29.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Setup helper: install-imessage — bundles the preflight + install commands
# from the /add-imessage skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the iMessage adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned chat-adapter-imessage package;
# builds. Local vs remote mode pick stays in the skill — this script only
# handles the deterministic install. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ==="
needs_install=false
[[ -f src/channels/imessage.ts ]] || needs_install=true
grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true
grep -q '"chat-adapter-imessage"' package.json || needs_install=true
[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
echo "STEP: register-import"
if ! grep -q "import './imessage.js';" src/channels/index.ts; then
printf "import './imessage.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install chat-adapter-imessage@0.1.1
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Setup helper: install-linear — bundles the preflight + install commands
# from the /add-linear skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Linear adapter in from the `channels` branch; appends the
# self-registration import; patches src/channels/chat-sdk-bridge.ts to add
# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the
# onNewMention handler never fires — the bridge needs a catchAll path);
# installs the pinned @chat-adapter/linear package; builds. All steps are
# safe to re-run.
#
# Note: the bridge patch's onNewMessage handler passes `false` for isMention
# (current trunk signature requires the arg). The /add-linear SKILL's
# snippet omits the arg — this script uses the full signature so TypeScript
# builds cleanly.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_LINEAR ==="
needs_install=false
[[ -f src/channels/linear.ts ]] || needs_install=true
grep -q "import './linear.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/linear"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true
grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
echo "STEP: register-import"
if ! grep -q "import './linear.js';" src/channels/index.ts; then
printf "import './linear.js';\n" >> src/channels/index.ts
fi
echo "STEP: patch-bridge-catchall-field"
if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then
awk '
/^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 }
in_iface && /^\}/ && !inserted {
print " /**"
print " * Forward ALL messages in unsubscribed threads, not just @-mentions."
print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g."
print " * Linear OAuth apps). The thread is auto-subscribed on first message."
print " */"
print " catchAll?: boolean;"
inserted = 1
in_iface = 0
}
{ print }
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
fi
echo "STEP: patch-bridge-catchall-handler"
if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
awk '
/ \/\/ DMs — apply engage rules too/ && !inserted {
print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear"
print " // OAuth apps). Forward every unsubscribed message and auto-subscribe."
print " if (config.catchAll) {"
print " chat.onNewMessage(/.*/, async (thread, message) => {"
print " const channelId = adapter.channelIdFromThreadId(thread.id);"
print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));"
print " await thread.subscribe();"
print " });"
print " }"
print ""
inserted = 1
}
{ print }
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/linear@4.29.0
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Setup helper: install-matrix — bundles the preflight + install commands
# from the /add-matrix skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Matrix adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @beeper/chat-adapter-matrix
# package; patches the adapter's published dist so its matrix-js-sdk/lib
# imports carry .js extensions (required under Node 22 strict ESM); builds.
# All steps are safe to re-run — re-run this script after any pnpm install
# that touches the adapter.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_MATRIX ==="
needs_install=false
[[ -f src/channels/matrix.ts ]] || needs_install=true
grep -q "import './matrix.js';" src/channels/index.ts || needs_install=true
grep -q '"@beeper/chat-adapter-matrix"' package.json || needs_install=true
[[ -d node_modules/@beeper/chat-adapter-matrix ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts
echo "STEP: register-import"
if ! grep -q "import './matrix.js';" src/channels/index.ts; then
printf "import './matrix.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @beeper/chat-adapter-matrix@0.2.0
echo "STEP: patch-esm-extensions"
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
));
console.log("Patched", f);
'
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Setup helper: install-resend — bundles the preflight + install commands
# from the /add-resend skill into one idempotent script so /new-setup can
# run them programmatically before continuing to credentials.
#
# Copies the Resend adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @resend/chat-sdk-adapter
# package; builds. All steps are safe to re-run.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== NANOCLAW SETUP: INSTALL_RESEND ==="
needs_install=false
[[ -f src/channels/resend.ts ]] || needs_install=true
grep -q "import './resend.js';" src/channels/index.ts || needs_install=true
grep -q '"@resend/chat-sdk-adapter"' package.json || needs_install=true
[[ -d node_modules/@resend/chat-sdk-adapter ]] || needs_install=true
if ! $needs_install; then
echo "STATUS: already-installed"
echo "=== END ==="
exit 0
fi
echo "STEP: fetch-channels-branch"
git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
echo "STEP: register-import"
if ! grep -q "import './resend.js';" src/channels/index.ts; then
printf "import './resend.js';\n" >> src/channels/index.ts
fi
echo "STEP: pnpm-install"
pnpm install @resend/chat-sdk-adapter@0.1.1
echo "STEP: pnpm-build"
pnpm run build
echo "STATUS: installed"
echo "=== END ==="

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