The SKILL.md is addressed to the coding agent (the interpreter), so its prose is
agent-facing by default. The parts meant for the HUMAN operator — clicking
through the Slack UI — were undifferentiated prose, so an agent couldn't tell
"relay this to the user" from "do this yourself", and the engine couldn't render
them as human steps.
nc:operator is the output twin of nc:prompt (prompt: ask the operator for input;
operator: give the operator instructions). Lead it with agent-facing prose like
"Tell the user:" so the agent relays it. The engine renders the body via the
Prompter's new optional tell() (a clack note in setup, a channel message when an
agent relays; absent in a headless rebuild → skipped). {{vars}} substitute in, so
a resolved value can be shown to the operator.
The content model is now: agent prose (unmarked, the degrade floor) / execution
directives / operator I/O (prompt in, operator out). add-slack's app-creation and
event-delivery walkthroughs are now nc:operator blocks; strip the fences and each
reads "Tell the user: <steps>" — natural, never narrating the engine.
Dry-run on the real skill: the two operator blocks route to tell() (the human),
the ncl commands to exec() (the agent) — cleanly separated. 600 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wire sections had drifted into explaining the engine ("capture:dm_channel
binds it", "the journal keeps the placeholder", "expressed as directives",
"every ncl create is idempotent"). Strip the nc: fences and it read as docs of
the apply system, not a skill — so a degrade would drop an agent into debugging
a jq pipeline instead of reading "open your DM channel with the bot" and doing it.
Rewrote slack/resend/linear wire prose as natural instructions a person or agent
follows, with the directives as the precise encoding underneath. Domain facts
stay (the Slack API calls, the slack:<channel> / resend:<address> id formats,
team-routing); engine machinery is gone. Directives themselves unchanged — lint
clean, slack template guard + apply tests green (42).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Output-capture is the twin of prompt: prompt binds human input into {{var}},
`run capture:<var>` binds a command's stdout. With it, the two steps that made
slack.ts "resist" being a SKILL.md — validate the token, resolve the DM channel —
are ordinary directives, so nothing in the bespoke flow is left unexpressible.
Engine:
- `exec` now returns the command's stdout; a `run capture:<var>` binds the
trimmed stdout into the vars map (a single-command run; last wins). Composes
with substitution: a captured value flows into later {{refs}}. The journal
still records the original (placeholder) command — secrets/values don't leak.
- Linter treats `run capture:<var>` as defining the var, so a downstream
{{var}} doesn't false-flag; doc + error message updated.
add-slack: the full procedure setup/channels/slack.ts ran is now in the SKILL.md
Wire section — prompt the member id + agent folder, `run effect:fetch` to
validate the bot token (auth.test) and resolve the DM channel
(conversations.open → `capture:dm_channel`), then `run effect:wire` to
users create → roles grant → messaging-groups create → wirings create →
messaging-groups send welcome. Dry-run against the real skill: the captured
`D0SLACK999` flows into every ncl command; zero deferred, zero agentTasks —
fully deterministic, the secret token never journaled.
Tests: capture binding + downstream substitution + lint-accepts-captured-var;
add-slack template guard updated to the complete install→credentials→wire flow
incl. the capture step. Full suite 597 passed | 1 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wiring a channel is just "collect input + call ncl", so it needs no new
directive — only that nc:run interpolates prompted {{vars}} and that the ncl
wiring verbs are idempotent + natural-key addressable.
Engine:
- nc:run now substitutes prompted {{vars}} into commands (same VAR_REF as
env-set), so a run can `ncl … {{owner_email}}`. The journal records the
ORIGINAL command (placeholders intact) so a substituted secret never lands in
the journal/remove replay. An unanswered prompt → unresolved → deferred
(degrade), never a crash. New documented effect:wire (no undo — the rows it
creates are user runtime data, not reversed on remove).
ncl:
- crud gains optional `naturalKey`: generic create returns an existing row
instead of a UNIQUE violation, making re-applied wiring idempotent (users).
- `messaging-groups create` → custom op: defaults the NOT NULL `instance` to
channel_type and is idempotent on (channel_type, platform_id, instance).
- `messaging-groups send` → new op: injects an inbound (the cli admin-transport
InboundEvent) to wake the wired agent — the welcome, as a plain ncl call.
- `wirings create` → custom op: resolves natural keys (channel_type+platform_id
→ messaging group; agent-group folder → agent group) and is idempotent on the
pair. Direct --messaging-group-id / --agent-group-id still work.
Skills (proof, one per archetype):
- add-resend: owner-bootstrap wiring is now nc:prompt + nc:run effect:wire
(users create → roles grant → messaging-groups create → wirings create →
messaging-groups send welcome), replacing the init-first-agent shell block.
- add-linear: pre-wire is now nc:prompt + nc:run effect:wire, reusing the
{{linear_team_key}} collected in Credentials — also retires the raw-SQL
INSERTs (anti-pattern #4, and they referenced a stale column set).
Tests: nc:run substitution (interpolate / journal-original / defer-on-unanswered);
ncl verb idempotency + natural-key resolution via host-caller dispatch. Full
suite 594 passed | 1 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mechanical follow-ups from the skill-guideline audit (no behavior change to apply):
- teams/matrix/linear REMOVE.md: env-var removal followed the discord template
— names inlined in prose, the `bash` block holds only the re-sync command —
instead of a non-functional `bash` fence that just listed bare var names.
matrix also drops the 4 optional vars (INVITE_AUTOJOIN[_ALLOWLIST],
RECOVERY_KEY, DEVICE_ID) that apply never sets.
- gcal/rtk SKILL.md: state explicitly that the runtime `ncl add-mcp-server` /
`add-mount` (and rtk's settings.json hook) reach-ins have no in-tree source
footprint, so a registration test is structurally inapplicable — per the
guidelines' "nothing to test in-tree" rule.
- gmail-allow-pattern.test.ts: drop the tautological third test (it asserted a
locally reimplemented `expectedPattern`, not the real `mcpAllowPattern`) and
the false "exercised directly" comment. The derivation is non-invocable
(unexported, call site inside SDK query options), so the two structural guards
are the correct archetype; no core export added to keep the surface minimal.
- resend SKILL.md: supports-threads yes → no (adapter sets supportsThreads:false).
- add-codex: delete the orphan codex-cli-tools.test.ts duplicate (SKILL.md copies
it from the providers branch; the skill-folder copy was unreferenced).
Telegram setup-step placement deferred: it's a trunk leftover from the v2
"move adapters off trunk" refactor (setup/pair-telegram.ts + setup/channels/
telegram.ts live in trunk), entangled with uncommitted telegram work — a
separate decision, not a skill fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The patch added a `catchAll?` field + handler to chat-sdk-bridge.ts, but:
- its second awk anchored on a stale marker (`// DMs — apply engage rules
too`) that no longer exists, so the handler was never inserted;
- the linear adapter never sets `catchAll: true` (it documents relying on
the bridge's default onNewMessage catch-all), and no adapter anywhere
consumes `catchAll` — so the block was dead even when the marker matched.
Every Linear comment is already delivered via the base bridge's universal
onNewMessage(/[\s\S]*/) forwarding → router evaluateEngage (pattern mode
returns true for all messages); subscription is handled router-side. Dropping
the patch is a behavior-preserving no-op and collapses add-linear into the
standard barrel-only channel template — resolving the stale-marker break, the
REMOVE incompleteness (nothing left to reverse), and the missing patch test
(now fully covered by linear-registration.test.ts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The three mount-using skills wrote container_configs.additional_mounts directly
via scripts/q.ts (raw SQL over core schema — smell #1: untyped, drift-prone).
They now call the operator-run `ncl groups config add-mount` / `remove-mount`
verb. Dropped the stale "no add-mount verb yet (#2395)" notes; the mount-allowlist
and restart prose are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mounting a host directory into a group's containers is a filesystem-access
boundary, so this is an OPERATOR-ONLY verb. A new `hostOnly` flag on commands,
enforced in dispatch BEFORE scope/approval, rejects any container (agent) caller
regardless of cli_scope — even `global`, even with approval — because the mount
allowlist is the boundary cli_scope itself lives inside.
Mirrors add-package: writes additional_mounts, idempotent, paired remove. It's
the WHO layer, complementing the existing spawn-time mount allowlist (the WHAT,
stored outside the project root). Tests: host-only rejection at global scope +
add/idempotent/remove behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 7 channels that didn't (telegram, teams, imessage, github, resend, matrix,
whatsapp-cloud) now match the slack/discord standard: their nc:copy fetches
<channel>-registration.test.ts from the channels branch, and a nc:run effect:test
runs it. Every chat-sdk channel now ships + runs its registration guard — the
red-on-drift check that the barrel registers the adapter (and covers the dep).
Verified in an isolated worktree: all 7 install (exact pins), build clean, and
their registration tests pass; engine suite 36/36.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the structured-skill engine (slack was the first conversion). Adds the
two directives the rest of the family needed, converts the 12 remaining skills,
and routes their setup flows through the engine — deleting the hand-maintained
shell scripts that had drifted from the skills.
Engine (scripts/):
- json-merge: merge a keyed JSON object into an array file (container/cli-tools.json),
idempotent + journal-removable. add-codex uses it for its @openai/codex entry.
- append at:<marker>: insert before a `// <<< <marker>` line instead of EOF.
- setup/index.ts: a dormant `nanoclaw:setup-steps` marker in the STEPS map.
Conversions (.claude/skills/): discord, telegram, teams, imessage, linear, github,
webex, resend, matrix, gchat, whatsapp-cloud, codex — each aligned with its
now-deleted setup script (versions, copied-file lists). @chat-adapter/* pins match
our chat core (4.26.0); the lint enforces it.
Setup integration (setup/): the discord/telegram/teams/imessage channel flows and
the codex provider now apply their skill in-process via applySkill (secrets via the
Prompter, fork-aware remote resolution), mirroring slack. Deleted 5 add-*.sh + 9
install-*.sh drifted duplicates; rewired the claude-assist diagnostics map.
Channel remove no longer tears down the DB: wechat/emacs REMOVE.md stop deleting
messaging_groups/sessions/wirings. Those are user runtime data the skill never
created, so remove must not touch them — and orphan rows are inert (adapters start
from the registry, not the DB).
Verified: all 12 skills lint clean; 168/168 setup+scripts tests pass; no deps
installed by the conversion and no core barrels applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces the optionally-deterministic skill format. Official skills carry
`nc:` directive fences (copy/append/dep/run/prompt/env-set/env-sync) embedded in
prose, so one SKILL.md is both agent-readable and machine-appliable. Robustness
lives in the whole system — graceful degradation to an agent, plus lint + tests —
not in the syntax, so the directives stay minimal and readable.
scripts/skill-directives.ts — parser + linter. Extracts nc: directives; flags
unpinned deps, undefined {{var}} references, and a @chat-adapter/* pin that
doesn't match our lockfile's `chat` core (the drift that put add-slack on the
wrong version).
scripts/skill-apply.ts — the application engine. Plan (idempotency, prompt
resolution, no writes) → mutate (copy/append/env-set, journaled) → run
(dep/build/test). Remove is the journal played back (no hand-written
REMOVE.md). Anything the engine can't do bounces to an AGENT with its prose —
never the human, never a hard abort. A Prompter abstraction lets one engine
serve both interactive setup and headless rebuilds; fork-aware remote
resolution replaces a hardcoded `origin`.
.claude/skills/add-slack/SKILL.md — converted to the format; `prompt` split from
`env-set` so a captured secret can feed env, ncl, or the vault; pinned 4.26.0
to match our chat core.
setup/channels/slack.ts — the Slack setup flow now applies the skill through the
engine in-process (secrets via the Prompter, never argv/disk), deleting the
hand-maintained setup/add-slack.sh + install-slack.sh, which had drifted from
the skill (they pinned 4.26.0 vs the skill's stale 4.27.0). One source of truth.
Verified end-to-end in an isolated worktree: apply copies the adapter + its
registration test, installs 4.26.0, builds clean against chat@4.26.0, and the
registration test passes. 19 unit tests for the parser + engine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
adfae67 moved the agent's global Node CLIs into container/cli-tools.json so a
skill adds one with a json-merge instead of editing the Dockerfile. The Codex
provider install was left behind — add-codex.sh still awk'd an ARG + RUN into
the Dockerfile and its test guarded that shape.
Migrate add-codex to the seam:
- add-codex.sh appends { name: "@openai/codex", version } to cli-tools.json
(idempotent json-merge); install/idempotency gates read the manifest.
- SKILL.md / REMOVE.md document the manifest append/removal, not Dockerfile edits.
- codex-dockerfile.test.ts -> codex-cli-tools.test.ts, asserting the manifest
entry (skips when the manifest is absent, e.g. the bare providers branch).
Pairs with the providers-branch commit that drops the codex Dockerfile lines,
renames the payload test, and points the setup install-check at the manifest.
Verified end-to-end: full add-codex install into a clean worktree leaves the
Dockerfile codex-free, the manifest correctly appended and idempotent; vitest
cli-tools.test.ts (6) and bun codex-cli-tools.test.ts (2) green; host tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Key Files table and the Secrets/OneCLI section referenced
src/onecli-approvals.ts and src/user-dm.ts, but both files were moved
under src/modules/ (src/modules/approvals/onecli-approvals.ts and
src/modules/permissions/user-dm.ts). onecli-approvals.ts is already
cited at its correct new path elsewhere in the same doc, so this was a
partial-rename miss. Docs only — no code changes.
Closes#2763
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "read imported-agent-memory.md, treat it as binding" doctrine sat in the
memory definition that every group loads, but it only matters when an import
actually happened. Move it into the /migrate-memory skill — the step that
writes the imported file and its index pointer (which the agent inlines into
its prompt each turn) — and drop the always-on block from definition.md.
Addresses review feedback on #2756.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent's global Node CLIs (claude-code, agent-browser, vercel) were each
a hardcoded ARG + RUN layer in the Dockerfile, so adding or bumping one meant
editing the Dockerfile — a code reach-in every tool-installing skill had to make.
Move the tool list into container/cli-tools.json. A skill now adds a CLI by
appending a {name, version} entry (a json-merge) — the safest change shape:
deterministic, idempotent, removable. install-cli-tools.sh parses the manifest
with node (no new jq dep), writes the per-tool only-built-dependencies opt-ins,
and runs one pinned `pnpm install -g`, so the pnpm supply-chain path is unchanged.
Behavior is byte-for-byte: same opt-ins, same pinned installs. agent-browser is
now pinned (0.27.1, what `latest` last resolved to) instead of floating.
container/cli-tools.test.ts guards the seam: red if a baseline tool is dropped,
a version unpins, or the Dockerfile wiring / pnpm path is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the agent provider a first-class, operator-chosen property instead of a
Claude-only assumption. Trunk gains the seams; the actual non-default payloads
(Codex first) install from the `providers` branch.
Setup
- A provider registry feeds a hard-wired setup picker (Claude | Codex). Picking
a non-default provider installs its payload (setup/add-codex.sh, channel-style),
runs a vault-only auth walkthrough (--step provider-auth), and records the pick
on the first agent before its first spawn.
- Picking Claude changes nothing — default installs are byte-for-byte unaffected.
Provider as a DB property
- Provider lives on container_configs.provider (materialized to container.json,
read by resolveProviderName). Creation stays provider-agnostic; the picked
provider is applied via the picked-provider seam. The deprecated
agent_groups.agent_provider path is not used.
Switching + memory
- Switch a live group with `ncl groups config update --provider` + restart.
- Memory never migrates at runtime — each provider keeps its own store. The
/migrate-memory skill carries a group's memory across a switch in either
direction (flat CLAUDE.local.md <-> memory/ scaffold). group-init seeds an
imported-agent-memory note for non-default providers; the runner's memory
definition reads it first turn. See docs/provider-migration.md.
No install-wide default, no runtime provider guard — switching is operator-by-
convention, consistent with the no-install-gating posture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inverts conversation archiving into an optional onExchangeComplete provider
hook: the runner never archives on a provider's behalf, and the markdown
writer ships with the provider that needs it. Dormant for the default
provider.
Slash commands now interrupt an in-flight turn — a runner-handled command
(/clear, /compact, /cost, …) arriving mid-turn aborts the active stream and
runs immediately instead of waiting out the turn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Injects credentials as request-time stubs so no credential is ever written
into a container or to disk. Gateway and CLI versions move to versions.json
(machine-checkable pins); breaking upgrades are documented in
docs/onecli-upgrades.md as an agent-executable runbook (detect / why / fix /
verify / rollback), and the update flow follows linked docs and diffs the
pins.
BREAKING: requires a gateway upgrade; the doc carries the steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Host-side registry where a provider can declare, by capability rather than
by name, that it owns its agent surfaces (project doc, skills). Default
providers keep the standard surfaces; a surfaces-owning provider suppresses
them. Dormant until a provider registers — no change for existing installs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The instance route-split suite (from #2733) keeps src/webhook-server.test.ts;
this branch's raw-route suite moves to src/webhook-server-raw.test.ts —
incompatible lifecycle setups (fixed port + afterEach vs random port +
afterAll) make a single merged file wrong. webhook-server.ts auto-merge
verified: raw routes take dispatch priority, stop clears both maps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>