Compare commits

...

136 Commits

Author SHA1 Message Date
Moshe Krupper 08ff3cd825 refactor: localize permission approval continuations 2026-07-15 09:27:01 +03:00
Moshe Krupper 732af93cfb refactor: model sender approvals as master detail 2026-07-14 15:53:08 +03:00
Moshe Krupper a2bfb22914 Merge remote-tracking branch 'origin/main' into feat/approval-contract 2026-07-14 11:02:58 +03:00
amit-shafnir da9a74fcfa Merge pull request #3022 from amit-shafnir/templates-scheduled-tasks
feat: support scheduled tasks in templates
2026-07-14 00:24:46 +03:00
Amit Shafnir e2476440ea feat: support scripts in template tasks 2026-07-13 23:51:45 +03:00
Amit Shafnir d875764535 feat: support scheduled tasks in templates 2026-07-13 23:51:45 +03:00
Moshe Krupper 208b7325de fix: harden unified approval lifecycle 2026-07-13 23:40:30 +03:00
Moshe Krupper e741642fa7 Merge origin/main into feat/approval-contract 2026-07-13 23:18:31 +03:00
glifocat bbae454ae7 Merge pull request #2998 from nanocoai/fix/mcp-approval-full-payload
fix(self-mod): render full MCP server payload on the approval card
2026-07-13 21:06:14 +02:00
glifocat e5b928783d Merge branch 'main' into fix/mcp-approval-full-payload
Resolves the conflict with the guard seam from #2986, which split
handleAddMcpServer into a precheck (validateAddMcpServer) and a hold
builder (requestAddMcpServerHold).

The split follows the seam: payload-shape checks (arg/env types, the 32/32
count caps, the 16KB payload cap) run in the precheck, so a malformed
request is answered without ever creating an approval row. Card rendering
(JSON-encoded fields, escaped invisibles, the code fence, secret redaction)
and the 1500-byte card cap run in the hold builder, which is the only path
that renders a card.

Tests drive both halves through a helper that mirrors the guard pipeline,
so every card and validation assertion still covers the production path.
2026-07-13 19:00:55 +00:00
Daniel Milliner a7a0e3d6c4 Merge pull request #2906 from nanocoai/feat/global-provider-default
feat: instance-wide default agent provider for new groups
2026-07-13 21:41:56 +03:00
Koshkoshinsk b0badcac7f Merge origin/main (290 commits) into feat/global-provider-default
Conflict resolutions:
- setup/set-env.ts: keep the upsertEnvVar extraction; drop the
  --sync-container block — main removed the dead data/env/env secrets
  mirror (c82f062d) and nothing reads it.
- src/config.ts: union — DEFAULT_AGENT_PROVIDER export + main's new env
  keys and the ASSISTANT_HAS_OWN_NUMBER deprecation note.
- src/cli/resources/groups.ts: take main's initGroupFilesystem call
  (#2415 workspace provisioning); it stamps the instance default
  provider via ensureContainerConfig inside, so the feature's intent is
  preserved without the bare ensureContainerConfig call.
- .claude/skills/manage-channels/SKILL.md: keep main's channel-defaults
  paragraph + this branch's instance-default provider wording (the
  register step has no --provider flag on either side).
- create-agent.test.ts: main's vi.hoisted guard-seam harness minus
  mockUpdateScalars — provider now flows through initGroupFilesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:18:38 +03:00
gavrielc ef220b53bc docs: exclude container-runner tests from the token count — 226k · 23%
The count is the system-understanding surface; agent-runner *.test.ts files
were counted only because the old workflow's exclude covered src/ alone.
The badge is regenerated manually now — repo-tokens/recount.py encodes the
canonical file set (counts HEAD's tracked tree, so local files can't skew it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:05:31 +03:00
gavrielc d6a94329a4 docs: token badge measures against a 1M context window
248k tokens · 25% of context window (was 124% against the old 200k default).
repo-tokens context-window default moves to 1000000, matching current
Claude models; badge regenerated with the action's own logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:00:49 +03:00
gavrielc 5d7f9d7747 ci: remove the auto version-bump and token-count workflows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:58:23 +03:00
github-actions[bot] d0b82f25cc docs: update token count to 248k tokens · 124% of context window 2026-07-13 14:56:50 +00:00
github-actions[bot] 4c1d4cdef0 chore: bump version to 2.1.53 2026-07-13 14:56:46 +00:00
gavrielc 680714852a Merge pull request #3035 from nanocoai/feat/structured-skill-format
Structured skill format: setup installs channels by applying the SKILL.md
2026-07-13 17:56:29 +03:00
gavrielc 66bfe9e72d Merge branch 'main' into feat/structured-skill-format 2026-07-13 17:54:52 +03:00
gavrielc 61980d0bd5 docs: confine directive-format mentions to the dedicated reference and contribution guide
The structured-apply syntax is core tooling — public-facing docs describe the
behavior (setup applies the same SKILL.md an agent follows), not the format.
References now live only in docs/skill-directives.md (the reference),
docs/skill-guidelines.md (the contribution guide's scoped section), and the
internal dev docs (CLAUDE.md key files, setup-flow, seam spec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:26:40 +03:00
gavrielc 4622fc9ea6 docs: structured skill format — reference, contributor framing, changelog
- NEW docs/skill-directives.md: canonical author-facing nc: reference (fence
  syntax, all eight kinds with idempotency semantics, effect taxonomy,
  capture/validate/when:, retired forms, prose-primary + degrade-to-agent).
- CONTRIBUTING.md + docs/skill-guidelines.md: the format is core tooling for
  the trunk skills setup drives — contributed skills follow the standard
  guidelines and are NOT required to carry fences; the conformance suite
  holds only core fence-carrying skills.
- CHANGELOG.md: [BREAKING] entry — channel installs are skill-driven; the
  bespoke wizard flows and per-channel installer scripts are gone, the
  /add-<channel> skills are the single source of truth.
- docs/skill-engine-seam.md: status header now reflects implementation;
  step-plan sections marked historical.
- CLAUDE.md: key-file rows for the engine/lint/policy/driver, docs index rows.
- docs/setup-flow.md, setup-wiring.md, skills-model.md, customizing.md:
  skill-driver path documented, stale installer references swept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:11:55 +03:00
gavrielc 26abae5613 feat(skills): Troubleshooting sections for the 11 credentialed channel skills
Satisfies the lint's reference floor: every skill with a secret prompt or an
interactive step now carries the human-facing Troubleshooting section — what
each credential paste should look like and where to find it, the platform's
most common live failure, and the registration-test + restart check. Also
strips stray tool-call artifact lines from add-gchat and add-whatsapp-cloud
and fixes add-telegram's example token to match its own shape description.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:11:53 +03:00
gavrielc a517ecee07 test(skills): conformance suite — every fence-carrying skill applies programmatically
scripts/skill-conformance.test.ts auto-discovers each .claude/skills/*/SKILL.md
with nc: fences (15 today) and asserts, per skill: lint clean (errors and
warn-lints); per branch-scenario from a colocated apply-fixtures.json, a full
applySkill run with stubbed exec/execStream/resolveRemote goes green — nothing
deferred, nothing degraded to agent, every var bound; every when: guard value
is exercised by some scenario (coverage read from ApplyResult.vars, with
documented exclusions); static effect ordering (mutations before build, build
before test, restart only after both); and a run-health sabotage probe — a
failing fetch/check/external run must gate every later restart/step/wire.

Fixture values satisfy the skills' real validate: regexes, so a fixture can't
paper over a broken prompt. Skills with prompts but no fixture fail with an
actionable message; prompt-less skills get a default empty scenario.

scripts/skill-inputs.ts adds the inputsFromEnv helper from the seam spec §6
(NC_INPUT_<VAR>, collision error); its test round-trips env-supplied inputs
through a real skill apply.

Runs in the existing CI vitest step — no workflow change needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:11:25 +03:00
gavrielc 4daeabdda7 chore(setup): delete orphaned per-channel installer scripts
setup/add-signal.sh, setup/add-whatsapp.sh, setup/install-whatsapp.sh,
setup/install-whatsapp-cloud.sh referenced flows that no longer exist —
nothing invokes them (setup routes every channel through runChannelSkill).
setup/install-signal-cli.sh stays: add-signal uses it for the binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:11:24 +03:00
github-actions[bot] 79ca4c01db chore: bump version to 2.1.52 2026-07-13 14:03:31 +00:00
gavrielc 78021edf5e Merge pull request #3002 from nanocoai/fix/warn-stale-skill-blockers
fix(container): warn when a real entry blocks a shared skill symlink
2026-07-13 17:03:11 +03:00
github-actions[bot] 23d977d073 chore: bump version to 2.1.51 2026-07-13 13:53:20 +00:00
gavrielc 93a5101866 Merge pull request #2996 from nanocoai/fix/missing-adapter-retry-path
fix(delivery): route missing-adapter messages into the retry path
2026-07-13 16:53:03 +03:00
github-actions[bot] 3375d4735e chore: bump version to 2.1.50 2026-07-13 13:50:46 +00:00
gavrielc fd0b56118d Merge pull request #2966 from nanocoai/fix/container-failed-ack
fix(agent-runner): log when an errored batch is acked completed
2026-07-13 16:50:31 +03:00
gavrielc 848441f59c Merge branch 'main' into fix/container-failed-ack 2026-07-13 16:50:02 +03:00
github-actions[bot] 67825444d5 chore: bump version to 2.1.49 2026-07-13 13:38:32 +00:00
github-actions[bot] 9fe39543ec docs: update token count to 245k tokens · 122% of context window 2026-07-13 13:36:31 +00:00
gavrielc 1e1bd27bdd Merge pull request #2988 from nanocoai/tasks/04-one-door-delivery
Tasks: one-door delivery — send_message is the only path out of a task session
2026-07-13 16:36:13 +03:00
github-actions[bot] 0b47ffc573 chore: bump version to 2.1.48 2026-07-13 13:34:23 +00:00
gavrielc 8922ba372e Merge branch 'main' into tasks/04-one-door-delivery 2026-07-13 16:33:35 +03:00
gavrielc 3c6a5e233e Merge pull request #3031 from nanocoai/feat/lean-harness-defaults
feat: lean harness defaults for new agent groups
2026-07-13 16:32:38 +03:00
gavrielc 4fa065a65a Merge origin/main: channel defaults, guard seam, WhatsApp number safety
Fuses 73 upstream commits into the structured-skill-format branch:

- src/cli: main's declaration-aware CRUD (resolveDefaults/postCreate/
  postCommit, deep help, guard seam) fused with our idempotent natural-key
  creates and hostOnly ops; hostOnly enforcement moved into commandDecide.
- add-whatsapp: main's number-ownership safety flow (ban-risk warning,
  shared-mode interception, ASSISTANT_HAS_OWN_NUMBER/ASSISTANT_NAME
  replace-writes, @-name engage pattern) ported into the structured skill;
  orphaned setup/channels/whatsapp tests rewritten against the surviving
  homes of their pure functions.
- add-slack/add-whatsapp: formatting container skills now copied from the
  channels branch (trunk stopped shipping them); engine mkdirs copy dest
  parents so fresh installs don't ENOENT; REMOVE.md cleans up the payload
  and drops the retired data/env mirror sync.
- add-linear keeps explicit engage flags (Linear declares mentions:never —
  dropping them would create a dead mention-mode wiring); channel-defaults
  guidance ported as prose. add-signal regains ncl-based wiring guidance.
- gcal/gmail tool skills keep our ncl mount verbs (upstream's ISO-timestamp
  SQL fix is subsumed by the DB layer's toISOString stamps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:31:19 +03:00
omri-maya 77c426d112 Merge branch 'main' into tasks/04-one-door-delivery 2026-07-13 16:07:50 +03:00
gavrielc 89c3a9bdd8 Apply suggestion from @gavrielc 2026-07-13 16:06:36 +03:00
gavrielc fd55cc4a42 Apply suggestion from @gavrielc 2026-07-13 16:04:14 +03:00
gavrielc a3be5d062b Apply suggestion from @gavrielc 2026-07-13 16:00:19 +03:00
gavrielc bf00d0e025 Merge pull request #2958 from nanocoai/add-teams-cli-flow
add-teams: Teams-CLI-first credentials flow in SSF directive grammar
2026-07-13 12:50:38 +03:00
Moshe Krupper 59b3680212 feat: one approval contract — approver rule, hold records, sender fold, lifecycle observers
Collapse the three separately-implemented approval flows (CLI command
holds, unknown-sender admission, channel registration) into one
hold-record contract on `pending_approvals` with a single
click-authorization rule. This is a behavior-preserving refactor: unifying
the flows changes no click-authorization decision versus main.

- ApproverRule (exclusive | admins-of-scope) + mayResolve() replace the
  three divergent click-auth copies (approvals response handler, sender
  handler, channel handler). a2a named approvers stay exclusive;
  sender/channel keep named-or-admin — the hold row encodes which.
- pending_approvals gains approver_rule / dedup_key (migration 020, with
  backfills: named-approver rows -> exclusive, agent_group_id stamped from
  the session). requestApproval supports sessionless holds, per-card
  options, and dedup keys.
- Sender admission folds onto the primitive (action 'sender_admit'):
  addMember + routeInbound replay on approve, deny via the shared reject
  path; pending_sender_approvals and its card/click code are deleted. Its
  UNIQUE(messaging_group_id, sender_identity) is preserved as a partial
  UNIQUE on dedup_key, and the hold is deleted BEFORE the reroute — so a
  second wired agent group still mints its own card (matching the pre-fold
  table). In-flight sender cards die at upgrade; a new message re-triggers.
- Channel registration + OneCLI keep their flows, adopt the contract:
  channel holds expose a synthesized PendingApproval view, OneCLI rows
  carry the contract fields.
- registerApprovalRequestedHandler / notifyApprovalRequested — the
  creation-side observer sibling of notifyApprovalResolved. Together they
  give observers the full hold lifecycle (outcome approve|reject|expire|
  sweep, session nullable) with zero touch points inside the flows.
- ncl approvals list/get expose approver_user_id / approver_rule /
  dedup_key.

Migration takes slot 020 (main already ships 019). No approver
blast-radius scope and no channel-approver change ride this PR — those
defect fixes (D1/D4) are deliberately out of scope for the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:03:15 +03:00
Omri Maya 379fd28c24 fix(tasks): tighten the task-mode delivery instruction per review
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:15:57 +03:00
Omri Maya 981123489a Merge remote-tracking branch 'oss/main' into tasks/04-one-door-delivery 2026-07-11 20:47:29 +03:00
glifocat 2826e72ec1 Merge branch 'main' into fix/mcp-approval-full-payload 2026-07-10 22:30:46 +02:00
glifocat ce426270b1 Merge branch 'main' into fix/missing-adapter-retry-path 2026-07-10 22:30:05 +02:00
glifocat 93d3908575 fix(agent-runner): log when an errored batch is acked completed
A provider error inside a consumed batch is acked completed after the
catch block, so the log shows "Query error" followed by "Completed N
message(s)" that reads like success. The only trace the user gets is
the error chat message; operators grepping the log see a clean run.

Add one log line in the error path saying the batch will be acked
completed with no redelivery. No status semantics change: flipping
errored batches to failed would silently stop recurring tasks
(recurrence fan-out gates on completed), so that stays out per review
feedback on the previous shape of this PR.
2026-07-10 12:42:55 +00:00
glifocat 9ff6f41b34 fix(container): warn when a real entry blocks a shared skill symlink
Groups created before the shared-skills refactor (8a12fa61) still hold
real skill copies in .claude-shared/skills/. The prune loop only removes
symlinks and the create loop skips any existing entry, so those groups
run stale skill content with nothing in the logs. #3001 has the full
trace and a repro.

Log a warning when a non-symlink entry occupies a desired skill path.
The entry is left in place, so template overlays keep working. Telling
overlays apart from stale copies needs an ownership marker, tracked in
#3001 as the second layer.
2026-07-10 11:22:41 +00:00
Omri Maya 3d4b349ba9 test(tasks): cover one-door task turns 2026-07-10 12:21:57 +03:00
Omri Maya 5512731512 fix(tasks): simplify one-door delivery contract 2026-07-10 11:16:30 +03:00
glifocat 8a03d1dbd4 fix(self-mod): fence, escape, and bound the MCP approval card
Render the payload in a code fence with invisibles and backticks
escaped as visible \uXXXX, redact secret-shaped values to a byte count
plus sha256 fingerprint (card only, the verbatim value is still
applied), and reject oversized payloads before the approval row exists
(32 args, 32 env vars, 16 KB payload, 1500-byte rendered card). A
failed card delivery now deletes the pending approval row instead of
leaving it orphaned.
2026-07-09 23:25:16 +00:00
glifocat 874ee63e18 fix(self-mod): render full MCP server payload on the approval card
The add_mcp_server approval card showed only the server name and
command, but approval applied name, command, args, and env. The
approver authorized fields they never saw.

Render every field JSON encoded, so boundaries are exact and an
embedded newline shows as a visible \n escape instead of adding fake
lines to the card. Validate args/env types before the approval is
created. apply.ts is unchanged, it already applies exactly the stored
payload.
2026-07-09 22:51:47 +00:00
glifocat 10cef9d443 fix(delivery): route missing-adapter messages into the retry path
When the exact adapter for an outbound message is not registered
(credentials missing so the factory returned null, setup failed, or a
named instance offline), the delivery bridge returned undefined.
drainSession treats a normal return as success, so the row was marked
delivered with platform_message_id NULL, the log said "Message
delivered", and outbox attachments were cleared. No platform send
happened.

The bridge now throws MissingChannelAdapterError, so the message takes
the existing retry path and ends as status='failed' after
MAX_DELIVERY_ATTEMPTS if the adapter never comes back. setTyping keeps
its tolerant behavior (typing is advisory). Exact-instance routing is
unchanged: a named instance never falls back to a sibling adapter.

Closes #2995. Supersedes #2226 by @kpscheffel, which implemented the
same approach before the bridge moved into channel-registry.ts.
2026-07-09 19:47:53 +00:00
Omri Maya 8357b1ca62 feat(tasks): one-door delivery — send_message is the only path out of a task session
A task fire has no chat attached, so delivery gets exactly one door: the
send_message tool with an explicit destination. Final-text <message to>
blocks are inert in task sessions; the fire's final text is auto-appended
to the series run log (tasks/<id>.md) instead — exactly once per run
(skipped when the agent already wrote its own append-log line that fire).
Inert blocks are stripped from auto-appended lines and logged as
[undelivered → …] so a misaddressed send is visible in the log rather than
silently gone. A same-turn nudge catches an agent that ends a fire with
inert blocks; the previous echo-suppression heuristics are removed with
the ambiguity they served. Chat sessions are unchanged.

The host now stamps is_task on session_routing at session creation —
the container reads that instead of sniffing the thread-id prefix.

BREAKING: task prompts that relied on final-text <message to> delivery
must call send_message(to=...); tasks created with the standard scaffold
already instruct this. Rebuild the container image and restart so runners
pick up the new poll loop (see CHANGELOG migration note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:28:42 +03:00
Koshkoshinsk a61b519176 add-teams: apply the mascot icons to the CLI-created app
The committed icons only reached the manual-path sideload zip; the CLI path's
package is generated by `teams app create` with placeholder icons. A new
creation-side fence runs `teams app update --color-icon --outline-icon` with
the captured teams app id, before the install operator so the install dialog
already shows the mascot. Cosmetic: a failure logs and continues, never
blocking setup. No operator adjacency touched — gate policy unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:24:58 +03:00
Koshkoshinsk eff7811930 add-teams: drop the dead deferWire option
wireIfResolved replaced it at the only call site (setup/auto.ts) and its
test was already deleted; the option, its two branches, and stale comment
references survived. The live drop-through fallback (wireIfResolved's
unresolved return + verify's DEFER_WIRE_CHANNELS pending path) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:08:08 +03:00
Koshkoshinsk 1292bf3db2 add-teams: app-package assets as data — manifest template, mascot icons, extracted zip writer
The manifest is now a checked-in manifest.template.json (code only patches
the per-install fields: app id, name, domain, optional RSC block), the two
icons are committed PNGs rendered from the NanoClaw mascot instead of a
160-line in-process PNG encoder, and the minimal stored-zip writer moves to
setup/lib/zip.ts. buildTeamsAppPackage's interface is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:02:09 +03:00
gavrielc 85ffa92f1b Merge pull request #2972 from nanocoai/fix/ssf-wizard-ux-fixes
Wizard UX + add-slack Socket Mode fixes
2026-07-08 09:58:19 +03:00
Koshkoshinsk 384231ab0b add-teams: wiring confirm — yes/no, then logged-in-account or other-account; no skip, no raw IDs
The CLI login is often not the person setting up NanoClaw. The confirm stays
yes/no; a no shows "You're currently logged in to Teams as {upn}" and asks
which Teams user to wire: logged-in-account (rebind straight back to the yes
branch — recovers a hesitant no, no ID typed or shown) or other-account
(entra-instructions infobox, GUID prompt, rebind with the provided id).
Either rebind re-enters the same link chain — no duplicated steps.

The provide/skip select, the deferred-wire option, and the raw-object-ID
display are gone: someone is always wired on a fresh create, and identities
show by sign-in name only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:44:37 +03:00
Koshkoshinsk 852cbbac6c add-teams: the declined-wire note leads with the operator's own Entra object ID
The ID was already captured from the CLI session (teams status .userObjectId)
and operator bodies interpolate — so a no now shows it: paste it via "provide"
to wire yourself after all, fetch a different user's ID from Entra / Teams
admin center, or skip to the DM-first ending. A no out of uncertainty no
longer locks the operator out of wiring without a re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:26:16 +03:00
Koshkoshinsk cf3ca4dc36 fix(setup): hostExec tees each command to a per-skill raw log
The async hostExec already pipes stderr off the wizard screen; this adds
the level-3 paper trail. runSkill allocates one
logs/setup-steps/NN-skill-<name>.log per apply (default exec only) and
every command appends $ cmd + stdout/stderr — success and failure alike —
so silenced warnings (the Teams CLI libsecret note) stay inspectable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:57:30 +03:00
Koshkoshinsk ee1d2773c2 fix(setup): spawn hostExec so step spinners animate during skill steps
execSync blocks the event loop for the whole command, freezing every clack
ticker in the process — skill steps ran with a dead spinner while earlier
wizard phases (async spawn) animated fine. Spawn + promise keeps the loop
free; the engine's exec seam already awaits. The failure shape is unchanged:
`exit <code>: <first stderr line>` first, full stderr below.

Also retitles operator notes "Do this" -> "Your turn".
2026-07-07 14:40:32 +03:00
Koshkoshinsk 20bcb2408c add-teams: declined wire offers wiring a provided Entra object ID; skip defers to a DM-first ending
A no at the wiring confirm now leads somewhere: an infobox explains the
Microsoft Entra object ID (and where to find it), a provide/skip select asks
for it right there, and a provided ID rebinds owner_aad_id + flips
wire_owner=yes via a capture fence — the link chain runs unchanged against
the target user, so the assistant messages the desired person first.

Skip binds a real value (a deferred prompt would abort channel setup via
runChannelSkill's fullyApplied gate), the chain guard-skips cleanly, and the
wizard's closing What's-left banner now carries the DM-first guidance: have
the desired person DM the bot once, then /init-first-agent picks them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:24:28 +03:00
Koshkoshinsk 2ef9f11588 add-teams: ask before wiring the detected owner — a no defers with manual-wire guidance
The identity note ("stop here (Ctrl-C) if that's not you") becomes a real
decision: an operator note names the detected account ({{owner_upn}}), then
nc:prompt wire_owner (^(yes|no)$ — renders as a select under the driver's
enum-prompt rendering) gates the whole DM-open chain via when:wire_owner=yes.
wire_owner is only prompted on fresh creates, and an unbound var fails any
when: guard, so drop-through re-runs skip the chain exactly as before.

On no: a note explains how to wire the desired Teams user afterwards (DM-first
path needs no IDs; proactive wiring needs the person's Entra object ID and
where to find it), the wire inputs stay unresolved, and wireIfResolved falls
through to the deferred ending. Install/create/env still complete either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:06:33 +03:00
Koshkoshinsk e7ecfb9955 add-teams: trim the tunnel-URL and sign-out prompts; rename "Open the owner DM"
- tunnel-URL prompt down to one line; the port-3000 / no-trailing-path detail
  moved into the pre-flight note just above it (an operator block followed by a
  prompt adds no extra confirm — gate policy rule 3)
- sign-out question reworded for the driver's select rendering of ^(yes|no)$
  prompts (degrades to a validated text prompt until that lands on the base)
- "Open the owner DM" -> "Link the bot to your account": spinner captions are
  heading-derived, and four "Open the owner DM (n/4)" rows read as something to
  do in Teams rather than background API calls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:50:47 +03:00
Koshkoshinsk eba05bb67e add-teams: take the first non-bot conversation member — the aadObjectId select came back empty on a live run
The 1:1 is created with exactly one member (the owner), so first-non-bot is
correct by construction; .aadObjectId is not reliably present in the members
response and its GUID casing varies. Also default the display name.
Live-run evidence (VM run #7): token + create-conversation with the 28: bot
id and an AAD member id both work; only the members select failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:55:43 +03:00
Koshkoshinsk 66b0dbd5c7 add-teams: wire the owner inline — the assistant messages the human first
Same move as Slack/Discord: after the app install, open the bot<->owner 1:1
proactively with the bot credentials. The owner is the Teams-CLI account
that created the bot (teams status --json -> username + AAD object id); the
conversation-members fetch converts that to the 29: id inbound senders carry
and doubles as an identity cross-check; the platform id is composed exactly
as the adapter encodes thread ids. An operator note names who gets wired.
runChannelSkill gains wireIfResolved: a fresh create wires + welcomes like
any channel, a drop-through re-run resolves nothing and keeps the deferred
ending. Logout is now the operator's choice (yes/no prompt) instead of
automatic, and runs after the identity resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:26:06 +03:00
Koshkoshinsk 405643f8a7 fix(setup): SSF-002 follow-up — step children: silence logger info, force clack colors
QA on the VM showed two leaks in the effect:step tee: the host logger's
INFO lines (it always emits ANSI and defaults to info) landed on the
wizard screen, and the child's clack card drew colorless because
picocolors disables ANSI on a piped stdout, clashing with the wizard
theme.

hostExecStream now spawns steps with LOG_LEVEL=warn (operator-set level
wins; warnings/errors still pass) and FORCE_COLOR=1 when the operator's
terminal is a real TTY.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 16:20:59 +03:00
Koshkoshinsk 1a3c3eaf9b fix(setup): wizard UX — pairing card, either/or selects, quiet bounces (SSF-002/003/004)
SSF-002 — the telegram pairing card renders via clack's static note/log
primitives instead of bare console.log. hostExecStream tees non-status
lines verbatim, and static clack output is just lines — only
interactive/animated widgets need the TTY the piped child doesn't have.

SSF-003 — an either/or nc:prompt (validate:^(a|b)$) renders as an
arrow-key p.select; the options come from the validate regex itself
(literalChoices), so no grammar addition and zero skill edits. Prefixes
and format unions keep the text prompt. The `?` help-escape doesn't
apply to selects — every choice is valid and self-describing.

SSF-004 — a bounced step no longer dumps a raw stacktrace or the skill's
reference prose into the wizard. hostExec recomposes failures as
`exit <code>: <first stderr line>` (full stderr kept below for the
agentTask reason an agent fixes from); run-channel-skill shows one line
per bounce and writes the full text to a logs/setup-steps raw log, which
fail() forwards to the Claude handoff.

The directive engine (scripts/skill-apply.ts, skill-directives.ts) is
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 16:06:26 +03:00
Koshkoshinsk 77a19a0c97 fix(skills): restore bot-event subscription in add-slack Socket Mode path (SSF-001)
'Subscribe to bot events' is shared by both delivery modes — only the
Request URL is webhook-specific. 2242f6c mis-sorted it into the webhook
operator block, so Socket Mode installs connected the socket but Slack
never pushed events: welcome DM sent, every reply dead on arrival.

Adds the Event Subscriptions step to the socket block (after Socket Mode
is enabled, so the page doesn't demand a Request URL) and corrects the
false "Socket Mode skips all of this" line. Audited the other when:-split
skills (imessage, whatsapp, teams) — no other mis-sorted shared steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HB8rQxJN7itcFhNYVDntpm
2026-07-06 15:49:29 +03:00
Koshkoshinsk fc5d7a6b38 add-teams: retire the finish-wiring infobox; the closing What's-left note carries it
The 4-line operator block duplicated the wizard's ending note and was the
'too much for the average joe' feedback. Setup now says it once, at the very
end; the SKILL.md keeps a one-line prose version for agents applying the
skill outside the wizard. The note gains 'with your coding agent'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:22:52 +03:00
Koshkoshinsk 125aec0617 add-teams: drop ngrok references; Cloudflare Tunnel is the suggestive example
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:40:03 +03:00
Koshkoshinsk 72f6c7e8b2 add-teams: drop the install-libsecret guidance; mark the sideloading probe advisory
The libsecret warning fires even with libsecret installed (headless = no
keyring daemon) and the plaintext fallback persists fine — with the logout
step, nothing lingers either way. The warning is now a one-line 'safe to
ignore'; the AUTH_REQUIRED troubleshooting keeps only the real cause (pnpm
workspace install). The login step's sideloading probe flapped between runs
on the same account — documented as advisory; the install link is the
authoritative test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:35:58 +03:00
Koshkoshinsk b45dc2dc42 add-teams: sign out of the Teams CLI once the bot exists
The M365 session is only needed to create the bot — the adapter runs on the
.env app credentials. On a headless box the session is a plaintext token
file, so setup no longer leaves it on disk. teams logout is idempotent;
later CLI maintenance just needs a fresh teams login (device code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:52:05 +03:00
Koshkoshinsk 0bcc3a74b1 add-teams: note that the login step's 'Azure CLI: not installed' line is informational
The CLI-first flow creates a Teams-managed bot so az is never needed; the
line only matters for --azure bots and the manual portal path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:40:00 +03:00
Koshkoshinsk 8f9ecd9592 setup: on a deferred wire the last box is the remaining action, not 'go say hi'
Teams ended with two conflicting banners: 'What's left: DM your bot' followed
by 'Check your Teams — your assistant is saying hi' (no welcome DM exists on
a deferred wire). The pending flag now gates the ending: What's left moves to
the final slot in the bright banner style, Go say hi only shows for channels
that actually sent a welcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:12:26 +03:00
Koshkoshinsk 8772306627 add-teams: default to single-tenant without asking; multi-tenant becomes a manual alternative
The tenant fork cost every user a question that is almost always 'single'.
Create is unconditional --sign-in-audience myOrg again; the prereqs infobox
notes the default and points multi-tenant users at Alternatives, which
regains a manual multi-tenant section (create command + MultiTenant env
pairing without tenant ID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:41:54 +03:00
Koshkoshinsk 8644e6bedb setup: verify treats a defer-wire channel awaiting its first DM as pending, not failed
Teams can't wire during setup (platform id only exists after the first
inbound), so a fresh teams-only install always ended 'A few things still
need your attention' + a debug offer. Zero groups now passes verify when
every configured channel is defer-wire and service+credentials are healthy;
the block gains WIRING=pending_first_dm and auto.ts prints a one-line
finish-wiring reminder instead. A wire-during-setup channel with zero
groups still fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:48:26 +03:00
Koshkoshinsk 571d72ba47 add-teams: silence pnpm-inherited npm config noise; document benign libsecret warning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:46:21 +03:00
Koshkoshinsk 37e2d2b2bb add-teams: prompt for app/bot name and single-vs-multi tenant in both flows
The create step hardcoded --name NanoClaw and single-tenant; both are the
human's choice. Two new nc:prompt fences (app_name, tenant) feed a tenant-
branched create + env pairing — when:tenant=multi omits TEAMS_APP_TENANT_ID,
encoding the 401 pairing rule in the branch itself. Client-secret name and a
separate Azure bot name are NOT knobs on the CLI path (secret is auto-named
'default', 2y; --name covers registration+bot+Teams app) — documented, and
the manual portal path now names all four asks explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:13:46 +03:00
Koshkoshinsk ca298d3714 add-teams: invoke the Teams CLI by absolute path — npm's global bin dir is not reliably on PATH
Run #3 on the VM: the global install fully succeeded (bin linked, keytar
binary built — this npm's allowScripts gate is advisory-only), but the box
exposes node via hand-picked symlinks in ~/.local/bin and npm's prefix bin
dir (~/node/bin) is not on PATH, so every 'teams …' step died with command
not found. Classic custom-prefix gotcha, so fix it universally: the login
and create fences call "$(npm prefix -g)/bin/teams"; prose points manual
runs at the same path; new Troubleshooting entry for 'teams: command not
found'. Test matchers updated for the absolute-path command shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 01:05:49 +03:00
Koshkoshinsk d9859fd59a add-teams: install the Teams CLI globally via npm — pnpm's build-script policy breaks its credential store
Proven on the VM: as a workspace dep, keytar (msal-node-extensions' native
credential store, required at module load) never gets its binary because
pnpm's supply-chain policy skips dependency install scripts — the whole
store fails to import and teams.cli silently falls back to an in-memory
token cache, so every teams command after login starts logged out
(AUTH_REQUIRED ~1s into create). libsecret alone does not fix it; the
import dies before any keyring is touched.

npm install -g (pinned 3.0.2) runs keytar's install script in the global
env, keeps the workspace's onlyBuiltDependencies policy untouched, and
matches Microsoft's own install instruction. All commands drop the
pnpm exec prefix; REMOVE.md uninstalls globally after teams logout; the
AUTH_REQUIRED troubleshooting entry now names both causes (workspace
install, missing libsecret).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:57:04 +03:00
Koshkoshinsk ff557092d8 add-teams: verify sign-in persistence in the login step; diagnose libsecret + user-policy failures
Found on a headless Ubuntu box: teams login succeeded but the session died
with the process — libsecret is absent, so msal-node-extensions silently
falls back to an in-memory cache (debug-level log), and teams app create (a
fresh process) fails in ~1s with AUTH_REQUIRED, two steps away from the
cause.

- The login step now re-reads the session from a fresh process
  (teams status --json | grep loggedIn) before declaring success, so a
  non-persisting cache fails AT the sign-in step with prose naming the fix
  (apt install libsecret-1-0).
- Troubleshooting: new AUTH_REQUIRED-after-login entry; sideloading entry
  now distinguishes tenant-level from per-user App setup policy (the login
  output prints which one is blocking).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:31:12 +03:00
Koshkoshinsk f6901e01a4 add-teams: Teams-CLI-first credentials flow in SSF directive grammar
One sign-in and one create command replace the ~7-step Azure portal walk:

- have_creds probe runs FIRST (either credential key in .env skips every
  creation step — prompts included — so re-runs drop straight to Restart;
  either-key semantics prevent a partial .env from pairing a new app with
  stale keys, the guaranteed-401 mismatch).
- teams login runs as nc:run effect:step (browser locally, device code over
  SSH; body emits the terminal status block the streaming exec requires).
- teams app create --json is one effect:external with a five-var JSON
  capture (CLIENT_ID/CLIENT_SECRET/TENANT_ID + teamsAppId + installLink),
  validate:^.+$ so a shape drift bounces instead of writing empty creds.
  Single-tenant (--sign-in-audience myOrg) is the default pairing; the CLI
  registers a Teams-managed bot — no Azure subscription, no manifest zip,
  no manual sideload.
- Webhook endpoint corrected everywhere: /webhook/teams (was
  /api/webhooks/teams, which silently receives nothing).
- Portal walk + multi-tenant variant moved to prose Alternatives; RSC via
  teams app rsc add (CLI path) or manifest --rsc re-sideload (manual path).
- REMOVE.md: teams logout before CLI uninstall (MSAL cache outlives the
  package), Entra deletion required on both paths (it revokes the secret),
  drop the retired data/env mirror step.
- Parity tests rewritten: drop-through fixture (creds present → no create,
  no prompts, fail() spy), runSkill-level fresh-create fixture (injected
  execStream; proves capture→env-set chain + substituted install-link URL
  offer), probe semantics executed from the shipped document, policy gate
  inventory updated (3 operators, 1 barrier).
- claude-assist: point teams handoff file lists at files that exist on
  this branch.

Known deferrals (engine-owned, flagged for the SSF branch owner):
reuseFromEnv offers capture-var env keys on re-runs (needs a one-line
promptShape guard); effect:external is not run-health gated; captures
cannot be marked secret. docs/skill-engine-seam.md line anchors into the
old teams SKILL.md are stale (pre-existing anchoring style).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:12:28 +03:00
Koshkoshinsk 36a7e3cf81 teams-manifest: in-process zip writer, --rsc, independent-reader tests
- Write the app-package zip in-process (stored entries, byte-deterministic,
  fixed DOS timestamp) instead of shelling out to an external `zip` binary.
- Add the rsc option: authorization.permissions.resourceSpecific +
  webApplicationInfo (RSC consent binds to webApplicationInfo.id, not
  bots[].botId) and a manifest version bump so a re-upload supersedes the
  original package. Plumbed through teams-manifest-build.ts as --rsc.
- New test suite parses the zip with an independent minimal reader
  (EOCD → central directory → local headers) so a structure Teams would
  reject goes red; builds run in beforeAll with tmpdir cleanup.

Ported from the parked main-based commit (tag backup/teams-cli-main-cf56c10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:12:03 +03:00
gavrielc 2242f6c997 feat(skills): port Slack Socket Mode into add-slack — when:-guarded delivery modes
Upstream added Socket Mode to the guided setup (cf8478ff) in the bespoke
setup/channels/slack.ts this branch deleted. The port expresses it the way
the engine was built for: a `connection` prompt (socket|webhook) with
when:-guarded branches —

- socket: app-level token walkthrough (connections:write scope, Socket Mode
  toggle), `nc:prompt app_token` (xapp-, reuse:SLACK_APP_TOKEN),
  SLACK_APP_TOKEN env-set. The adapter enables Socket Mode purely from the
  token's presence, so no public URL and no signing secret.
- webhook: the existing signing-secret + Event Subscriptions path, now
  guard-scoped to its branch.

Gate-policy parity holds: all three operator blocks remain prompt-barriered
(no confirms), matching the §5.1 rules; the placeholder-URL negative fixture
moves to operator index 2. Structure-tracking tests updated (directive
order, operator/prompt/env-set inventories, Option-A inputs gain
connection:webhook).

Suite 827 passed | 1 skipped; add-slack lint exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:32:19 +03:00
gavrielc 40b65521c0 refactor(skills): retire nc:env-sync — the data/env/env mirror is dead
Upstream removed every setup-side writer of data/env/env (c82f062d) because
nothing has read it since the container mount was dropped — before this
branch even forked. Our engine codified the dead pattern as a directive:
every apply copied the full .env (live tokens included) into a file nothing
consumes.

- engine: env-sync handler removed from selfStatus + applyOne
- grammar: dropped from KNOWN; a new RETIRED table gives a targeted lint
  error ("delete the fence, the adapter reads .env directly") instead of a
  generic unknown-directive message
- skills: fence stripped from the 14 converted skills; orphaned "sync to the
  container" prose cleaned (incl. add-deltachat/add-wechat old-format prose
  upstream's sweep missed); teams troubleshooting entry rewritten
- policy/spec doc lists updated

Suite 827 passed | 1 skipped; all nc: skills lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:27:40 +03:00
gavrielc 81fea1d7ec Merge origin/main (185 commits) into feat/structured-skill-format
Conflict resolutions:
- 9 SKILL.mds we rewrote in the nc: format: kept ours, then bumped every
  @chat-adapter pin 4.26.0 -> 4.29.0 to match the merged chat core (the
  lockstep lint enforces equality; pin-test expectation updated).
- 14 setup/add-*.sh, install-*.sh + setup/channels/{slack,signal,whatsapp}.ts:
  stay deleted — replaced by the directive engine + runChannelSkill. Upstream's
  Slack Socket Mode (cf8478ff, setup-side) will be ported into add-slack's
  SKILL.md as a when:-guarded mode in a follow-up commit.
- src/cli/resources/groups.ts: fused create — upstream's --template branch
  first, else our idempotent-on-folder create with ensureContainerConfig.
- src/cli/registry.ts + dispatch.test.ts: both sides' additions kept
  (hostOnly field/doc + upstream's resource doc and approval-context fixture).

Known follow-ups (next commits): remove the now-dead nc:env-sync directive
(upstream removed the data/env/env mirror writers; the reader died pre-fork),
port Slack Socket Mode into the SKILL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 08:21:49 +03:00
gavrielc 8a3444a68c feat(setup): ordinal-suffix repeated spinner captions — (1/2), (2/2)
Two steps under one heading share a heading-derived caption (add-telegram's
build + test both render "5. Build and validate"), which reads as a
stuttered duplicate in the wizard. The driver now pre-computes labelOrdinals
from the parsed document (same pre-parse the gate policy uses) and suffixes
" (i/n)" when a caption repeats — keyed by the directive line the step
events already carry. Pure driver-side presentation; no engine change, no
event-payload change. Solo captions stay unsuffixed; counting is static, so
a runtime-skipped sibling can leave a cosmetic gap in the sequence.

Live stutter observed in the telegram wizard smoke run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:36:40 +03:00
gavrielc 0c020e664d fix(setup): live-smoke fixes — anchor the channels-remote glob; silence operator blocks once blocked
Two bugs the first live wizard run surfaced:

- channels-remote.sh's unanchored `*qwibitai/nanoclaw*` glob matched sibling
  channel repos (qwibitai/nanoclaw-discord sorts first on multi-remote
  machines) and resolved the from-branch copy to a remote with no channels
  ref, failing every from-branch fetch. The pattern now anchors the repo-name
  tail (nanoclaw / nanoclaw.git only).

- Once the run-health gate latched `blocked`, later nc:operator blocks still
  rendered — walking the human through "a pairing code is about to appear"
  and a readiness confirm for a step the engine had already gated. Blocked
  runs now skip operator directives: no event (so no URL offer / readiness
  confirm), no operatorMessages entry, recorded in skipped. Spec §2 updated.

Suite 722 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:05:59 +03:00
gavrielc c02c002caa refactor(skills): grammar diet — lint rejects the six removed presentation attrs
Drop min:/error:/open:/gate validation and their grammar doc from
scripts/skill-directives.ts; add hard lint errors for all six removed
attrs (operator open:/gate, prompt min:/error:, any-directive
label:/on-fail:) so stale authorship fails loudly instead of silently
no-oping, each pointing at its replacement. Add the warn-only
lintGateAmbiguity check (unguarded operator followed by when:-guarded
directives spanning more than one branch value — spec §3/§5.1 open
question 1), wired into the CLI warnings alongside the reference floor.
Prompt/operator grammar doc rewritten for validate-at-bind and
structure-derived gating; the dead open: {{var}} ref check removed.
Directive tests reworked: min: dropped from the attrs fixture, the
open/gate supported-syntax suite re-added inverted, gate-ambiguity
fixtures incl. an all-channel-skills negative sweep.

Spec: docs/skill-engine-seam.md §8 step 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:50:45 +03:00
gavrielc 498b80b344 refactor(skills): skills cleanup — strip gate/open:/min: attrs, fold URLs into prose
Per docs/skill-engine-seam.md §8 step 4 (+§3 table, §5.2 inventory):
- add-teams: drop open:https://portal.azure.com (URL already in the body),
  drop both gate attrs (natural-barrier policy reproduces them — §5.1 parity),
  min:20 → validate:^.{20,}$ with "(at least 20 characters)" folded into the
  question prose (§5.3 derives error text from it).
- add-telegram: drop open:, fold https://t.me/{{bot_username}} into the
  operator body — the URL offer (§5.2) preserves the open-in-browser behavior.
- add-discord: drop open:, fold the OAuth2 invite URL into the operator body.

All three skills re-linted clean; the teams run-channel parity test still
proves both barriers fire and the portal offer survives from prose alone.
Suite: 713 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:45:26 +03:00
gavrielc 76c1f13ca3 refactor(skills): core deletion of the old seam — Prompter/PromptOpts/StepReporter and the presentation attrs leave the engine
The engine now declares and emits only: inputs/resolveInput acquire,
onEvent renders. Removed the legacy Prompter (ask/tell/confirm/open),
PromptOpts, StepReporter, ApplyOptions.prompter/reporter, and engine
handling of open:/gate (driver policy now owns both), label: (labels
are heading-derived only), on-fail:+AgentTask.hint (hint is always the
prose — firstFailureHint reads it directly), and the min:/error:
plumbing (flags/normalize live on InputMeta). Engine tests for the
removed syntax deleted; semantic siblings kept (heading labels,
prose-hint default, operator-body {{var}} deferral — plus a new
no-event-fired assertion for the deferred block).

Spec: docs/skill-engine-seam.md §8 step 3, §9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:42:00 +03:00
gavrielc 22b294e233 refactor(skills): shared policy module + wizard driver migration — presentation derived from document structure
scripts/skill-policy.ts: UI-free gatePolicy(md) (§5.1 rules 1-5: guard-
compatibility, operator-chain termination, prompt/end-of-doc barriers,
confirm flavor) + extractOfferUrl(text) (§5.2 placeholder exclusion; slack's
<your-public-host> is the normative negative), unit-tested against the full
parity table over the real in-tree channel skills.

setup/lib/skill-driver.ts: clackPrompter/spinnerReporter dissolve into
clackResolveInput (help-escape intact) + a default onEvent policy handler
(spinner branch + operator note → URL offer → natural-barrier confirm,
decline = proceed); new TTY-gated confirm/openUrl seams on RunSkillOptions;
an injected onEvent replaces the default policy entirely; reuseFromEnv
pre-filters offers through the prompt's normalize/validate/flags (§5.4);
promptValidator loses min/error and derives its message from the question
prose (§5.3). run-channel-skill renames the overrides and passes the new
seams through; providers/install drops its defer-all stub Prompter. No
in-tree caller passes prompter/reporter anymore; normalizeValue is exported
for the reuse pre-filter. Suite: 723 passed | 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:32:10 +03:00
gavrielc 79060c5407 refactor(skills): core seam (additive) + validate-at-bind — resolveInput/onEvent land beside the legacy prompter/reporter
Step 1 of the skill-engine seam plan (docs/skill-engine-seam.md §8.1):

- New core interface (§2): InputMeta + ApplyEvent types;
  ApplyOptions.resolveInput (replaces Prompter.ask) and
  ApplyOptions.onEvent (replaces StepReporter + Prompter.tell). The
  engine prefers the new seams when present and falls back to the
  legacy prompter/reporter otherwise (transitional — deleted in step 3).
- Awaited-event ordering (§2.3): every onEvent call is awaited before
  the next directive; a handler throw is the standard bounce path,
  including on operator events (blocked latch cascades).
- Validate-at-bind (§4): normalize-then-validate at the single bind
  point for inputs, resolveInput answers, and legacy prompter answers
  alike. A mismatch leaves the var unbound and records
  `<var>: invalid value (does not match validate:<re>)` in deferred —
  never the value (secrets can't leak), never a fallthrough from
  invalid inputs to resolveInput.
- stepLabel null semantics re-documented (§2): a step-cost/interactivity
  declaration, not spinner advice.
- §4 fixture sweep: Option-A slack inputs (signing_secret, owner_handle)
  + the userId assertion updated to validate-passing values; teams
  deferWire app_password to a 20+-char value. Assertion structure
  unchanged.

Suite: 699 passed | 1 skipped (was 680 | 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:19:18 +03:00
gavrielc 912a89bf94 docs: skill-engine seam spec — declaration/acquisition split
Spec for splitting scripts/skill-apply.ts into a declare/emit core
(inputs, resolveInput, onEvent) and consumer-side acquisition/
presentation (wizard gate policy, URL offer, reuse confirm). Folds the
confirmed review findings: operator-chain gate rule fix (teams :158
double-confirm), Option-A fixture updates under validate-at-bind,
placeholder-URL exclusion, shared scripts/skill-policy.ts module,
driver confirm/openUrl seams, pipeline effect:step boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:06:33 +03:00
Koshkoshinsk 77f3be57fa Merge upstream/main (templates) into feat/global-provider-default
Resolution: main replaced the generic `groups create` with a custom
handler (--template branch), removing the generic-create path our
afterCreate hook attached to. Re-seat the instance-default stamp as a
direct ensureContainerConfig(group.id) call in the bare-row handler,
and drop the now-userless afterCreate hook from crud.ts. Template
creation already calls ensureContainerConfig and inherits the default
through the chokepoint unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:05:02 +03:00
Koshkoshinsk 35248f1bfa fix: persist DEFAULT_AGENT_PROVIDER only after provider install + auth succeed
A failed codex setup previously left DEFAULT_AGENT_PROVIDER=codex in .env,
defaulting every future group to an unauthenticated runtime. Also drop the
overstated "single writer" claim on upsertEnvVar — legacy setup steps still
write .env directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:47:27 +03:00
Daniel M 551660a2bf Merge branch 'main' into feat/global-provider-default 2026-07-01 17:03:42 +03:00
Koshkoshinsk a13bb24300 docs: align changelog + update-nanoclaw with instance-default provider
CHANGELOG: reword the unreleased provider entry to describe the
instance-wide default (DEFAULT_AGENT_PROVIDER) rather than stating no
install-wide default exists.

update-nanoclaw: drop Step 6.5 (the codex-default-offer during updates);
the default is set in /setup and by /add-codex, not on upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:55:00 +03:00
Koshkoshinsk ec35d9c3f7 docs: document the instance-wide default provider
- add-codex: offer to set DEFAULT_AGENT_PROVIDER=codex on install.
- update-nanoclaw: nudge to default new groups to codex when codex is installed
  but no default is set (detected via src/providers/codex.ts).
- manage-channels / init-first-agent: correct the stale 'no install-wide default'
  and dead --provider guidance.
- picked-provider: rewrite the header to reflect the persisted default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:19:16 +03:00
Koshkoshinsk 96f924f2ac feat: instance-wide default agent provider for new groups
Add DEFAULT_AGENT_PROVIDER (.env-backed, default 'claude'): newly created agent
groups are created on this provider; existing groups are never touched. Applied
at a single chokepoint — ensureContainerConfig stamps a fresh config row with it
(INSERT OR IGNORE, so existing rows stay frozen). Resolution is unchanged
(session -> container_configs.provider -> 'claude'); per-group
`ncl groups config update --provider` still overrides.

- config: DEFAULT_AGENT_PROVIDER constant.
- chokepoint: ensureContainerConfig defaults an absent provider to it,
  normalizing claude/casing to NULL/lowercase; every creation path
  (channel-approval, register, init scripts, ncl groups create afterCreate)
  inherits it without each having to remember.
- subagents (create_agent) inherit the parent's effective provider, not the
  global, so a child never spawns on a runtime the parent can't reach.
- setup persists the operator's pick to .env and pre-selects the current default
  in the picker so a re-run does not silently reset it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:19:16 +03:00
Koshkoshinsk 5945a19655 refactor: extract upsertEnvVar and add generic afterCreate CRUD hook
Structural prep for the instance-wide default provider, no behavior change:
- Extract the .env upsert from set-env's run() into a reusable upsertEnvVar()
  so setup code can persist a key without reinventing the grep/sed pipeline.
- Add an optional afterCreate hook to the CRUD ResourceDef + genericCreate so a
  resource can seed a dependent row the single-table INSERT can't cover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:19:16 +03:00
gavrielc 9c91c00c93 feat(setup): reference-prose floor — restore dropped reference prose + kill engine-narration leak
Add referenceProse(md) to slice the engine-ignored reference sections
(## Alternatives / ## Optional configuration / ## Troubleshooting) verbatim
from the raw markdown, surfaced read-only on ApplyResult.referenceProse;
run-channel-skill shows it beside agentTasks on a bounce (the degrade floor a
human would scroll to). Add lintReferenceFloor(md) — WARN-ONLY (never blocks)
when a credentialed/interactive skill (nc:prompt secret or nc:run effect:step)
ships no ## Troubleshooting.

Restore the dropped agent-facing reference prose in add-signal (Path-A
dedicated-number registration, the 5 daemon env keys + TCP security note, the
~7 dropped Troubleshooting entries — correctly dropping the wiring SQL and the
Java prereq), add-whatsapp (QR-in-browser alternative + headless detection,
ASSISTANT_HAS_OWN_NUMBER, Pairing-code-not-working), and add-teams (Teams-CLI/az
auto path, RSC receive-all manifest block, a new Troubleshooting section). Also
delete the engine-narrating leak paragraph at the top of add-signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:12:06 +03:00
gavrielc f8214ed1d8 feat(setup): backGate — first-prompt back-to-channel-selection
Add backGate(label) to setup/lib/back-nav.ts: a brightSelect ["Yes,
connect <label>", "← Back to channel selection"] wrapped in ensureAnswer
(Esc/Ctrl-C → exit 0), returning the existing BACK_TO_CHANNEL_SELECTION
sentinel on back. runChannelSkill grows an opt-in offerBack flag that
runs the gate at the very top — before resolveAgentName/role, the skill
run, and the wire (covers Teams/deferWire too). All auto.ts channel
dispatch sites opt in. Kept opt-in so headless callers and existing
tests are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:01:51 +03:00
gavrielc 665b0dca77 feat(setup): help-escape handoff — a lone "?" at any prompt hands off to Claude
clackPrompter now wraps each prompt's validate with the tested
validateWithHelpEscape helper so a lone "?" short-circuits format checks,
then intercepts it post-prompt: it calls offerClaudeHandoff with the run's
channel + step context and the prompt question, then re-asks the same
prompt. Recursion is operator-bounded. Gated on a TTY, so a headless /
non-TTY run is a no-op (no interactive child spawned).

The channel + step label are plumbed through RunSkillOptions → runSkill →
clackPrompter, and run-channel-skill threads the channel it is wiring so
the handoff has the right context. An injected prompter is unaffected (the
injector owns its I/O).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:55:53 +03:00
gavrielc 46de9ae05a feat(setup): Prompter.open + nc:operator open:/gate — human barrier for manual UI steps
Add a trailing-optional Prompter.open?(url) (wired in clackPrompter to
setup/lib/browser.ts openUrl, headless-safe). Extend the nc:operator handler
with two pure-polish attrs: open:<url> deep-links the operator to the page the
steps describe (after rendering, {{vars}} substituted), and a bare gate flag
turns the block into a human BARRIER — a confirm the engine waits on before the
following side-effecting directives run. Both degrade invisibly: a stripped
fence leaves the same prose, and a prompter without open/confirm skips them.

Grammar/validator awareness in skill-directives (open: var-ref check + empty-URL
flag). Applied: add-teams gates the operator blocks before the manifest build +
restart (fixes the manifest-builds-before-the-Azure-app-exists hazard) and opens
the Azure portal; add-telegram opens the t.me bot deep-link; add-discord opens
the OAuth invite URL (pre-filled with the captured application_id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:47:13 +03:00
gavrielc d7d104aa0b feat(setup): M3 PromptOpts — flags/min/normalize/error + helper-owned reuse
Widen Prompter.ask with a trailing-optional PromptOpts (flags/min/error/
normalize); existing async ask(name) fakes are untouched. clackPrompter builds
the regex with opts.flags, enforces min-length, surfaces opts.error, and sets
clearOnError on the secret prompt — via an exported, TTY-free promptValidator.
normalize (trim | rstrip-slash | lower) binds DETERMINISTICALLY in the engine,
so an inputs value and a typed answer land identically. The grammar + validator
learn flags/min/normalize/error/reuse on nc:prompt. reuseFromEnv gains a second
pass over reuse:<ENV_KEY> so a credential a helper script owns (imessage Photon
IMESSAGE_SERVER_URL/API_KEY, written by effect:external) regains the masked
reuse offer. Applied: add-teams app_password min:20, public_url
normalize:rstrip-slash; add-imessage server_url flags:i + reuse on the Photon
prompts. SKILLs stay lint-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:32:50 +03:00
gavrielc bd0962fd97 feat(setup): bounced-step prose as failure hint — M4 surface the diagnosis
When a channel skill doesn't fully apply, surface the prose beside the
directive that bounced as the operator's failure hint instead of a generic
"couldn't finish" message.

- skill-apply.ts: AgentTask gains hint? (default = trimmed prose; optional
  on-fail:<token> attr narrows it to the prose LINE diagnosing the failure,
  falling back to full prose when the token has no matching line so a stripped
  fence never leaks a bare token). New exported firstFailureHint(res) returns the
  first bounce's heading as a concise headline + its hint.
- run-channel-skill.ts: the !fullyApplied path threads firstFailureHint into
  fail(`<channel>-install`, headline, hint) so runner.ts dims the hint and
  forwards it to offerClaudeOnFailure. fail is now injectable for tests.
- claude-handoff.ts: repoint buildHandoffPrompt's stale setup/channels/<ch>.ts
  reference to the channel's SKILL.md (the bespoke files are deleted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:24:12 +03:00
gavrielc d997d545cc feat(setup): multi-field JSON capture + validate-on-capture — derive Discord creds from one call
Extend the stdout-capture branch (effect:fetch/external) so a
`capture:a=.x,b=.owner.id` spec parses the command stdout as JSON and binds
each var to its jq-style dot-path — one API call resolves several values. A
bare `capture:var` (no =) still binds stdout as-is. effect:step's terminal-
block field capture is untouched (distinguished by effect). A `validate:<re>`
on a run capture shape-guards each bound value; a mismatch (or unparseable
JSON) throws → bounces to an agent (a command's output has no human to
re-prompt). The linter validates the run-capture regex too.

Rewrite add-discord Resolve to derive application_id + public_key +
owner_handle from one `GET /oauth2/applications/@me` call, removing the two
hand-pastes and the shape-only prompt validation. Resolved owner_handle /
platform_id stay byte-identical to the prior hand-entered flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:15:23 +03:00
gavrielc 8455eb0809 feat(setup): effect:check predicate gate — precondition checks that bounce/defer/gate
Add an `effect:check` branch to the run-case in the apply engine: it runs the
body as a shell predicate, mutating nothing (no journal, no capture). A non-zero
exit bounces to an agent and latches the run-health `blocked` flag, so a failed
precondition gates the dangerous side effects (restart, pairing/QR step, wire)
that follow; an unresolved {{var}} defers. Document the new effect in the
directive grammar.

Author the four lost safety preconditions as one-line nc:run effect:check blocks
beside their prose: add-imessage (non-Mac local hard-fail), add-whatsapp (empty
captured bot_phone guard), add-teams (Azure app_id set before manifest build),
add-signal (signal-cli presence probe before linking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:04:48 +03:00
gavrielc 462619d287 feat(setup): apply-lifecycle reporter — per-step driver spinners
The apply engine now brackets each real mutation (applyOne) with a
StepReporter (stepStart/stepEnd, balanced even on the failure path) and
derives a human spinner caption per step via stepLabel: the nearest
heading for slow/effectful kinds (dep, run, branch-fetch copy), null for
instant kinds (local copy, env-set, json-merge, env-sync) and for the
self-rendering effect:step so a spinner never clobbers a QR/pairing card.
A label:<word> fence attr overrides; it's stripped with the fence, so it
degrades invisibly to prose.

runner.ts exposes its spinner machinery as startSpinner (runUnderSpinner
now delegates to it, behavior unchanged). skill-driver.ts builds a
TTY-gated spinnerReporter from it and passes it into applySkill by
default, so piped/CI/test runs stay silent and unchanged. run-channel-skill
threads an optional reporter override and keeps the connected_as line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:59:28 +03:00
gavrielc c249989b5a feat(setup): run-health gate — a bounce blocks later side effects
Once any directive bounces to an agent, the skill is no longer in a
known-good state, so the engine must not fire a dangerous side effect on
its own — a live restart, an interactive pairing/QR step, or a wire 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 and converts any later effect:restart / effect:step / effect:wire
into its own bounce, so the agent finishes it from the prose after fixing
the failure. A deferred prompt (headless rebuild, no answer) never
bounces, so `blocked` stays false and a later restart remains runnable.

Reorder add-slack so auth.test / conversations.open validate the
credential ABOVE effect:restart — fast-fail a bad token while it's still
cheap, matching the old bespoke-flow position.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:48:34 +03:00
gavrielc 3b41171071 feat(setup): convert all 5 remaining channels to the SKILL.md flow; delete bespoke .ts
telegram, signal, whatsapp, imessage, teams now run through the directive
engine via runChannelSkill — the bespoke setup/channels/<ch>.ts flows
(2.6k lines) are deleted. Built on the when:/effect:step primitives:

- imessage — when:mode=local/remote guards + setup/channels/imessage-configure.sh
  (effect:external) for the mode-exclusive env writes. Wires normally.
- signal / telegram / whatsapp — effect:step against their trunk step scripts
  (signal-auth / pair-telegram / whatsapp-auth), which now render their own
  operator display (QR / code card) as plain stdout lines and emit the capture
  fields in the terminal block. Wire via the resolved owner_handle + platform_id.
- teams — platform_id is runtime-only, so the SKILL installs + manifests + ends
  with an nc:operator handoff; runChannelSkill gains a deferWire path that skips
  resolve+wire (mirrors the old finishWithHandoff).

auto.ts dispatches every channel through runChannelSkill; provider-contract
CREATION_FILES drops the deleted flows. Byte-parity for platform_id + user_id
was adversarially re-derived per channel and confirmed identical.

Verified: typecheck clean; full suite 620 passed | 1 skipped; all 5 skills
lint clean; the deferWire teams path is exercised end-to-end (fake exec).
NOT yet integration-tested: the effect:step live streaming handshake against
real Telegram/Signal/WhatsApp accounts (no credentials available) — wiring,
byte-parity, and the step-script tweaks are verified statically only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:07:26 +03:00
gavrielc 6f616824d6 feat(skills): when: guards + effect:step streaming run with multi-field capture
Two generic engine primitives, derived from how the bespoke setup flows
already work, so the remaining channels can move to SKILL.md:

- `when:<var>=<value>` — a guard on any directive, evaluated against an
  earlier prompt/capture var. Unmet (incl. unresolved) skips the directive;
  a guarded prompt is skipped, never deferred, so a fully-programmatic apply
  still completes across mutually-exclusive branches (e.g. imessage's
  local vs remote mode). Lint requires the guard var be defined earlier.

- `nc:run effect:step` + `capture:<var>=<FIELD>,…` — directive access to the
  existing spawnStep/StatusStream mechanism: runs a long-running,
  operator-interactive step (a pairing code, a QR device-link), tees its
  `=== NANOCLAW SETUP ===` blocks to the operator live, and binds the
  terminal block's named fields into vars. Degrades to an agent when no
  streaming exec is wired. New seam: ApplyOptions.execStream; the driver
  provides hostExecStream. One primitive covers telegram/whatsapp/signal.

Full suite 624 passed | 1 skipped; the six already-converted skills still
lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:27:54 +03:00
gavrielc ab596d31d3 feat(setup): convert discord to the Option A SKILL.md flow; delete discord.ts
Ran a multi-agent workflow over the remaining chat-SDK channels
(discord/teams/imessage/telegram), each converting its SKILL.md to the Option A
resolve pattern with an adversarial verify that the captured platform_id is
byte-identical to what the old bespoke .ts passed init-first-agent.

Outcome:
- discord: CONVERTED + verified. Resolve prompts owner_handle (Discord user id,
  validate ^\d{17,20}$), captures connected_as (/users/@me) and platform_id
  (POST /users/@me/channels → "discord:@me:" + .id — byte-identical to the old
  discord:@me:<dmChannelId>). auto.ts now calls runChannelSkill('discord');
  setup/channels/discord.ts deleted. (Minor deviation: application_id/public_key
  are prompted-with-validate rather than auto-derived from /oauth2/applications/@me
  as the old flow did — values identical, one extra paste standalone.)
- imessage: resolve added (owner_handle = phone/Apple-ID handle; platform_id =
  the raw handle, byte-identical — native adapter, no prefix). NOT yet wired: its
  credentials branch on local vs remote mode, which a directive can't express
  deterministically (needs a shell-conditional run). Bespoke imessage.ts stays.
- teams: does NOT fit — the DM platform_id (teams:<b64 conv>:<b64 url>) only
  exists after the first inbound activity; no synchronous API resolves it.
- telegram: does NOT fit — resolve is an interactive pairing handshake
  (pair-telegram.ts), not a directive.

613 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:22:00 +03:00
gavrielc 564e605900 feat(setup): finish the polish — identity display + reuse-existing credentials
Closes the last two gaps to near-parity with the old bespoke setup.

Identity display ("Connected to slack as @bot in Acme"):
- add-slack's token check now captures the identity — auth.test piped through
  jq into `capture:connected_as` (still fails on a bad token). runChannelSkill
  shows res.vars.connected_as after the skill runs.

Reuse-existing credentials:
- Prompter gains `confirm`; clackPrompter implements it via p.confirm.
- runSkill gains a `reuse` option: before prompting, it maps each prompt var to
  its ENV_KEY from the skill's own `env-set` directives, checks `.env`, and for
  any already set offers "Found an existing SLACK_BOT_TOKEN (xoxb-…). Use it?".
  Accepted values become inputs, so the prompt is skipped. runChannelSkill opts
  in (reuse: true). Generic — works for any skill, no per-channel logic.

Tests: reuse accept/decline; identity capture flows through the adapter. 614 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:04:45 +03:00
gavrielc be0ca10630 feat(skills): nc:prompt validate:<re> — restore input format validation
Restores the old setup's credential format checks. A prompt can carry
`validate:<regex>`; the clack prompter re-asks until the answer matches (a bad
paste is caught at entry, not later). `inputs` bypass it (programmatic apply is
trusted). Lint rejects a non-compiling regex.

add-slack prompts now validate: bot_token `^xoxb-`, signing_secret
`^[a-fA-F0-9]{16,}$`, owner_handle `^U[A-Z0-9]{8,}$`.

612 pass. Remaining setup polish: "Connected as @bot" identity display and
reuse-existing-credential prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:43:49 +03:00
gavrielc 0a5ffde133 refactor(setup): Option A — shared wire stays in init-first-agent, no duplication
The connect/wire procedure (owner role + cli_scope=global, messaging-group,
wiring, /welcome) is identical across channels, so it should NOT be re-expressed
in every channel SKILL.md. It stays in scripts/init-first-agent.ts. A channel
SKILL.md owns only the channel-specific part: install + credentials + resolving
the wire inputs (owner_handle + platform_id).

- Engine: ApplyResult gains `vars` — the non-secret resolved values (prompt
  answers + capture outputs), so a caller can read what a skill produced.
  Secrets are never surfaced.
- add-slack SKILL.md: the ncl wire block is removed. "Connect yourself" becomes
  "Resolve your DM channel" — prompt owner_handle, validate auth.test, and
  capture platform_id from conversations.open as `slack:<channelId>`.
- runChannelSkill: re-adds the shared polish (agent name default "Nano", role
  owner|admin) and routes the wire through init-first-agent with the composed
  user-id and the resolved platform_id. No ncl wiring emitted by the adapter.

This restores the two regressions for free (cli_scope=global and the /welcome
system instruction come from init-first-agent) plus role choice and the
agent-name prompt — with the wire in exactly one place. Dry-run on the real
add-slack: res.vars = {owner_handle, platform_id}, secrets excluded. 611 pass.

Remaining polish (next): prompt format-validation, "Connected as @bot" identity
display, reuse-existing-credential prompts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:40:19 +03:00
gavrielc 4c4b2ecbbe feat(setup): wire /setup slack through the SKILL.md driver — delete slack.ts
auto.ts now applies add-slack via the thin driver instead of the bespoke
runSlackChannel. The whole connect+wire procedure lives in the SKILL.md; the
host side is just the driver plus a small adapter that ensures the wire-target
agent group.

- runChannelSkill(channel, displayName): ensures the `dm-with-<name>` agent
  group exists over ncl (idempotent — a 2nd DM channel reuses it), then runs
  `.claude/skills/add-<channel>` with that folder pre-supplied as agent_folder.
  Reports the outcome via fullyApplied. All I/O injectable for tests.
- `ncl groups create` → custom op: creates the agent group AND its container
  config (a working group needs it; generic create made only a bare row),
  idempotent on folder. The minimal wire-target slice of the deferred #3.
- skill-driver: resolveRemote is now an option (so the adapter/tests can inject).
- Deleted setup/channels/slack.ts — the first bespoke channel flow retired.
  provider-contract test now points at run-channel-skill.ts.

Only slack is migrated; the other channels keep run<Channel>Channel until their
SKILL.md wire sections are converted. Tests: groups-create scaffold+idempotent;
the adapter drives the real add-slack with injected I/O (ensures group → wires
to it). 610 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:32:29 +03:00
gavrielc f6c13eb6f3 feat(setup): thin generic skill driver — render + ask + run, no phasing
The whole connect+wire procedure now lives in the SKILL.md (operator
walkthroughs, prompts, restart, ncl wiring), so the host-side driver is just:
render nc:operator via clack notes, ask nc:prompt via clack text/password, run
the engine in document order. One function replaces the bespoke per-channel
setup/channels/<channel>.ts flows.

setup/lib/skill-driver.ts:
- clackPrompter(): ask (password for secrets / text; cancel defers) + tell (note).
- hostExec(root): returns stdout (so `run capture:<var>` binds it) and puts the
  project bin/ on PATH so a bare `ncl …` resolves to bin/ncl.
- runSkill(skillDir, opts): applySkill wired with the clack prompter, host exec,
  fork-aware channels remote; accepts `inputs` (fully programmatic) and
  `skipEffects` (e.g. ['restart'] when the caller restarts once). Returns the
  ApplyResult; fullyApplied(res) is the success check.
- CLI entry: `tsx setup/lib/skill-driver.ts <skillDir>` applies interactively.

Tests: drives prompt/operator/wire through an injected prompter; runs fully from
inputs; hostExec PATH-resolves a bare command and returns stdout. typecheck +
608 host tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:18:37 +03:00
gavrielc f629ff25fa feat(skills): effect:restart directive — phasing is just directive order
The install→restart→wire "phases" aren't a driver concern; they're directives in
document order. Restart is a run, placed between the install/credentials and the
wiring, and the engine already runs directives top-to-bottom — so no phase
orchestration is needed.

- effect:restart: a run whose body restarts the service and waits for the ncl
  socket so a following wiring directive doesn't race it. New helper
  setup/lib/restart.sh (platform-aware restart + best-effort socket wait; the
  engine execs each body line separately, so one self-contained script).
- ApplyOptions.skipEffects: run effects the CALLER owns and performs itself are
  skipped (not executed). A standalone /add-slack runs the restart; a headless
  rebuild or a setup that restarts once at the end passes ['restart'] and it's
  skipped. The declarative form of applyProviderSkill's isFlowOwnedCommand.

add-slack now carries the restart between Credentials and the wire, so the whole
flow is one self-contained ordered directive sequence. Dry-run: standalone runs
the restart, a flow caller skips it — both fully apply, both wire. 604 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:11:35 +03:00
gavrielc 3eca5588f9 feat(skills): inputs option — fully programmatic apply, no prompter needed
ApplyOptions gains `inputs` (prompt var → value), checked before the prompter.
Pass every answer and the whole skill runs through with no human interaction and
no prompter; the prompter (now optional) only fills prompts `inputs` doesn't
cover. A prompt with neither still defers (headless rebuild, unchanged).

- ApplyResult gains `operatorMessages`: every `nc:operator` body is collected
  (and still rendered live when an interactive prompter is present), so a
  programmatic caller relays/outputs the human steps instead of them blocking.
- `fullyApplied(res)` — the check a programmatic caller makes: nothing deferred
  for a missing input, nothing bounced to an agent.

Proof: the real add-slack skill applied from `inputs` alone (no prompter) →
fullyApplied true, 0 deferred / 0 agentTasks, the captured DM channel flows into
all 5 ncl commands, and both operator walkthroughs are collected for relay.

Tests: run-through-from-inputs, missing-input-defers, inputs-win-over-prompter.
604 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:56:32 +03:00
gavrielc e33fa970f5 feat(skills): nc:operator — delineate operator-facing content from agent prose
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>
2026-06-27 16:37:12 +03:00
gavrielc 3c14310928 docs(skills): make the wire prose prose-primary again — oblivious to auto-apply
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>
2026-06-27 16:19:12 +03:00
gavrielc f387d371fd feat(skills): nc:run capture — output into a {{var}}; slack flow is pure SKILL.md
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>
2026-06-27 16:09:47 +03:00
gavrielc 2f26d4ebc1 feat(skills): programmatic wiring via ncl — no nc:wire directive
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>
2026-06-27 15:46:12 +03:00
gavrielc c277deae21 fix(skills): conformance batch — REMOVE hygiene, explicit no-test notes, dead test
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>
2026-06-16 23:10:17 +03:00
gavrielc 0cc8ade34c fix(skills): drop add-linear's dead bridge catch-all patch
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>
2026-06-16 23:03:20 +03:00
gavrielc d33806389c fix(skills): gmail/gcal/rtk mounts use ncl add-mount, not raw SQL
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>
2026-06-16 22:15:47 +03:00
gavrielc f350ed24e1 feat(cli): host-only ncl groups config add-mount / remove-mount
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>
2026-06-16 22:15:46 +03:00
gavrielc cc07387025 test(skills): copy + run the registration test in all chat-sdk channels
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>
2026-06-16 22:00:35 +03:00
gavrielc 59460e9a5c feat(skills): convert all chat-sdk channels + codex to nc: format, wire setup
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>
2026-06-16 21:48:30 +03:00
gavrielc ae48986e42 feat(skills): structured nc: directive format + apply engine, first applied to Slack
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>
2026-06-14 22:42:47 +03:00
222 changed files with 15606 additions and 8256 deletions
+66 -74
View File
@@ -11,6 +11,8 @@ 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
@@ -23,92 +25,69 @@ 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`
### Fetch and copy
### 1. Fetch and copy the payload
```bash
git fetch origin providers
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
```
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
### 2. Wire the barrels
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
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.
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");
}
'
```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';
```
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.
### 3. CLI manifest
### Build
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.
```bash
```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
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
### Restart the host
### 5. Validate
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
```
### Validate
```bash
```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/
```
```nc:run effect:test
cd container/agent-runner && bun test src/providers/
```
@@ -116,9 +95,7 @@ The registration tests import only the real barrels — they go red if a barrel
## Authenticate
> **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
```nc:run effect:external
pnpm exec tsx setup/index.ts --step provider-auth codex
```
@@ -135,7 +112,22 @@ ncl groups restart --id <group-id>
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
### Default new groups to codex (optional)
New groups are created on the **instance default** (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). Installing this skill wires codex in but does NOT change that default — "installed" is not "authenticated", so the default stays claude until you opt in explicitly.
After install, ask the operator before flipping it:
> "Codex is installed. Default new agent groups to codex? Existing groups keep their current provider."
On yes — set it, then restart the host so it takes effect:
```bash
pnpm exec tsx setup/index.ts --step set-env -- --key DEFAULT_AGENT_PROVIDER --value codex
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS; Linux: systemctl --user restart nanoclaw
```
This affects only groups created afterward. Per-group `ncl groups config update --provider` still overrides the default in either direction. Creation itself stays provider-agnostic (no `--provider` flag — provider is a DB property stamped from the instance default at creation).
## Troubleshooting
@@ -1,39 +0,0 @@
// 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,7 +89,6 @@ 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
+121 -58
View File
@@ -5,99 +5,162 @@ description: Add Discord bot channel integration via Chat SDK.
# Add Discord Channel
Adds Discord bot support via the Chat SDK bridge.
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the Discord 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 Discord adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/discord.ts
src/channels/discord-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './discord.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/discord@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/discord@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/discord-registration.test.ts
```
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`.
`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.
## Credentials
### Create Discord Bot
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:
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
```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.
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
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.
## 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 wire
this channel with `/init-first-agent` (or `/manage-channels`).
## 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
- **default-isolation**: Same agent group for your personal server. Separate agent group for servers with different communities or where different members have different information boundaries.
## Troubleshooting
**The Bot Token paste is rejected.** The token must be at least 50 characters of letters, digits, dots, underscores, and hyphens — a real Bot Token has two `.` separators. It lives under **Bot → Reset Token** in the Developer Portal and is shown only once; reset to get a fresh one. The short numeric **Application ID** and the **OAuth2 Client Secret** are different values and won't pass.
**`applications/@me` returns 401.** The token was reset since you copied it, or a stray space/newline came along with the paste. Reset the token in the Bot tab and re-run the check — it fails here on purpose, before the restart, while the credential is still cheap to fix.
**The bot is online but never sees your messages.** Two usual causes: Message Content Intent is off (Bot tab → Privileged Gateway Intents), so message bodies arrive empty and nothing triggers; or the bot doesn't share a server with you — in which case `POST /users/@me/channels` also refuses. Open the invite URL and add the bot to a server you're in, then retry.
**Adapter looks installed but Discord never connects.** Run `pnpm exec vitest run src/channels/discord-registration.test.ts` — red means the barrel import or the `@chat-adapter/discord` install drifted, so re-run the Apply steps. If it's green, the service probably hasn't restarted since the credentials were stored: `bash setup/lib/restart.sh`, then check `logs/nanoclaw.error.log` for missing `DISCORD_PUBLIC_KEY` / `DISCORD_APPLICATION_ID` complaints.
@@ -0,0 +1,15 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. The applications/@me stub answers the multi-field JSON capture (application_id/.id, public_key/.verify_key, owner_handle/.owner.id); the users/@me/channels stub answers the platform_id capture.",
"scenarios": [
{
"name": "bot-token",
"inputs": {
"bot_token": "MTA0fake.token.fake_fake-fake.fake0123456789abcdefghijklmnopqrstuvwxyz"
},
"exec": [
{ "match": "oauth2/applications/@me", "stdout": "{\"id\":\"1234567890\",\"verify_key\":\"abcdef0123456789\",\"owner\":{\"id\":\"111222333444\"}}" },
{ "match": "users/@me/channels", "stdout": "discord:@me:D0FAKE" }
]
}
]
}
+4 -6
View File
@@ -54,10 +54,8 @@ Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, o
Reload your config or restart Emacs.
## 5. Remove the messaging group (optional)
## 5. Messaging group (left intact)
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';"
```
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>`.
+5 -6
View File
@@ -12,14 +12,13 @@ ncl groups config remove-mcp-server --id <group-id> --name calendar
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
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`):
This is a **host-only / operator** verb — run it host-side. It's idempotent (a no-op if the mount is absent):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '<group-id>';"
ncl groups config remove-mount \
--id <group-id> \
--host "$HOME/.calendar-mcp" \
--container .calendar-mcp
```
## 3. Delete the copied test file
+10 -13
View File
@@ -133,6 +133,8 @@ 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
@@ -160,27 +162,22 @@ Approval behaviour depends on where you run it: from inside an agent's container
### Add the `.calendar-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts` `setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
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).
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config add-mount \
--id <group-id> \
--host "$HOME/.calendar-mcp" \
--container .calendar-mcp
```
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 an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
`--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.
**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>`).
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.
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
**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.
## Phase 4: Build and Restart
+70 -42
View File
@@ -5,63 +5,70 @@ description: Add Google Chat channel integration via Chat SDK.
# Add Google Chat Channel
Adds Google Chat support via the Chat SDK bridge.
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat 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 Google Chat adapter and its
registration test into `src/channels/` (overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/gchat.ts
src/channels/gchat-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './gchat.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/gchat@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/gchat@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
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.
`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.
## 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**
@@ -73,21 +80,32 @@ End-to-end message delivery against a real Google Chat space is verified manuall
> - Grant the Chat Bot role
> - Create a JSON key and download it
### Configure environment
### Store the credentials
Add the service account JSON as a single-line string to `.env`:
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:
```bash
GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."}
```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":"..."}`.
```
```nc:env-set
GCHAT_CREDENTIALS={{gchat_credentials}}
```
### Webhook server
Sync to container: `mkdir -p data/env && cp .env data/env/env`
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.
## 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
@@ -97,3 +115,13 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **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.
## Troubleshooting
**The adapter starts, then errors about credentials.** `GCHAT_CREDENTIALS` must be the *entire* service account JSON collapsed to one line — inspect `.env` and confirm it still contains `"type":"service_account"`, `"private_key"`, and `"client_email"`. A truncated paste (shells often mangle the multi-line private key) is the usual cause; download a fresh JSON key under **IAM & Admin → Service Accounts → Keys** and re-paste it as a single line.
**Messages sent in the space never reach the agent.** Google Chat delivers only to the HTTP endpoint URL set under **Google Chat API → Configuration**, and that URL must be publicly reachable at `/webhook/gchat` (shared webhook server, port 3000). Tunnel hostnames (ngrok free tier) change on restart — make sure the Configuration URL matches the tunnel that's actually up.
**The app doesn't appear when adding it to a space.** Check the Chat API Configuration page: the app status must be live and its visibility must include your domain or user, and you must be adding it from the same Google Workspace the Cloud project belongs to.
**Everything configured but still silent.** Run `pnpm exec vitest run src/channels/gchat-registration.test.ts` — red means the barrel import or the `@chat-adapter/gchat` install drifted, so re-run the Apply steps. If green, restart the service so it picks up the adapter and `.env`, then watch `logs/nanoclaw.log` for the inbound webhook hit.
@@ -0,0 +1,11 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials.",
"scenarios": [
{
"name": "service-account",
"inputs": {
"gchat_credentials": "fake-service-account-json-blob"
}
}
]
}
+66 -42
View File
@@ -5,64 +5,68 @@ 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.
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.
## Prerequisites
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
## Install
## Apply
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
### 1. Copy the adapter
### Pre-flight (idempotent)
Fetch the `channels` branch and copy the GitHub adapter into `src/channels/`
(overwrite — the branch is canonical):
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
```nc:copy from-branch:channels
src/channels/github.ts
src/channels/github-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './github.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/github@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/github@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/github-registration.test.ts
```
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`.
`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.
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.
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.
## Credentials
@@ -88,18 +92,28 @@ On each repo (logged in as the repo owner/admin):
### 3. Configure environment
Add to `.env`:
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):
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```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}}
```
`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?**
@@ -160,3 +174,13 @@ systemctl --user restart $(systemd_unit) # Linux
- **supports-threads**: yes (PR and issue comment threads are native conversations)
- **typical-use**: Webhook-driven — the agent receives PR and issue comment events and responds in comment threads when @-mentioned. After the first mention, the thread is subscribed and the agent responds to all follow-up comments.
- **default-isolation**: Use `per-thread` session mode. Each PR or issue gets its own isolated agent session. Typically wire to a dedicated agent group if the repo contains sensitive code.
## Troubleshooting
**API calls return 401/403 with the token.** The token must be a **fine-grained** PAT starting `github_pat_`, created while logged in as the *bot* account (Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens), with the monitored repos selected under Repository access and both **Pull requests** and **Issues** set to Read & Write. A classic `ghp_` token, or one minted on your personal account, is the usual miss.
**Webhook deliveries show red in the repo settings.** Open **Settings → Webhooks → Recent Deliveries** on the repo: a 401 response means the secret in the webhook form doesn't match `GITHUB_WEBHOOK_SECRET`; a timeout means `https://your-domain/webhook/github` isn't publicly reachable on the shared webhook port (3000). Fix, then use **Redeliver** to retest without writing a new comment.
**Comments never trigger the agent.** The @-mention must match `GITHUB_BOT_USERNAME` exactly, and the webhook must subscribe to **Issue comments** and **Pull request review comments** (not just pushes). Comments authored by the bot account itself are filtered by design — test from a different account than the bot.
**Adapter installed but the channel is dead.** Run `pnpm exec vitest run src/channels/github-registration.test.ts` — red means the barrel import or the `@chat-adapter/github` install drifted, so re-run the Apply steps. If green, restart the service (see Next Steps) so it loads the adapter and the new `.env` values.
@@ -0,0 +1,13 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials.",
"scenarios": [
{
"name": "pat",
"inputs": {
"github_token": "ghp_fake0123456789fake0123456789fake01",
"webhook_secret": "fake-webhook-secret",
"bot_username": "nanoclaw-bot"
}
}
]
}
+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)
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):
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:
```bash
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 = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config remove-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
```
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`.
+6 -11
View File
@@ -181,21 +181,16 @@ Approval behaviour depends on where you run it: from inside an agent's container
### Add the `.gmail-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
Register the mount with the host-only `ncl groups config add-mount` verb. For each chosen `<group-id>`:
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '$GROUP_ID';"
ncl groups config add-mount \
--id <group-id> \
--host "$HOME/.gmail-mcp" \
--container .gmail-mcp
```
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 an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`--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.
**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,10 +8,9 @@
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* 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__*`.
* 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.
*/
import fs from 'fs';
import path from 'path';
@@ -25,11 +24,6 @@ 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();
@@ -48,8 +42,4 @@ 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__*');
});
});
+154 -56
View File
@@ -5,110 +5,196 @@ 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).
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the iMessage adapter into `src/channels/`
(overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/imessage.ts
src/channels/imessage-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './imessage.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install chat-adapter-imessage@0.1.1
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
chat-adapter-imessage@0.1.1
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
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`.
`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.
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.js binary.
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.
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:
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:
```bash
open "$(dirname "$(which node)")"
```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
```
Then tell the user:
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
```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.
```
Stop and wait for the user to confirm before continuing.
Stop and wait for the user to confirm Full Disk Access is granted before
continuing.
### Remote Mode (Photon API)
1. Set up a [Photon](https://photon.codes) account
2. Get your server URL and API key
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.
```
### Configure environment
**Local mode** -- add to `.env`:
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):
```bash
IMESSAGE_ENABLED=true
IMESSAGE_LOCAL=true
**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
```
**Remote mode** -- add to `.env`:
**Remote mode** — writes `IMESSAGE_LOCAL=false`, `IMESSAGE_SERVER_URL`, and
`IMESSAGE_API_KEY`, and removes `IMESSAGE_ENABLED` if present:
```bash
IMESSAGE_LOCAL=false
IMESSAGE_SERVER_URL=https://your-photon-server.com
IMESSAGE_API_KEY=your-api-key
```nc:run effect:external when:mode=remote
bash setup/channels/imessage-configure.sh remote "{{server_url}}" "{{api_key}}"
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## 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.
## 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 wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
@@ -118,3 +204,15 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: no
- **typical-use**: Interactive 1:1 chat — personal messaging
- **default-isolation**: Same agent group if you're the only person messaging the bot across iMessage and other channels. Separate agent group if different contacts should have information isolation.
## Troubleshooting
**The mode answer is rejected.** It must be exactly `local` or `remote`, lowercase. Local only exists on macOS — it reads this Mac's `chat.db` directly — so on any other OS the platform check stops you and remote (Photon) is the only path.
**Local mode: outgoing works but nothing ever arrives.** Full Disk Access wasn't granted to the *actual* Node binary the service runs under — with nvm the path changes per Node version (`~/.nvm/versions/node/v22.x.x/bin/node`), so an old grant silently stops covering a new binary. Re-open System Settings → Privacy & Security → Full Disk Access, add the binary at `$(which node)`, then restart the service.
**Remote mode: Photon values rejected or unreachable.** The server URL must start with `http://` or `https://` — copy it and the API key from your Photon dashboard at photon.codes. If the adapter starts but sends fail, curl the server URL from this machine to rule out a network path issue.
**Your handle is rejected at the resolve step.** It must be a bare +E.164 number (`+14155551234` — no spaces, dashes, or parentheses) or an email/Apple ID. Use the exact handle you actually send iMessages from — a number-vs-email mismatch means your messages never map to the wired conversation.
**Adapter installed but silent.** Run `pnpm exec vitest run src/channels/imessage-registration.test.ts` — red means the barrel import or the `chat-adapter-imessage` install drifted, so re-run the Apply steps. If green, restart the service (`bash setup/lib/restart.sh`) so it loads the adapter and the mode config, then check `logs/nanoclaw.error.log`.
@@ -0,0 +1,27 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. Two scenarios cover both when:mode= legs; the echo stub answers the platform_id capture (the skill echoes the owner handle back).",
"scenarios": [
{
"name": "local",
"inputs": {
"mode": "local",
"owner_handle": "+15551234567"
},
"exec": [
{ "match": "echo \"+15551234567\"", "stdout": "+15551234567" }
]
},
{
"name": "remote",
"inputs": {
"mode": "remote",
"server_url": "https://imsg.example.com",
"api_key": "fake-photon-api-key",
"owner_handle": "owner@example.com"
},
"exec": [
{ "match": "echo \"owner@example.com\"", "stdout": "owner@example.com" }
]
}
]
}
+1 -10
View File
@@ -18,16 +18,7 @@ rm -f src/channels/linear.ts src/channels/linear-registration.test.ts
## 2. Remove credentials
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
```
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:
```bash
mkdir -p data/env && cp .env data/env/env
+120 -66
View File
@@ -5,7 +5,15 @@ 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.
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.
## Prerequisites
@@ -20,61 +28,66 @@ Adds Linear support via the Chat SDK bridge. The agent participates in issue com
**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).
## Install
## Apply
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.
Linear OAuth apps post and read comments under an app identity that can't be
@-mentioned; the adapter's declared channel defaults therefore respond to plain
comments rather than mention-only, and the wiring below sets that same pattern
mode explicitly.
### 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 Linear adapter and its registration
test into `src/channels/` (overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/linear.ts
src/channels/linear-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './linear.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/linear@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/linear@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
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
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` 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.
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.
## 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**
@@ -86,49 +99,78 @@ End-to-end message delivery against a real Linear workspace is verified manually
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Configure environment
### 2. Store the credentials
Add to `.env`:
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):
```bash
# OAuth app (recommended)
LINEAR_CLIENT_ID=your-client-id
LINEAR_CLIENT_SECRET=your-client-secret
# OR Personal API key (simpler, but agent posts as you)
# LINEAR_API_KEY=lin_api_...
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
LINEAR_BOT_USERNAME=NanoClaw Bot
LINEAR_TEAM_KEY=ENG
LINEAR_API_KEY=lin_api_...
```
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public Linear workspace?**
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). The host
service must be running — `ncl` connects to it over a Unix socket.
- **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).
The sender policy depends on the workspace: a private workspace can use `public`
(only workspace members can comment anyway); a public workspace should use
`strict` so only registered members may talk to the agent.
Run `/manage-channels` to wire the Linear channel to an agent group, or create the rows directly with `ncl`. **The host service must be running**`ncl` connects to it over a Unix socket:
```bash
# Create messaging group (one per team)
ncl messaging-groups create --channel-type linear --platform-id "linear:ENG" \
--name "Engineering" --is-group 1 --unknown-sender-policy <policy>
# Wire to agent group (engage mode/pattern default to the Linear adapter's
# declared channel defaults; grab the mg id from the create output above)
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
--session-mode per-thread
```nc:prompt agent_folder
Which agent should answer Linear comments? Enter its folder (run `ncl groups list`).
```
```nc:prompt linear_sender_policy normalize:lower validate:^(public|strict)$
Is this a private or public Linear workspace? Enter `public` for a private workspace (only members can comment) or `strict` for a public workspace (only registered members may talk to the agent).
```
```nc:run effect:wire
ncl messaging-groups create --channel-type linear --platform-id linear:{{linear_team_key}} --is-group 1 --unknown-sender-policy {{linear_sender_policy}} --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
```
Replace `<policy>` with `public` or `strict` based on the user's choice above. 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.
The explicit `pattern` engage mode with pattern `.` matches the Linear adapter's
declared channel defaults — Linear can't be @-mentioned, so the agent answers
every comment. 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. If you chose `strict`, register the people who may talk
to the agent (see the GitHub skill for adding members).
## Next Steps
@@ -152,3 +194,15 @@ systemctl --user restart $(systemd_unit) # Linux
- **supports-threads**: yes (issue comment threads are native conversations)
- **typical-use**: Webhook-driven — the agent receives all issue comment events and responds automatically. No @-mention needed (Linear OAuth apps can't be @-mentioned).
- **default-isolation**: Use `per-thread` session mode. Each issue comment thread gets its own isolated agent session.
## Troubleshooting
**Comments never reach the agent.** New Linear webhooks can lag 15 minutes, so wait before digging. Then check the webhook in Linear Settings → API → Webhooks: the URL must be your public host at `/webhook/linear` (shared webhook server, port 3000), the right team selected, and the **Comment** event checked. A mismatch between the webhook's signing secret and `LINEAR_WEBHOOK_SECRET` makes deliveries fail signature verification silently — re-copy the secret from the webhook page.
**OAuth credentials rejected.** The Client ID and Secret come from Linear Settings → API → OAuth Applications, and the app must have **Client credentials** enabled under grant types after creation — without that toggle the token exchange 401s. If you meant to use a Personal API key instead, answer `none` at both OAuth prompts and set `LINEAR_API_KEY` in `.env` by hand.
**The agent ignores your own comments.** That's Personal-API-key mode working as designed: comments from the key's account are filtered as self-messages so the bot doesn't answer itself. Other members' comments still trigger it; if it must answer you too, switch to the OAuth app identity.
**Sender-policy answer rejected, or issues route nowhere.** The policy must be exactly `public` or `strict` (lowercase), and `LINEAR_TEAM_KEY` must be the short team key (e.g. `ENG`) from Settings → Teams — all issues in that one team route to the messaging group.
**Wired but dead.** Run `pnpm exec vitest run src/channels/linear-registration.test.ts` — red means the barrel import or the `@chat-adapter/linear` install drifted, so re-run the Apply steps. If green, restart the service (see Next Steps) so the adapter and `.env` values are live.
@@ -0,0 +1,17 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. The effect:wire ncl runs carry no capture, so the exec stub's no-op answer is fine.",
"scenarios": [
{
"name": "workspace",
"inputs": {
"linear_client_id": "fake-client-id",
"linear_client_secret": "fake-client-secret",
"linear_webhook_secret": "fake-webhook-secret",
"linear_team_key": "ENG",
"linear_bot_username": "nanoclaw",
"agent_folder": "main",
"linear_sender_policy": "public"
}
}
]
}
+1 -16
View File
@@ -18,22 +18,7 @@ rm -f src/channels/matrix.ts src/channels/matrix-registration.test.ts
## 2. Remove credentials
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:
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:
```bash
mkdir -p data/env && cp .env data/env/env
+95 -42
View File
@@ -5,57 +5,53 @@ description: Add Matrix channel integration via Chat SDK. Works with any Matrix
# Add Matrix Channel
Adds Matrix support via the Chat SDK bridge.
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Matrix adapter into `src/channels/`
(overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/matrix.ts
src/channels/matrix-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './matrix.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @beeper/chat-adapter-matrix@0.2.0
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
```
### 5. Patch matrix-js-sdk ESM imports
### 4. 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):
extensions (idempotent — safe to re-run). Re-run this after every `pnpm install`
that touches the adapter:
```bash
```nc:run effect:external
node -e '
const fs = require("fs"), path = require("path");
const root = "node_modules/.pnpm";
@@ -69,22 +65,32 @@ node -e '
'
```
Re-run this after every `pnpm install` that touches the adapter.
### 5. Build
### 6. Build and validate
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.
```bash
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/matrix-registration.test.ts
```
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`.
`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.
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.
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.
### Create a bot account
@@ -131,12 +137,47 @@ MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
```
### Configure environment
### Store the credentials
Add the chosen env vars to `.env`, then sync:
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:
```bash
mkdir -p data/env && cp .env data/env/env
```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}}
```
## Next Steps
@@ -153,3 +194,15 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability)
- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account).
- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts.
## Troubleshooting
**Build fails with `ERR_MODULE_NOT_FOUND` for `matrix-js-sdk/lib/...`.** The ESM extension patch (step 4) hasn't been applied — or a later `pnpm install` reinstalled the adapter and wiped it. Re-run the patch, then `pnpm run build`; the patch is idempotent, so re-running is always safe.
**Login fails with `M_FORBIDDEN`.** The username/user-ID split is the usual trip: `MATRIX_USERNAME` is the bare localpart (`andybot`), while `MATRIX_USER_ID` is the full ID (`@andybot:matrix.org`) — swapping them fails auth. With Option B, an access token dies the moment that Element session signs out; grab a fresh one from Settings → Help & About → Access Token, or via the login API.
**The bot never joins your room.** Auto-join is on by default (`MATRIX_INVITE_AUTOJOIN=true`), but an allowlist (`MATRIX_INVITE_AUTOJOIN_ALLOWLIST`) that doesn't include your user ID makes it ignore your invites. Invite the bot from your own account and watch the service log for the join.
**Messages to yourself never arrive.** Matrix cannot DM your own account — the bot must be its own account, separate from yours. If you configured the adapter with your personal credentials, register a dedicated bot account and redo the credential steps.
**Registered but silent.** Run `pnpm exec vitest run src/channels/matrix-registration.test.ts` — red means the barrel import or the `@beeper/chat-adapter-matrix` install drifted, so re-run the Apply steps. If green, restart the service (see Next Steps) and check `logs/nanoclaw.error.log` for login errors.
@@ -0,0 +1,16 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. All prompts in this skill are unguarded (the login-method legs are prose alternatives, not when:-branches), so a fully-programmatic apply must answer every one — username/password AND access_token.",
"scenarios": [
{
"name": "homeserver",
"inputs": {
"base_url": "https://matrix.example.com",
"user_id": "@nanoclaw:example.com",
"bot_username": "nanoclaw",
"username": "nanoclaw",
"password": "fake-password",
"access_token": "syt_fake_access_token"
}
}
]
}
+107 -46
View File
@@ -5,61 +5,70 @@ description: Add Resend (email) channel integration via Chat SDK.
# Add Resend Email Channel
Connect NanoClaw to email via Resend for async email conversations.
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Resend adapter into `src/channels/`
(overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/resend.ts
src/channels/resend-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './resend.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @resend/chat-sdk-adapter@0.1.1
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@resend/chat-sdk-adapter@0.1.1
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/resend-registration.test.ts
```
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`.
`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.
## 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.
@@ -69,30 +78,82 @@ Both must be clean before proceeding. `resend-registration.test.ts` is the one i
- Events: select **email.received**.
- Copy the signing secret.
### Configure environment
### Store the credentials
Add to `.env`:
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):
```bash
RESEND_API_KEY=re_...
RESEND_FROM_ADDRESS=bot@yourdomain.com
RESEND_FROM_NAME=NanoClaw
RESEND_WEBHOOK_SECRET=your-webhook-secret
```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`).
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
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>`).
## 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. (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.)
## Channel Info
- **type**: `resend`
- **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)
- **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)
- **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.
## Troubleshooting
**Sends fail with 401.** The API key comes from the Resend dashboard's **API Keys** page and starts with `re_`. Keys are shown once at creation — if in doubt, create a new one and update `RESEND_API_KEY` in `.env`.
**The hello email never lands, or hits spam.** The sending domain must show as verified under Resend → **Domains** — until the SPF/DKIM DNS records propagate, sends are rejected or spam-foldered. `RESEND_FROM_ADDRESS` must be an address on that verified domain; a free-mail from-address will not work.
**Replies never reach the agent.** Inbound only flows via the webhook: Resend → **Webhooks** must point at your public host at `/webhook/resend` (shared webhook server, port 3000) with the **email.received** event selected, and `RESEND_WEBHOOK_SECRET` must match that webhook's signing secret. Resend's webhook page lists delivery attempts — a run of failures means the URL is unreachable or the secret mismatches.
**Adapter installed but nothing flows.** Run `pnpm exec vitest run src/channels/resend-registration.test.ts` — red means the barrel import or the `@resend/chat-sdk-adapter` install drifted, so re-run the Apply steps. If green, restart the service so it loads the adapter and `.env`, then re-send the hello.
@@ -0,0 +1,16 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. The effect:wire ncl runs carry no capture, so the exec stub's no-op answer is fine.",
"scenarios": [
{
"name": "email",
"inputs": {
"api_key": "re_fake_api_key",
"webhook_secret": "whsec_fake",
"from_address": "assistant@mail.example.com",
"from_name": "NanoClaw",
"owner_email": "owner@example.com",
"agent_folder": "main"
}
}
]
}
+5 -11
View File
@@ -4,21 +4,15 @@ Idempotent — safe to run even if some steps were never applied. Run Steps 1
## 1. Remove the mount from the container config
Read the current mounts, drop the entry whose `containerPath` is `/usr/local/bin/rtk`, and write the rest back.
Remove the rtk mount with the host-only `remove-mount` verb. It is idempotent — a no-op if the mount isn't present:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
ncl groups config remove-mount --id <group-id> \
--host ~/.local/bin/rtk \
--container /usr/local/bin/rtk
```
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.
This verb is operator-only and runs host-side; it is rejected from inside a container.
## 2. Remove the PreToolUse hook from settings.json
+15 -21
View File
@@ -13,6 +13,10 @@ 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
@@ -43,33 +47,24 @@ Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 35 for each
## Step 3 — Mount rtk into the container config
`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:
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:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
ncl groups config add-mount --id <group-id> \
--host ~/.local/bin/rtk \
--container /usr/local/bin/rtk \
--ro
```
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:
This verb is operator-only and runs host-side (via `/setup`, `/customize`, or `/manage-mounts`); it is rejected from inside a container.
```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>'"
```
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.
Verify:
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
ncl groups config get --id <group-id>
# Look for the /usr/local/bin/rtk mount
```
## Step 4 — Add the PreToolUse hook to settings.json
@@ -120,9 +115,8 @@ 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
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 config get --id <group-id>
# Look for the /usr/local/bin/rtk mount
ncl groups restart --id <group-id>
```
+189 -179
View File
@@ -1,53 +1,203 @@
---
name: add-signal
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
description: Add Signal channel integration via signal-cli device-link. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
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.
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.
## Apply
## Prerequisites
### 1. Install signal-cli
### Java
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:
signal-cli requires Java 17+:
```bash
java -version
```nc:run effect:external
command -v signal-cli >/dev/null 2>&1 || bash setup/install-signal-cli.sh
```
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`
### 2. Copy the adapter and its registration test
Java 1725 all work.
Fetch the `channels` branch and copy the Signal adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
### 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
```nc:copy from-branch:channels
src/channels/signal.ts
src/channels/signal-registration.test.ts
```
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
### 3. Register the adapter
## Registration
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:
Two paths. The new-number path is recommended and battle-tested.
```nc:append to:src/channels/index.ts
import './signal.js';
```
### Path A: Register a new number (recommended)
### 4. Install the QR-rendering dependency
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
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
```
## 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. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket):
```bash
# Engage mode/pattern default to the Signal adapter's declared channel defaults
ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID
```
### 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` (host service running):
```bash
ncl users create --id "signal:UUID" --kind signal --display-name "<name>"
ncl roles grant --user "signal:UUID" --role owner
ncl members add --user "signal:UUID" --group ag-AGENTID
```
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 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 get their own agent group (the default `shared` session mode already gives each messaging group its own session).
### 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.
> **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.
@@ -107,69 +257,12 @@ signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start $(systemd_unit)
```
### Path B: Link as secondary device
Once registered, set `SIGNAL_ACCOUNT` to this number (as under **Persist the account** above) and restart the service.
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
## Optional configuration
```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
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
# TCP daemon host and port (default: 127.0.0.1:7583)
@@ -189,85 +282,6 @@ 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. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl`**the host service must be running** (`ncl` connects to it over a Unix socket):
```bash
# Engage mode/pattern default to the Signal adapter's declared channel defaults
ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID
```
### 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` (host service running):
```bash
ncl users create --id "signal:UUID" --kind signal --display-name "<name>"
ncl roles grant --user "signal:UUID" --role owner
ncl members add --user "signal:UUID" --group ag-AGENTID
```
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 get their own agent group (the default `shared` session mode already gives each messaging group its own session)
### 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
@@ -278,9 +292,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 +1YOURNUMBER listIdentities` should succeed without prompting
- Confirm the account is linked: `signal-cli -a +YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
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`.
### Bot not responding
@@ -299,7 +313,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. 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.
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, grant access as shown under **Grant user access** in the Wiring section (or via `/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.
### Captcha required
@@ -317,10 +331,6 @@ 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`.
### Java not found
### QR / linking URL expired
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.
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.
@@ -0,0 +1,10 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — no prompts in this skill; the fixture exists to feed the effect:step device-link's terminal-block fields (capture:platform_id=ACCOUNT,owner_handle=ACCOUNT).",
"scenarios": [
{
"name": "device-link",
"inputs": {},
"stepFields": { "ACCOUNT": "+15550001111" }
}
]
}
+5 -7
View File
@@ -10,19 +10,17 @@ Delete the self-registration import from `src/channels/index.ts` (skip if alread
import './slack.js';
```
Then delete the copied adapter and its registration test:
Then delete the copied adapter, its registration test, and the `slack-formatting`
container skill (part of the channel payload — trunk doesn't ship it):
```bash
rm -f src/channels/slack.ts src/channels/slack-registration.test.ts
rm -f src/channels/slack.ts src/channels/slack-registration.test.ts container/skills/slack-formatting/SKILL.md
```
## 2. Remove credentials
Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
Remove `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_SIGNING_SECRET` from
`.env` (each is present only if its delivery mode was configured).
## 3. Remove the package
+155 -79
View File
@@ -5,37 +5,26 @@ description: Add Slack channel integration via Chat SDK.
# Add Slack Channel
Adds Slack support via the Chat SDK bridge.
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter, registration test, and formatting skill
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the Slack adapter, its registration test,
and the formatting container skill into place (overwrite — the branch is
canonical):
- `src/channels/slack.ts` exists
- `src/channels/slack-registration.test.ts` exists
- `src/channels/index.ts` contains `import './slack.js';`
- `container/skills/slack-formatting/SKILL.md` exists
- `@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. Copy the adapter and its registration test
```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
mkdir -p container/skills/slack-formatting
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
```nc:copy from-branch:channels
src/channels/slack.ts
src/channels/slack-registration.test.ts
container/skills/slack-formatting/SKILL.md
```
The `slack-formatting` container skill is part of the channel payload: it
@@ -43,84 +32,161 @@ reaches agents via `~/.claude/skills` (synced at spawn) and teaches Slack's
mrkdwn syntax. Trunk does not ship it — without this copy step agents send
Slack messages with generic markdown that renders literally.
### 3. Append the self-registration import
### 2. Register the adapter
Append to `src/channels/index.ts` (skip if the line is already present):
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:
```typescript
```nc:append to:src/channels/index.ts
import './slack.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/slack@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/slack@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/slack-registration.test.ts
```
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.
`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.
## Credentials
### Create Slack App
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.
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
```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).
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
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:
### Webhook server
```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-).
```
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.
For webhook delivery, tell the user:
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`.
```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
```
## 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 wire
this channel with `/init-first-agent` (or `/manage-channels`).
## Channel Info
@@ -131,3 +197,13 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **supports-threads**: yes
- **typical-use**: Interactive chat — team channels or direct messages
- **default-isolation**: Same agent group for channels where you're the primary user. Separate agent group for channels with different teams or sensitive contexts.
## Troubleshooting
**A token paste is rejected.** Each secret has a fixed shape: the Bot User OAuth Token starts `xoxb-` (OAuth & Permissions, after Install to Workspace), the App-Level Token starts `xapp-` (Basic Information → App-Level Tokens), and the Signing Secret is a hex string (Basic Information). The classic mix-up is pasting a user token (`xoxp-`) instead of the bot token, or the app's Client Secret instead of the Signing Secret.
**`auth.test` fails, or `conversations.open` returns no channel.** A failing `auth.test` means the bot token is wrong or the app was never installed to the workspace. `conversations.open` coming back empty means the `im:write` scope is missing — add it under OAuth & Permissions and **reinstall the app**; scope changes only take effect after reinstall, which also mints a new `xoxb-` token to store.
**The greeting arrives but your replies vanish.** Sending works with just the bot token; *receiving* needs the event path. Socket Mode: the toggle on, `SLACK_APP_TOKEN` set with `connections:write`, and the bot events (`message.im`, `message.channels`, `message.groups`, `app_mention`) subscribed. Webhook: the Request URL must have passed Slack's challenge and the same events subscribed. Either way, App Home's Messages Tab must be enabled or Slack refuses DMs to the app.
**Adapter registered but Slack never connects.** Run `pnpm exec vitest run src/channels/slack-registration.test.ts` — red means the barrel import or the `@chat-adapter/slack` install drifted, so re-run the Apply steps. If green, restart the service (`bash setup/lib/restart.sh`) so it picks up the adapter and tokens, then check `logs/nanoclaw.error.log`.
@@ -0,0 +1,31 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. Two scenarios cover both when:connection= legs; the auth.test stub answers the connected_as capture and conversations.open answers platform_id.",
"scenarios": [
{
"name": "socket",
"inputs": {
"connection": "socket",
"bot_token": "xoxb-fake-bot-token",
"app_token": "xapp-fake-app-token",
"owner_handle": "U12345678"
},
"exec": [
{ "match": "auth.test", "stdout": "@nano in Acme" },
{ "match": "conversations.open", "stdout": "slack:D0FAKE" }
]
},
{
"name": "webhook",
"inputs": {
"connection": "webhook",
"bot_token": "xoxb-fake-bot-token",
"signing_secret": "0123456789abcdef",
"owner_handle": "U12345678"
},
"exec": [
{ "match": "auth.test", "stdout": "@nano in Acme" },
{ "match": "conversations.open", "stdout": "slack:D0FAKE" }
]
}
]
}
+27 -15
View File
@@ -18,26 +18,38 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
## 2. Remove credentials
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
```
## 3. Remove the package
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`.
## 3. Sign out the Teams CLI, then remove the packages
`teams login` caches a Microsoft 365 session on disk that outlives the package —
sign out first (skip if the CLI was never installed):
```bash
teams logout
npm uninstall -g @microsoft/teams.cli
pnpm uninstall @chat-adapter/teams
```
## 4. Rebuild and restart
## 4. Remove local artifacts
```bash
rm -rf data/teams
```
## 5. Clean up cloud resources
Uninstall the app from Teams (Apps > Manage your apps). Then, on **both**
paths, delete the Entra app registration in Azure Portal > App registrations —
that is the step that actually revokes the client secret. Additionally:
- **Teams CLI path**: delete the app listing in the Teams Developer Portal
(https://dev.teams.microsoft.com/apps) — removing it there alone does NOT
revoke the secret.
- **Manual Azure path**: delete the Azure Bot resource, and the `nanoclaw-rg`
resource group if you created one (`az group delete --name nanoclaw-rg`).
## 6. Rebuild and restart
```bash
pnpm run build
+518 -189
View File
@@ -5,251 +5,580 @@ description: Add Microsoft Teams channel integration via Chat SDK.
# Add Microsoft Teams Channel
Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages.
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch.
Teams has no "paste a token" shortcut — a bot has to exist in Microsoft's cloud
before it can receive a message. The Microsoft Teams CLI collapses that into
one sign-in and one create command: it registers the Entra app, generates the
client secret, registers a Teams-managed bot (through the Teams Developer
Portal — **no Azure subscription needed**), uploads the app package, and hands
back an install link. The old ~7-step Azure portal walk survives only as a
fallback in [Alternatives](#alternatives) for tenants where the Developer
Portal is blocked.
### Pre-flight (idempotent)
## Apply
Skip to **Credentials** if all of these are already in place:
### 1. Copy the adapter and its registration test
- `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
Fetch the `channels` branch and copy the Teams adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```nc:copy from-branch:channels
src/channels/teams.ts
src/channels/teams-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './teams.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/teams@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/teams@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/teams-registration.test.ts
```
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.
`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.
## Credentials
Two paths — manual (Azure Portal) or auto (Teams CLI).
The adapter is installed and registered, but it can't receive a message until a
bot exists, points at this machine, and is installed into Teams. The Teams CLI
does all of that below.
### Auto: Teams CLI
### Check for existing credentials
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
Re-running `teams app create` provisions a brand-new app registration and bot
each time — it never reuses the first one. So the flow starts with a probe:
when `.env` already carries a Teams credential — either key; a partial pair
means a half-finished setup that creating ANOTHER app would only corrupt —
every step below (prompts included) is skipped and the flow drops straight
through to [Restart](#restart). To rotate credentials or finish a partial
configuration, see [Troubleshooting](#troubleshooting); if your tunnel URL
changed, the fix is `teams app update`, not a re-run (also in Troubleshooting).
1. Install the CLI:
```bash
npm install -g @microsoft/teams.cli@preview
```
2. Sign in and verify:
```bash
teams login
teams status
```
3. Create the Entra app, client secret, and bot registration:
```bash
teams app create \
--name "NanoClaw" \
--endpoint "https://your-domain/api/webhooks/teams"
```
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
- `CLIENT_ID` → `TEAMS_APP_ID`
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
Continue to [Configure environment](#configure-environment).
---
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
az bot create \
--resource-group nanoclaw-rg \
--name nanoclaw-bot \
--app-type SingleTenant \
--appid YOUR_APP_ID \
--tenant-id YOUR_TENANT_ID \
--endpoint "https://your-domain/api/webhooks/teams"
```nc:run capture:have_creds
( grep -q '^TEAMS_APP_ID=.' .env 2>/dev/null || grep -q '^TEAMS_APP_PASSWORD=.' .env 2>/dev/null ) && echo yes || echo no
```
### Step 4: Configure Messaging Endpoint
Before creating anything, tell the user:
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
```nc:operator when:have_creds=no
Confirm you have everything Teams setup needs:
1. A Microsoft 365 account that can create Entra app registrations and upload custom apps (sideloading) — free personal Teams does NOT qualify; you need a Microsoft 365 Business / EDU / developer tenant.
2. A way to expose an HTTPS endpoint that forwards to this machine's webhook port 3000 (e.g. a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `cloudflared tunnel --url http://localhost:3000` — the create step needs the URL up front. The next prompt asks for its public base URL: just the https:// origin, no trailing path.
Note: the bot is created single-tenant (only your own Microsoft 365 tenant can install it) — the right default for a self-hosted assistant. If you need a bot other tenants can install, set it up manually via the Alternatives section of this skill instead.
```
### Step 6: Create and Sideload Teams App
### Public URL
Create a `manifest.json`:
Microsoft delivers bot messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000, configurable via
`WEBHOOK_PORT`) at `/webhook/teams`.
```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"]
}
```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash
Paste your tunnel's public https:// URL — e.g. https://your-tunnel.trycloudflare.com
```
Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together.
### App name
**Sideload in Teams:**
1. Open Teams > **Apps** > **Manage your apps**
2. Click **Upload an app** > **Upload a custom app**
3. Select the zip file
One more choice belongs to the human before anything is created. The name is
used everywhere at once: the Entra app registration, the bot, and the Teams
app are all created under it. There is no client-secret name to pick on this
path — the CLI generates the secret itself (Entra displayName `default`,
2-year expiry); rotating it later is in [Troubleshooting](#troubleshooting).
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
{
"authorization": {
"permissions": {
"resourceSpecific": [
{ "name": "ChannelMessage.Read.Group", "type": "Application" }
]
}
}
}
```nc:prompt app_name when:have_creds=no validate:^[\sA-Za-z0-9._-]{1,30}$ normalize:trim
What should the bot be called? One name covers the Entra app registration, the bot, and the Teams app (letters, digits, spaces, . _ -; max 30 characters) — e.g. NanoClaw.
```
### Configure environment
### Install the Teams CLI
Add to `.env`:
Installed globally with npm — not as a workspace dependency — deliberately:
the CLI's credential store (keytar) is a native module whose install script
must run to fetch its prebuilt binary, and pnpm's supply-chain policy blocks
dependency build scripts — a workspace install leaves the sign-in unable to
persist. The global install matches Microsoft's own instruction and keeps the
workspace policy intact. Pinned; re-running is a no-op. (If npm reports
EACCES here, your global prefix needs root — prefer a user-level Node like
nvm, or `npm config set prefix ~/.npm-global`.) `--loglevel=error` because
npm runs inside a pnpm script here and warns about every pnpm config var it
inherits — pure noise; real errors still print.
```bash
TEAMS_APP_ID=your-app-id
TEAMS_APP_PASSWORD=your-client-secret
# For Single Tenant only:
TEAMS_APP_TENANT_ID=your-tenant-id
```nc:run effect:external when:have_creds=no
npm install -g @microsoft/teams.cli@3.0.2 --loglevel=error
```
npm's global bin directory is not reliably on PATH (custom prefixes rarely
are), so every step below calls the CLI by its absolute path,
`$(npm prefix -g)/bin/teams` (stderr of the prefix lookup silenced — same
pnpm-config noise as above). Where this document says to run `teams …` by
hand, use that path too if plain `teams` isn't found.
### Sign in to Microsoft 365
Every `teams` command is a separate process, so the sign-in must survive into
the next one via the CLI's on-disk token cache. A "libsecret not found —
token cache will be stored unencrypted" warning here is safe to ignore: the
CLI falls back to a plaintext cache file that persists fine, and setup signs
the session out at the end anyway. The login output may
also report "Azure CLI: not installed" — informational only; this flow
creates a Teams-managed bot precisely so the Azure CLI is never needed (it
only matters for `--azure` bots and the manual portal path). The
step below verifies persistence by re-reading the session from a fresh
process after login. In an interactive terminal the login opens a browser;
on a headless box (SSH) it prints a device code — open
microsoft.com/devicelogin on any machine and enter it. If this step fails,
run `teams login` then `teams status` by hand: status must say logged in, or
the cache is not persisting (see Troubleshooting).
```nc:run effect:step when:have_creds=no
"$(npm prefix -g 2>/dev/null)/bin/teams" login && "$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null | grep -q '"loggedIn": true' && printf '=== NANOCLAW SETUP: TEAMS-LOGIN ===\nSTATUS: success\n=== END ===\n'
```
### Create the bot
One command registers the Entra app, generates a client secret (Graph can take
~30s to see the new app — the CLI retries), registers a Teams-managed bot, and
uploads the app package to the Teams Developer Portal. It needs the sign-in
from the previous step (`AUTH_REQUIRED` means run that first). The bot is
always created single-tenant (`--sign-in-audience myOrg`) — the right default
for a self-hosted assistant, applied without asking; for a bot other
Microsoft 365 tenants can install, set it up manually per
[Alternatives](#alternatives).
```nc:run effect:external when:have_creds=no capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
"$(npm prefix -g 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json
```
### Store the credentials
The adapter reads these from `.env` (set-if-absent — a value you've already
filled in is never overwritten). The pairing matters: `SingleTenant` requires
`TEAMS_APP_TENANT_ID`, and a multi-tenant app must instead set
`TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a mismatch makes the
adapter authenticate against the wrong authority and every message fails with
a 401 from Bot Framework.
```nc:env-set when:have_creds=no
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TENANT_ID={{app_tenant_id}}
TEAMS_APP_TYPE=SingleTenant
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Set the app icons
### Webhook server
The CLI-created app ships with placeholder icons; this swaps in the NanoClaw
mascot (the same PNGs the manual-path package bakes into its zip), so the
install dialog below already shows it. Cosmetic — a failure is logged and
skipped, never blocking setup. Re-runnable any time while signed in to the
Teams CLI:
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.
```nc:run effect:external when:have_creds=no
"$(npm prefix -g 2>/dev/null)/bin/teams" app update {{teams_app_id}} --color-icon setup/assets/teams/color.png --outline-icon setup/assets/teams/outline.png --json || echo "Icon update failed — cosmetic only, continuing."
```
For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration.
### Who owns this bot
The account signed into the Teams CLI is the account that just created the
bot — that human is the wiring target this flow suggests. Its identity comes
from the CLI session, so this runs before the sign-out step below:
```nc:run effect:fetch when:have_creds=no capture:owner_upn=.username,owner_aad_id=.userObjectId validate:^.+$
"$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null
```
### Confirm the wiring target
Nothing is wired without a confirmed target, and someone is always wired —
there is no skip. The account signed into the Teams CLI is often NOT the
person setting up NanoClaw, so a no leads to a clarifying choice: wire the
logged-in Teams user after all, or a different Teams user by Microsoft Entra
object ID. Identities are shown by sign-in name, never a raw ID:
```nc:operator when:have_creds=no
Detected the account that created the bot: {{owner_upn}}. Wiring the assistant to it means its first message arrives in that account's Teams DMs.
```
```nc:prompt wire_owner when:have_creds=no validate:^(yes|no)$ normalize:lower
Wire the assistant to this account?
```
```nc:operator when:wire_owner=no
You're currently logged in to Teams as {{owner_upn}}.
- To wire the assistant to this logged-in Teams user, choose "logged-in-account".
- To wire a different Teams user, get their Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users — and choose "other-account". Once wired, the assistant messages them first.
```
```nc:prompt wire_target when:wire_owner=no validate:^(logged-in-account|other-account)$ normalize:lower
Which Teams user should the assistant be wired to?
```
```nc:prompt target_aad_id when:wire_target=other-account 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}$ normalize:trim
Paste the Microsoft Entra object ID of the Teams user to wire (a GUID like 00000000-0000-0000-0000-000000000000).
```
Either choice re-enters the exact same path as a yes above — rebind the
wiring target and flip the branch, so the link chain below needs no second
copy per branch:
```nc:run when:wire_target=other-account capture:owner_aad_id=.aad,wire_owner=.wire validate:^.+$
printf '{"aad":"%s","wire":"yes"}' "{{target_aad_id}}"
```
```nc:run when:wire_target=logged-in-account capture:wire_owner validate:^yes$
echo yes
```
### Install the app in Teams
The app package is already uploaded — no manifest zip, no manual sideload.
Tell the user:
```nc:operator when:have_creds=no
Install the bot into Teams:
1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.
2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link
3. If Teams refuses with a custom-app-upload error, a tenant admin must enable sideloading: Teams Admin Center > Teams apps > Setup policies > Global > "Upload custom apps" = On.
Once the app shows up in your Teams sidebar (or app list), continue.
```
### Link the bot to your account
Nothing to do in Teams yet — these are background API calls, and the whole
chain runs only for a confirmed target from
[Confirm the wiring target](#confirm-the-wiring-target) (the detected owner
on a yes, or the provided Entra object ID). Same move as
Slack's `conversations.open` and Discord's `users/@me/channels`:
create the bot↔owner 1:1 conversation proactively with the bot's own
credentials, so the assistant messages the human first — nobody has to DM the
bot to bootstrap it. This only works now that the app is installed (the step
above); if Microsoft hasn't finished propagating the install yet, the create
below can fail once — re-running the skill is safe.
First a Bot Framework token from the app credentials:
```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ
curl -sf -X POST "https://login.microsoftonline.com/{{app_tenant_id}}/oauth2/v2.0/token" --data-urlencode "grant_type=client_credentials" --data-urlencode "client_id={{app_id}}" --data-urlencode "client_secret={{app_password}}" --data-urlencode "scope=https://api.botframework.com/.default" | jq -er '.access_token'
```
Create the 1:1 conversation (the AAD object id from the CLI session is a
valid member id; `smba.trafficmanager.net/teams` is the global service URL —
the same default the adapter itself uses):
```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$
curl -sf -X POST "https://smba.trafficmanager.net/teams/v3/conversations" -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"bot":{"id":"28:{{app_id}}"},"members":[{"id":"{{owner_aad_id}}","name":"","role":"user"}],"tenantId":"{{app_tenant_id}}","channelData":{"tenant":{"id":"{{app_tenant_id}}"}},"isGroup":false}' | jq -er '.id'
```
The adapter identifies inbound senders by their Bot Framework `29:` id, not
the AAD id — the owner must be wired under that handle or their replies
would not be recognized. The conversation was created with exactly one
member (the owner), so its member list is the owner by construction; the
filter only guards against channels that list the bot itself (`28:` ids).
(Don't select by `.aadObjectId` here — the field is not reliably present in
this response and its GUID casing varies.)
```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$
curl -sf "https://smba.trafficmanager.net/teams/v3/conversations/{{conversation_id}}/members" -H "Authorization: Bearer {{bot_token}}" | jq -er '[.[] | select((.id // "") | startswith("28:") | not)][0] | {id, name: (.name // .givenName // "Teams user")}'
```
Compose the platform id exactly as the adapter encodes thread ids
(`teams:{b64url conversation}:{b64url service url}`):
```nc:run when:wire_owner=yes capture:platform_id validate:^teams:
node -e 'const c=process.argv[1];const s="https://smba.trafficmanager.net/teams/";console.log("teams:"+Buffer.from(c).toString("base64url")+":"+Buffer.from(s).toString("base64url"))' "{{conversation_id}}"
```
### Sign out of the Teams CLI
The Microsoft 365 session was only needed to create the bot and identify its
owner — the running adapter authenticates with the app credentials in
`.env`, never with your account. On a headless box that session is a
plaintext token file, which is worth removing unless you plan more `teams …`
commands (rotate secret, endpoint update, RSC — each just needs a fresh
`teams login`, a ~30-second device code):
```nc:prompt signout when:have_creds=no validate:^(yes|no)$ normalize:lower
Sign out of the Teams CLI now? The bot doesn't need this login to run — signing out is recommended on shared or headless boxes, and `teams login` gets you back any time.
```
```nc:run effect:external when:signout=yes
"$(npm prefix -g 2>/dev/null)/bin/teams" logout
```
## 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
On a fresh create, [Link the bot to your account](#link-the-bot-to-your-account) already resolved
everything the wire needs — `owner_handle` (the owner's `29:` id) and
`platform_id` (the bot↔owner DM). The setup wizard wires automatically from
those and the welcome message lands in the owner's Teams DMs. Applying this
skill outside the wizard? Run the same wire yourself:
```bash
pnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id "teams:<owner_handle>" --platform-id "<platform_id>" --display-name "<the human's name>" --agent-name "<assistant name>" --role owner
```
**Fallback (re-runs, or the link step failed):** with credentials already in
`.env` the resolve steps are skipped, so there is nothing new to wire — the
first run's wiring still stands. If the install was never wired at all, the
DM-first path always works: DM the bot once ("hi" is fine) — the router
auto-creates the messaging group row in `data/v2.db` from that first inbound
— then run `/init-first-agent` (or `/manage-channels`) with your coding
agent.
## 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 — it wires
the owner automatically from the resolved values. Otherwise wire per
[Finish wiring](#finish-wiring).
## 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.
- **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
### Multi-tenant bot
The Credentials flow above always creates a single-tenant bot (only your
Microsoft 365 tenant can install it) — the right default for a self-hosted
assistant, so the skill doesn't ask. For a bot any tenant can install, run
the create by hand with `multipleOrgs` and store the matching env pairing —
`MultiTenant` with **no** tenant ID (the same 401 pairing rule from the
credentials step):
```bash
"$(npm prefix -g)/bin/teams" app create --name "YourBot" --endpoint "https://your-domain/webhook/teams" --sign-in-audience multipleOrgs --json
```
```bash
TEAMS_APP_ID=<CLIENT_ID from the output>
TEAMS_APP_PASSWORD=<CLIENT_SECRET from the output>
TEAMS_APP_TYPE=MultiTenant
```
Install via the `installLink` in the output, then continue from
[Restart](#restart). If this skill already created a single-tenant app,
start over first — see Rotate or recreate credentials in
[Troubleshooting](#troubleshooting).
### Manual Azure portal path
For tenants where the Teams Developer Portal is blocked. Unlike the CLI path,
the Azure Bot resource in step 3 requires an active **Azure subscription**.
This is the classic walk; every value it produces maps onto the same `.env`
keys. Ask the human before creating anything: the app registration name,
single vs multi tenant, a client secret description, and (this path only) a
separate Azure Bot handle.
1. **App registration**: in https://portal.azure.com, search "App registrations"
→ "New registration". Name it (e.g. "NanoClaw"); Supported account types:
Single tenant (most common for self-host) or Multi tenant. From the Overview
page copy the **Application (client) ID** and — single tenant only — the
**Directory (tenant) ID**.
2. **Client secret**: in the app registration, "Certificates & secrets" → "New
client secret" (expires 180 days or longer). **Copy the Value now** — Azure
shows it once (the Value column, not the Secret ID).
3. **Azure Bot resource**: search "Azure Bot" → Create. Bot handle: any unique
name; Type of App: must match step 1; Creation type: "Use existing app
registration" with the App ID from step 1. After creating, open the bot →
Configuration and set **Messaging endpoint** to
`https://your-domain/webhook/teams`, then Apply.
4. **Enable the Teams channel**: Azure Bot resource → Channels → Microsoft
Teams → Accept terms → Apply.
5. **Store the credentials** in `.env` (the same 401 pairing rule applies —
`SingleTenant` needs the tenant ID, `MultiTenant` must omit it):
```bash
TEAMS_APP_ID=<Application (client) ID>
TEAMS_APP_PASSWORD=<client secret Value>
TEAMS_APP_TYPE=SingleTenant
TEAMS_APP_TENANT_ID=<Directory (tenant) ID>
```
6. **Build the app package** (manifest + icons, written in-process to
`data/teams/teams-app-package.zip` — no `zip` binary needed):
```bash
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain
```
7. **Sideload**: Microsoft Teams → Apps → Manage your apps → Upload an app →
"Upload a custom app" → select the zip → Add.
8. Continue from [Restart](#restart).
Or create the bot resource with the Azure CLI instead of the portal:
```bash
az group create --name nanoclaw-rg --location eastus
az bot create --resource-group nanoclaw-rg --name nanoclaw-bot --app-type SingleTenant --appid YOUR_APP_ID --tenant-id YOUR_TENANT_ID --endpoint "https://your-domain/webhook/teams"
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
```
## Optional configuration
### Receive all channel messages (without @-mention)
By default the bot only receives messages when @-mentioned. With a CLI-created
bot, grant the resource-specific-consent (RSC) permissions directly — no
manifest edit, no re-upload; the app version is bumped automatically:
```bash
teams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application
teams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application
```
Then update/reinstall the app in the team so the new permissions get consented.
(`<teams-app-id>` is the Teams App ID shown in the install step — recover it
any time with `teams app list`, or find the app at
https://dev.teams.microsoft.com/apps.)
On the manual path, regenerate the package with RSC baked in and sideload it
again (the manifest version is bumped so the upload supersedes the original):
```bash
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain --rsc
```
## Troubleshooting
### "Upload a custom app" is missing / sideloading blocked
`teams status` shows whether sideloading is enabled at both tenant
and user level; the login output prints the same check.
- **Tenant level off**: Teams Admin Center → **Teams apps** → **Setup
policies** → **Global** → **Upload custom apps** = On.
- **"Enabled for the tenant, but your user policy blocks it"**: the per-user
policy is the blocker — Teams Admin Center → **Users** → find the user →
**Policies** → **App setup policy** → assign one with **Upload custom
apps** = On. Policy changes can take a while to propagate.
Free personal Teams does not support sideloading at all — use a Microsoft 365
Business / EDU / developer tenant.
The login step's sideloading probe is **advisory** — policy edits can take
hours to propagate and the probe has been seen flapping between runs on the
same account. The authoritative test is whether the install link's Add
actually works; only act on the probe if the install itself refuses.
### `teams: command not found`
The CLI installed fine but npm's global bin directory isn't on your PATH — a
common state with custom npm prefixes. Find it with `npm prefix -g` (the
binary is at `<prefix>/bin/teams`), then either add that directory to PATH or
symlink the binary somewhere already on it. The skill's own steps are immune —
they invoke the absolute path.
### Create fails immediately with `AUTH_REQUIRED` after a successful sign-in
The sign-in didn't persist: each `teams` command is a separate process, and
when the CLI's credential store can't load it silently falls back to an
in-memory cache that dies with the login process. Symptom check:
`teams status` says logged out right after a login succeeded. The known
cause: the **CLI was installed as a pnpm workspace dependency** — pnpm's
supply-chain policy skips dependency build scripts, so keytar (the CLI's
native credential store) never gets its binary and the whole store fails to
load. Use the global npm install this skill performs — and `pnpm uninstall
@microsoft/teams.cli` if a workspace copy lingers, so `teams` resolves to
the global one. (The "libsecret not found → stored unencrypted" warning is
NOT this failure — that fallback persists fine and is safe to ignore.)
After fixing, sign in again and confirm `teams status` shows logged in, then
re-run this skill.
### Bot never receives messages
1. The app is actually installed in Teams — if setup was interrupted before
the install step, nothing got installed. Recover the install link:
`teams app list` shows the Teams App ID, then
`teams app get <teams-app-id> --install-link`.
2. The tunnel is up and the messaging endpoint matches it — the endpoint must
be `https://<your-domain>/webhook/teams`, and your tunnel (e.g.
`cloudflared tunnel --url http://localhost:3000`) must be forwarding to
this machine's port 3000. Check
with `teams app doctor <teams-app-id>` (CLI-created bots) or Azure
Bot → **Configuration** (manual path).
3. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
4. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,
`TEAMS_APP_TYPE`).
### Tunnel URL changed
Point the bot at the new endpoint:
`teams app update <teams-app-id> --endpoint "https://new-domain/webhook/teams"`
(manual path: Azure Bot → Configuration → Messaging endpoint).
### `Unauthorized` / 401 from Azure Bot Service
Either the credential pairing is wrong, or the secret is dead:
- **Pairing**: `TEAMS_APP_TYPE=SingleTenant` requires `TEAMS_APP_TENANT_ID`;
`MultiTenant` must have **no** tenant ID set. A mismatch authenticates
against the wrong authority and every send/receive 401s.
- **Secret**: expired or mispasted. Rotate with
`teams app auth secret create <teams-app-id>` (or Azure portal →
Certificates & secrets), update `TEAMS_APP_PASSWORD` in `.env`, and restart.
### Rotate or recreate credentials
The credentials flow skips creation while `.env` has `TEAMS_APP_ID` **or**
`TEAMS_APP_PASSWORD` — deleting just one line does not make the skill
regenerate it (that would pair a new app with stale keys). To rotate only the
secret, use the 401 section above. To start over completely: delete **all**
`TEAMS_*` lines from `.env`, optionally delete the old app at
https://dev.teams.microsoft.com/apps (CLI path) or in Azure Portal → App
registrations (manual path), then re-run this skill. Re-running
`teams app create` with old credentials still in `.env` would otherwise create
a second, orphaned app.
### Replies land in the wrong place
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.
@@ -0,0 +1,75 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. Four scenarios: existing-creds drop-through (the have_creds probe answers 'yes'); a fresh create wiring the logged-in owner directly; a fresh create wiring a DIFFERENT account (wire_target=other-account); and a fresh create re-confirming via the logged-in account (wire_target=logged-in-account). The `app create` stub answers the five-field JSON capture; `status --json` answers owner_upn/owner_aad_id; the smba stubs answer the DM-open chain. Guards driven by CAPTURES (have_creds) are selected by exec stdout, not inputs.",
"coverageExclude": [
"wire_owner=no"
],
"coverageExcludeReason": "wire_owner=no is exercised at runtime by the other-account and logged-in-account scenarios (their wire_target prompts only run under it), but BOTH of those legs deliberately re-bind wire_owner to 'yes' via capture (SKILL.md ~L257/L261) before the run ends, so no scenario can finish with vars.wire_owner='no'. Final-vars coverage is impossible by design, not missing.",
"scenarios": [
{
"name": "existing-creds",
"inputs": {},
"exec": [
{ "match": "TEAMS_APP_ID=.", "stdout": "yes" }
]
},
{
"name": "create-wire-owner",
"inputs": {
"public_url": "https://nc.example.com",
"app_name": "NanoClaw Bot",
"wire_owner": "yes",
"signout": "yes"
},
"exec": [
{ "match": "TEAMS_APP_ID=.", "stdout": "no" },
{ "match": "app create", "stdout": "{\"credentials\":{\"CLIENT_ID\":\"11111111-2222-3333-4444-555555555555\",\"CLIENT_SECRET\":\"fake-client-secret\",\"TENANT_ID\":\"99999999-8888-7777-6666-555555555555\"},\"teamsAppId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"installLink\":\"https://teams.microsoft.com/l/app/fake\"}" },
{ "match": "status --json", "stdout": "{\"username\":\"owner@contoso.com\",\"userObjectId\":\"12345678-1234-1234-1234-123456789abc\"}" },
{ "match": "oauth2/v2.0/token", "stdout": "eyJhbGciOiJIUzI1NiJ9.fake.sig" },
{ "match": "/members", "stdout": "{\"id\":\"29:1fakeownerid\",\"name\":\"Owner\"}" },
{ "match": "v3/conversations\"", "stdout": "a:1fakeconversationid" },
{ "match": "base64url", "stdout": "teams:YTpmYWtl:aHR0cHM6Ly9zbWJh" }
]
},
{
"name": "create-wire-other-account",
"inputs": {
"public_url": "https://nc.example.com",
"app_name": "NanoClaw Bot",
"wire_owner": "no",
"wire_target": "other-account",
"target_aad_id": "12345678-1234-1234-1234-123456789abc",
"signout": "no"
},
"exec": [
{ "match": "TEAMS_APP_ID=.", "stdout": "no" },
{ "match": "app create", "stdout": "{\"credentials\":{\"CLIENT_ID\":\"11111111-2222-3333-4444-555555555555\",\"CLIENT_SECRET\":\"fake-client-secret\",\"TENANT_ID\":\"99999999-8888-7777-6666-555555555555\"},\"teamsAppId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"installLink\":\"https://teams.microsoft.com/l/app/fake\"}" },
{ "match": "status --json", "stdout": "{\"username\":\"owner@contoso.com\",\"userObjectId\":\"12345678-1234-1234-1234-123456789abc\"}" },
{ "match": "printf '{\"aad\"", "stdout": "{\"aad\":\"12345678-1234-1234-1234-123456789abc\",\"wire\":\"yes\"}" },
{ "match": "oauth2/v2.0/token", "stdout": "eyJhbGciOiJIUzI1NiJ9.fake.sig" },
{ "match": "/members", "stdout": "{\"id\":\"29:1faketargetid\",\"name\":\"Target user\"}" },
{ "match": "v3/conversations\"", "stdout": "a:1fakeconversationid" },
{ "match": "base64url", "stdout": "teams:YTpmYWtl:aHR0cHM6Ly9zbWJh" }
]
},
{
"name": "create-wire-logged-in-account",
"inputs": {
"public_url": "https://nc.example.com",
"app_name": "NanoClaw Bot",
"wire_owner": "no",
"wire_target": "logged-in-account",
"signout": "no"
},
"exec": [
{ "match": "TEAMS_APP_ID=.", "stdout": "no" },
{ "match": "app create", "stdout": "{\"credentials\":{\"CLIENT_ID\":\"11111111-2222-3333-4444-555555555555\",\"CLIENT_SECRET\":\"fake-client-secret\",\"TENANT_ID\":\"99999999-8888-7777-6666-555555555555\"},\"teamsAppId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"installLink\":\"https://teams.microsoft.com/l/app/fake\"}" },
{ "match": "status --json", "stdout": "{\"username\":\"owner@contoso.com\",\"userObjectId\":\"12345678-1234-1234-1234-123456789abc\"}" },
{ "match": "echo yes", "stdout": "yes" },
{ "match": "oauth2/v2.0/token", "stdout": "eyJhbGciOiJIUzI1NiJ9.fake.sig" },
{ "match": "/members", "stdout": "{\"id\":\"29:1fakeownerid\",\"name\":\"Owner\"}" },
{ "match": "v3/conversations\"", "stdout": "a:1fakeconversationid" },
{ "match": "base64url", "stdout": "teams:YTpmYWtl:aHR0cHM6Ly9zbWJh" }
]
}
]
}
+123 -66
View File
@@ -5,111 +5,168 @@ description: Add Telegram channel integration via Chat SDK.
# Add Telegram Channel
Adds Telegram bot support via the Chat SDK bridge.
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.
## Install
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.
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.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter, helpers, and tests
Skip to **Credentials** if all of these are already in place:
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):
- `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
```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
```
### 2. Copy the adapter, helpers, tests, registration test, and setup step
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
```nc:append to:src/channels/index.ts
import './telegram.js';
```
### 4. Register the setup step
### 3. Register the pairing setup step
In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present):
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:
```typescript
```nc:append to:setup/index.ts at:nanoclaw:setup-steps
'pair-telegram': () => import('./pair-telegram.js'),
```
### 5. Install the adapter package (pinned)
### 4. Install the adapter package
```bash
pnpm install @chat-adapter/telegram@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/telegram@4.29.0
```
### 6. Build and validate
### 5. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/telegram-registration.test.ts
```
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.
`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.
## Credentials
### Create Telegram Bot
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:
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
```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-zyx57W2v1u123ew11a).
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.
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
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.
## 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 wire
this channel with `/init-first-agent` (or `/manage-channels`).
## 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.
- **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).
- **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.
- **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.
## Troubleshooting
**The bot token paste is rejected.** A BotFather token is `<numeric bot id>:<35+ character secret>` — e.g. `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11a`. Pasting only the part after the colon, or the bot's @username, won't pass. Recover the full token any time by sending `/token` to @BotFather.
**`getMe` fails.** The token was revoked (a `/revoke` or a fresh `/token` invalidates the old value) or picked up whitespace in the paste. Get the current token from BotFather and re-paste it.
**Pairing never completes.** The live adapter is what observes the code, so the service must be running — the restart step comes before pairing for exactly this reason. Send *just* the 4 digits from the exact chat you want registered; in a group with Group Privacy on, prefix them with `@<botname>`. Wrong guesses are fine (a fresh code is issued, up to 5 times), but a dead adapter waits forever.
**The bot ignores group messages.** Group Privacy is on, so the bot only sees @-mentions and replies. BotFather → `/mybots` → your bot → Bot Settings → Group Privacy → Turn off — then remove and re-add the bot to the group so the change takes effect.
**Everything green but no replies.** Run `pnpm exec vitest run src/channels/telegram-registration.test.ts` — red means the barrel import or the `@chat-adapter/telegram` install drifted, so re-run the Apply steps. If green, restart again (`bash setup/lib/restart.sh`) and check `logs/nanoclaw.error.log` for token errors.
@@ -0,0 +1,15 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. The getMe stub answers the bot_username capture (the operator block after the restart renders it); stepFields feed the pairing step's terminal block (capture:platform_id=PLATFORM_ID,owner_handle=ADMIN_USER_ID).",
"scenarios": [
{
"name": "pairing",
"inputs": {
"bot_token": "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
"exec": [
{ "match": "getMe", "stdout": "nanoclaw_bot" }
],
"stepFields": { "PLATFORM_ID": "telegram:123456789", "ADMIN_USER_ID": "987654321" }
}
]
}
+82 -47
View File
@@ -5,85 +5,110 @@ description: Add Webex channel integration via Chat SDK.
# Add Webex Channel
Adds Cisco Webex support via the Chat SDK bridge.
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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the Webex 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 Webex adapter and its registration test
into `src/channels/` (overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/webex.ts
src/channels/webex-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './webex.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @bitbasti/chat-adapter-webex@0.1.0
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
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/webex-registration.test.ts
```
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.
`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.
## Credentials
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**
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**.
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.
### Configure environment
### Store the credentials
Add to `.env`:
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):
```bash
WEBEX_BOT_TOKEN=your-bot-token
WEBEX_WEBHOOK_SECRET=your-webhook-secret
```nc:prompt bot_token secret
Paste the Bot Access Token — from the Webex bot you created.
```
```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
Sync to container: `mkdir -p data/env && cp .env data/env/env`
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.
## 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
@@ -93,3 +118,13 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **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 information.
## Troubleshooting
**Sends fail with 401.** The Bot Access Token is shown once, on the bot's page under developer.webex.com → My Apps — it is *not* the 12-hour personal access token from the API docs pages, which is the classic mix-up (that one works briefly, then everything 401s). Regenerate the token on the bot page if needed; regenerating invalidates the old value, so update `WEBEX_BOT_TOKEN` right away.
**Messages in the space never reach the agent.** Webex delivers only to the webhook you created: it must target your public host at `/webhook/webex` (shared webhook server, port 3000) with resource `messages`. List your webhooks with `GET https://webexapis.com/v1/webhooks` using the bot token — Webex flips a webhook to `inactive` after repeated delivery failures, and it stays off until you re-enable or recreate it.
**Events arrive but are rejected.** Signature mismatch: the secret set at webhook creation must equal `WEBEX_WEBHOOK_SECRET` exactly. Recreate the webhook with a known secret and update `.env` to match.
**Adapter installed but silent.** Run `pnpm exec vitest run src/channels/webex-registration.test.ts` — red means the barrel import or the `@bitbasti/chat-adapter-webex` install drifted, so re-run the Apply steps. If green, restart the service so it loads the adapter and the tokens, then watch `logs/nanoclaw.log` for the webhook hit.
@@ -0,0 +1,12 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials.",
"scenarios": [
{
"name": "bot",
"inputs": {
"bot_token": "fake-webex-bot-token",
"webhook_secret": "fake-webex-webhook-secret"
}
}
]
}
+5 -9
View File
@@ -36,16 +36,12 @@ pnpm uninstall wechat-ilink-client
rm -rf data/wechat
```
## 5. Remove DB wiring
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>`.
```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
## 5. Rebuild and restart
Run from your NanoClaw project root:
-1
View File
@@ -85,7 +85,6 @@ 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
+81 -40
View File
@@ -6,62 +6,71 @@ 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.
## Install
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.
NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch.
## Apply
### Pre-flight (idempotent)
### 1. Copy the adapter
Skip to **Credentials** if all of these are already in place:
Fetch the `channels` branch and copy the WhatsApp Cloud adapter into
`src/channels/` (overwrite — the branch is canonical):
- `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
```nc:copy from-branch:channels
src/channels/whatsapp-cloud.ts
src/channels/whatsapp-cloud-registration.test.ts
```
### 2. Copy the adapter and its registration test
### 2. Register the adapter
```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
```
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:
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
```nc:append to:src/channels/index.ts
import './whatsapp-cloud.js';
```
### 4. Install the adapter package (pinned)
### 3. Install the adapter package
```bash
pnpm install @chat-adapter/whatsapp@4.29.0
Pinned to an exact version — the supply-chain policy rejects ranges and `latest`:
```nc:dep
@chat-adapter/whatsapp@4.29.0
```
### 5. Build and validate
### 4. Build and validate
```bash
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
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts
```
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`.
`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.
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.
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.
## 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**:
@@ -73,18 +82,40 @@ End-to-end message delivery against a real WhatsApp Business number is verified
- Subscribe to webhook fields: `messages`.
5. Copy the **App Secret** from **Settings** > **Basic**.
### Configure environment
### Store the credentials
Add to `.env`:
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):
```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 access_token secret
Paste the System User access token — WhatsApp > API Setup, with `whatsapp_business_messaging` permission.
```
```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
Sync to container: `mkdir -p data/env && cp .env data/env/env`
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.
## Next Steps
@@ -100,3 +131,13 @@ 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.
## Troubleshooting
**Meta's "Verify and save" fails on the webhook.** Meta hits your URL with a challenge the moment you click, so the endpoint must already be publicly reachable at `/webhook/whatsapp` (shared webhook server, port 3000) *and* the service must be running with `WHATSAPP_VERIFY_TOKEN` set to exactly the string you typed under WhatsApp > Configuration. Start or restart the service first, then click verify.
**Everything works for a day, then all calls 401.** You stored the temporary token from WhatsApp > API Setup, which expires in ~24 hours. Create a **System User** under Business Settings → Users, grant it the app with `whatsapp_business_messaging`, generate a permanent token, and replace `WHATSAPP_ACCESS_TOKEN`.
**Outbound messages are accepted but never delivered.** Two Meta-side gates: while the app is in development mode you can only message numbers added to the recipient allowlist in API Setup; and free-form replies are only allowed within 24 hours of the user's last inbound message — outside that window you need an approved template. Also confirm `WHATSAPP_PHONE_NUMBER_ID` is the Phone Number *ID*, not the phone number itself.
**Adapter installed but nothing flows.** Run `pnpm exec vitest run src/channels/whatsapp-cloud-registration.test.ts` — red means the barrel import or the `@chat-adapter/whatsapp` install drifted, so re-run the Apply steps. If green, restart the service (see Next Steps) so the adapter and `.env` values are live.
@@ -0,0 +1,14 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials.",
"scenarios": [
{
"name": "cloud-api",
"inputs": {
"access_token": "EAAfake-access-token",
"phone_number_id": "123456789012345",
"app_secret": "fake-app-secret",
"verify_token": "fake-verify-token"
}
}
]
}
+5 -6
View File
@@ -10,10 +10,13 @@ Delete the self-registration import from `src/channels/index.ts` (skip if alread
import './whatsapp.js';
```
Then delete the copied adapter, its registration test, and its unit test:
Then delete the copied adapter, its registration test, its unit test, and the
`whatsapp-formatting` container skill (part of the channel payload — trunk
doesn't ship it):
```bash
rm -f src/channels/whatsapp.ts src/channels/whatsapp-registration.test.ts src/channels/whatsapp.test.ts
rm -rf container/skills/whatsapp-formatting
```
## 2. Remove the setup steps
@@ -37,11 +40,7 @@ rm -f setup/whatsapp-auth.ts
## 3. Remove credentials
Remove `ASSISTANT_HAS_OWN_NUMBER` from `.env` (only present if a dedicated number was configured), then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
Remove `ASSISTANT_HAS_OWN_NUMBER` and `ASSISTANT_NAME` from `.env`.
## 4. Remove the packages
+335 -181
View File
@@ -5,73 +5,82 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
# Add WhatsApp Channel
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
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.
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.
## Number safety check (required)
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
Complete this check before running any install or authentication command. If
the user already said they want to use their **shared**, **personal**,
**main**, **existing**, or **everyday** WhatsApp number, treat it as a shared
number and show the warning immediately. Do not ask the number-type question
again.
Otherwise, use `AskUserQuestion`:
Otherwise, ask which WhatsApp number NanoClaw will use:
**Which WhatsApp number will NanoClaw use?**
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
- **Shared / personal number** — the user's existing everyday WhatsApp number
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
If `NUMBER_MODE=shared`, show this warning exactly:
> ⚠️ **Risk to your WhatsApp account**
>
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
>
> We strongly recommend using a separate, dedicated number for NanoClaw.
Then use `AskUserQuestion`:
- **Go back and use a dedicated number (Recommended)**
- **I understand the risk — continue with my shared number**
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
## Install
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.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `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
- `container/skills/whatsapp-formatting/instructions.md` exists
- `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
```nc:prompt number_mode validate:^(dedicated|shared)$
Which WhatsApp number will NanoClaw use? `dedicated` (recommended) — a separate number used only for NanoClaw (spare SIM, eSIM, or old phone). `shared` — your existing everyday / personal WhatsApp number.
```
### 2. Copy the adapter and setup steps
If the answer is `shared`, show this warning — tell the user:
```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
mkdir -p container/skills/whatsapp-formatting
git show origin/channels:container/skills/whatsapp-formatting/SKILL.md > container/skills/whatsapp-formatting/SKILL.md
git show origin/channels:container/skills/whatsapp-formatting/instructions.md > container/skills/whatsapp-formatting/instructions.md
```nc:operator when:number_mode=shared
⚠️ Risk to your WhatsApp account
Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
We strongly recommend using a separate, dedicated number for NanoClaw.
On your personal number, the agent lives only in your "You" / self-chat. Messages other people send you are ignored entirely — never read, never answered, never flagged for approval. Nobody else can talk to the agent.
If you want the agent reachable as its own contact, consider:
• Telegram — a bot takes ~2 minutes to set up
• a dedicated WhatsApp number — spare SIM, eSIM, or old phone
• /add-whatsapp-cloud — the official Meta Business API
```
Then confirm how to proceed. Do not continue with installation or
authentication unless the user explicitly selects the second option:
```nc:prompt shared_confirm validate:^(continue|dedicated)$ when:number_mode=shared
How would you like to proceed? `dedicated` (recommended) — go back and use a dedicated number. `continue` — I understand the risk, continue with my shared number.
```
Remember the effective mode for the rest of this workflow: it is `shared` only
when the user explicitly acknowledged the risk and continued; anyone who chose
a dedicated number — up front or at the warning — continues as a
dedicated-number install without seeing the warning again:
```nc:run capture:mode effect:fetch when:number_mode=dedicated
echo dedicated
```
```nc:run capture:mode effect:fetch when:shared_confirm=continue
echo shared
```
```nc:run capture:mode effect:fetch when:shared_confirm=dedicated
echo dedicated
```
## Apply
### 1. Copy the adapter and its registration test
Fetch the `channels` branch and copy the WhatsApp adapter, its registration
test, and the `whatsapp-formatting` container skill (overwrite — the branch is
canonical). The `whatsapp-auth` setup step is maintained in trunk, so it is not
copied here:
```nc:copy from-branch:channels
src/channels/whatsapp.ts
src/channels/whatsapp-registration.test.ts
container/skills/whatsapp-formatting/SKILL.md
container/skills/whatsapp-formatting/instructions.md
```
The `whatsapp-formatting` container skill is part of the channel payload: its
@@ -80,172 +89,187 @@ group's composed CLAUDE.md (see `src/claude-md-compose.ts`), teaching agents
WhatsApp's formatting syntax. Trunk does not ship it — without this copy step
agents format WhatsApp messages with generic markdown that renders literally.
### 3. Append the self-registration import
### 2. Register the adapter
Append to `src/channels/index.ts` (skip if already present):
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:
```typescript
```nc:append to:src/channels/index.ts
import './whatsapp.js';
```
### 4. Register the setup steps
### 3. Install the adapter packages
In `setup/index.ts`, add these entries to the `STEPS` map (skip lines already present):
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:
```typescript
groups: () => import('./groups.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
```nc:dep
@whiskeysockets/baileys@7.0.0-rc.9
qrcode@1.5.4
@types/qrcode@1.5.6
pino@9.6.0
```
### 5. Install the adapter packages (pinned)
### 4. Build and validate
```bash
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
Build first: it typechecks the adapter against core and proves the dependencies
are installed. Then run the one integration test.
### 6. Build and validate
```bash
```nc:run effect:build
pnpm run build
```
```nc:run effect:test
pnpm exec vitest run src/channels/whatsapp-registration.test.ts
```
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.
`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.
End-to-end message delivery against a real WhatsApp number is verified manually once the service is running — see Credentials, Wiring, and Troubleshooting.
## Authenticate
## Credentials
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.
WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone.
The number safety check above is still required even when credentials already
exist. If `store/auth/creds.json` exists, skip ahead to "Dedicated vs personal
number" after completing the safety check — the link step below reports the
already-linked number and moves on.
### Check current state
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):
Check if WhatsApp is already authenticated. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
```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).
```
### Detect environment
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):
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"
```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).
```
### Ask the user
Point the user at the right screen before the code appears. For the QR method,
tell the user:
Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:**
If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp?
- **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number)
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
- **QR code in browser** (Recommended) - 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/
```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.
```
### Run WhatsApp authentication
For the pairing-code method, tell the user:
For QR code in browser (recommended):
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
```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.
```
(Bash timeout: 150000ms)
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`:
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
```nc:run effect:step capture:bot_phone=PHONE when:auth_method=qr
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
```
(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 &
```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}}
```
Then immediately poll for the code (do NOT wait for the background command to finish):
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.
```bash
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
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}}" ]
```
Display the code to the user the moment it appears. Tell them:
## Your personal chat number (dedicated number only)
> **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
On a dedicated number, the agent owns the linked line and you chat with it from
your own, different number. Collect that number — it is required, and it is
*not* the number you just linked. Tell the user:
After the user enters the code, poll for authentication to complete:
```nc:operator when:mode=dedicated
The agent is signed in as +{{bot_phone}}.
```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
Now, your personal number — the one you'll chat with the agent from. It'll show up as a normal two-way conversation with the agent's contact.
```
**If failed:** logged_out → delete `store/auth/` and re-run. timeout → ask user, offer retry.
```nc:prompt chat_phone validate:^\d{8,15}$ when:mode=dedicated
Your personal number, where you'll chat from — digits only, country code first (e.g. 14155551234). Required — this must be YOUR number, not the agent's linked one.
```
### Verify authentication succeeded
Chatting from the bot's own number IS the shared-number setup — if the number
given equals the linked number, stop and route through the same interception
screen as the up-front pick: show the account-risk warning from the number
safety check again and get explicit acknowledgement before treating this
install as shared (or collect a genuinely different personal number and stay
dedicated). If the install does become shared, correct the mode everywhere it
was recorded — in particular make sure `.env` ends up with
`ASSISTANT_HAS_OWN_NUMBER=false`, rewriting a `true` that may already have been
written; a stale `true` on a personal number makes the bot claim messages
addressed to the human:
```bash
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
```nc:run effect:check when:mode=dedicated
[ "{{chat_phone}}" != "{{bot_phone}}" ]
```
## Dedicated vs personal number
The adapter behaves fundamentally differently depending on whether the linked number is the assistant's own or the operator's personal one. The switch is `ASSISTANT_HAS_OWN_NUMBER` in `.env`, read by the adapter itself at startup. **Inference rule: absent (or anything other than `true`) means shared/personal** — the safe default, since misreading a personal number as dedicated makes the bot claim messages addressed to the human.
The adapter behaves fundamentally differently depending on whether the linked
number is the assistant's own or the operator's personal one. The switch is
`ASSISTANT_HAS_OWN_NUMBER` in `.env`, read by the adapter itself at startup.
**Inference rule: absent (or anything other than `true`) means shared/personal**
— the safe default, since misreading a personal number as dedicated makes the
bot claim messages addressed to the human.
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
Use the mode selected in the required safety check. If information discovered
later contradicts that selection, ask again before changing modes; switching to
shared requires the same warning and explicit acknowledgement.
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
Write the answer to `.env` **explicitly in both cases** (don't rely on the
inference rule for new installs), replacing any existing
`ASSISTANT_HAS_OWN_NUMBER` line. Written in both modes so a re-run that
switches dedicated → shared doesn't leave a stale `true` behind:
```bash
# Dedicated:
ASSISTANT_HAS_OWN_NUMBER=true
# Shared/personal:
ASSISTANT_HAS_OWN_NUMBER=false
```nc:run effect:external when:mode=dedicated
grep -q '^ASSISTANT_HAS_OWN_NUMBER=' .env && sed -i.bak 's/^ASSISTANT_HAS_OWN_NUMBER=.*/ASSISTANT_HAS_OWN_NUMBER=true/' .env && rm -f .env.bak || echo 'ASSISTANT_HAS_OWN_NUMBER=true' >> .env
```
```nc:run effect:external when:mode=shared
grep -q '^ASSISTANT_HAS_OWN_NUMBER=' .env && sed -i.bak 's/^ASSISTANT_HAS_OWN_NUMBER=.*/ASSISTANT_HAS_OWN_NUMBER=false/' .env && rm -f .env.bak || echo 'ASSISTANT_HAS_OWN_NUMBER=false' >> .env
```
### Assistant name
Both modes: keep the adapter's outbound prefix / mention normalization in sync
with the chosen agent name (the adapter's config default is `Andy` otherwise).
Use the assistant's already-chosen name if one was configured; otherwise ask:
```nc:prompt agent_name normalize:trim validate:^.+$
What should your assistant be called? (e.g. `Nano` — used as the outbound name prefix on a shared number, and for @-name engagement)
```
Persist it to `.env` as `ASSISTANT_NAME`, replacing any existing
`ASSISTANT_NAME` line (the value is written literally — no pattern expansion):
```nc:run effect:external
touch .env && grep -v '^ASSISTANT_NAME=' .env > .env.tmp; printf 'ASSISTANT_NAME=%s\n' '{{agent_name}}' >> .env.tmp && mv .env.tmp .env
```
### Update path: existing install, flag unset
@@ -284,17 +308,85 @@ Stale approval cards from that era can also linger. Clear pending channel approv
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM pending_channel_approvals WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='whatsapp')"
```
## Self-chat engagement (shared number only)
On a shared number the agent lives in your "You" / self-chat. Choose whether it
responds to every message you write there, or only to messages addressed to it
by name:
```nc:prompt selfchat_engage validate:^(all|mention)$ when:mode=shared
Respond to every self-chat message, or only messages starting with @<agent name>? `all` — every message (the self-chat becomes the agent's inbox). `mention` — only messages starting with @<agent name> (keep the self-chat for your own notes too).
```
For `mention`, the engage pattern is `@` plus the regex-escaped agent name,
anchored to the start of the message, with a trailing `\b` word-boundary guard.
`\b` only terminates a match after a word character — skip it for names ending
in punctuation, where it would never match:
```nc:run capture:engage_pattern effect:fetch when:selfchat_engage=mention
node -e 'const n=process.argv[1];const e=n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");console.log(/\w$/.test(n)?"^@"+e+"\\b":"^@"+e)' '{{agent_name}}'
```
`engage_pattern` is what the self-chat wiring uses: when wiring this channel
with `scripts/init-first-agent.ts`, pass it as `--engage-pattern`. Choosing
`all` leaves it unset — the wiring falls back to the respond-to-everything
default for a DM.
## Restart
Restart NanoClaw so it loads the WhatsApp adapter and sees your credentials and
settings, and wait for its CLI socket before resolving. Restart only after
`ASSISTANT_HAS_OWN_NUMBER` / `ASSISTANT_NAME` land in `.env` — the adapter
computes its shared/dedicated mode and name once at module load, so restarting
earlier would leave it running with defaults:
```nc:run effect:restart
bash setup/lib/restart.sh
```
## Resolve your DM channel
Resolve the conversation address as the WhatsApp JID for the number you chat
from — the linked number itself for a shared account (your self-chat), or the
personal number you gave for a dedicated one. Run the one matching the mode:
```nc:run capture:platform_id effect:fetch when:mode=shared
echo "{{bot_phone}}@s.whatsapp.net"
```
```nc:run capture:platform_id effect:fetch when:mode=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.
For a shared number, set expectations — tell the user:
Otherwise, run `/manage-channels` to wire this channel to an agent group.
```nc:operator when:mode=shared
Self-chat mode: only your "You" / self-chat is connected. Messages other people send to your number are ignored — never seen, never asked about. The welcome message will land in your "You" chat on WhatsApp.
Wire a specific chat later with /manage-channels.
```
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`) — in shared
`mention` mode, pass the engage pattern above via `--engage-pattern`.
## Channel Info
- **type**: `whatsapp`
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-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"`.
- **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"`.
- **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.
@@ -308,18 +400,76 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- Typing indicators — composing presence updates
- Credential requests — text fallback (WhatsApp has no modal support)
Not supported (WhatsApp linked device limitation): edit messages, delete messages.
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 writes this key for you **in both modes** — `true` for a
dedicated number, `false` for a shared (personal) one — so a re-run that
switches modes never leaves a stale value behind. Absent (or anything other
than `true`) is read as shared/personal, the safe default.
## Troubleshooting
### QR code expired
### QR code or pairing code expired
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`:
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:
```bash
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
```
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:
@@ -330,7 +480,11 @@ 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
@@ -338,9 +492,8 @@ pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
### "waiting for this message" on reactions
Signal sessions corrupted from rapid restarts. Clear sessions.
Run from your NanoClaw project root:
WhatsApp sessions corrupted from rapid restarts. Clear sessions, then restart the
service. Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
@@ -358,7 +511,8 @@ systemctl --user start $(systemd_unit)
### "conflict" disconnection
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
Two instances connected with the same credentials. Ensure only one NanoClaw
process is running.
### Trunk updated but shared-number behavior unchanged (stale adapter copy)
@@ -0,0 +1,51 @@
{
"notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. Three scenarios cover all four number_mode/shared_confirm guard values, both auth methods, and both when:mode= legs. The echo stubs answer the mode/platform_id/owner_handle captures; stepFields feed the QR / pairing-code step's terminal block (capture:bot_phone=PHONE); the 'test(n)' stub answers the engage_pattern capture on the shared leg.",
"scenarios": [
{
"name": "shared-qr",
"inputs": {
"number_mode": "shared",
"shared_confirm": "continue",
"auth_method": "qr",
"agent_name": "Nano",
"selfchat_engage": "mention"
},
"exec": [
{ "match": "echo shared", "stdout": "shared" },
{ "match": "test(n)", "stdout": "^@Nano\\b" },
{ "match": "@s.whatsapp.net", "stdout": "15550001111@s.whatsapp.net" }
],
"stepFields": { "PHONE": "15550001111" }
},
{
"name": "dedicated-pairing-code",
"inputs": {
"number_mode": "dedicated",
"auth_method": "pairing-code",
"phone": "15550001111",
"chat_phone": "15559998888",
"agent_name": "Nano"
},
"exec": [
{ "match": "echo dedicated", "stdout": "dedicated" },
{ "match": "@s.whatsapp.net", "stdout": "15559998888@s.whatsapp.net" }
],
"stepFields": { "PHONE": "15550001111" }
},
{
"name": "shared-bails-to-dedicated",
"inputs": {
"number_mode": "shared",
"shared_confirm": "dedicated",
"auth_method": "qr",
"chat_phone": "15559998888",
"agent_name": "Nano"
},
"exec": [
{ "match": "echo dedicated", "stdout": "dedicated" },
{ "match": "@s.whatsapp.net", "stdout": "15559998888@s.whatsapp.net" }
],
"stepFields": { "PHONE": "15550001111" }
}
]
}
+1 -1
View File
@@ -82,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
--agent-name "${AGENT_NAME}"
```
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
The new group is created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To put it on a different provider, switch after creation with `ncl groups config update --id <group-id> --provider <name>`. Add `--welcome "System instruction: ..."` to override the default welcome prompt.
The script:
1. Upserts the `users` row and grants `owner` role if no owner exists.
+1 -1
View File
@@ -110,7 +110,7 @@ The `register` step creates the agent group (reusing it if the folder already ex
Omitted engage/policy fields default from the channel adapter's declaration (see "Channel Defaults" above). Optional overrides: `--trigger "<regex>"` (explicit engage pattern), `--engage-mode <pattern|mention|mention-sticky>`, `--is-group <true|false>`, `--unknown-sender-policy <strict|request_approval|public>`. Don't pick a mention mode on a channel whose declaration says `mentions: 'never'` — it can never engage there.
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
New agent groups are created on the instance default provider (`DEFAULT_AGENT_PROVIDER` in `.env`, or `claude` when unset). To run a group on a different provider, switch it after creation with `ncl groups config update --provider <name>` (e.g. `codex`).
For separate agents, also ask for a folder name and optionally a different assistant name.
+2 -2
View File
@@ -233,7 +233,7 @@ Parse the diff output for lines that contain `[BREAKING]` anywhere in the line.
```
If no `[BREAKING]` lines are found:
- Skip this step silently. Proceed to Step 7 (skill updates check).
- Skip this step silently. Proceed to Step 7.
If one or more `[BREAKING]` lines are found:
- Display a warning header to the user: "This update includes breaking changes that may require action:"
@@ -244,7 +244,7 @@ If one or more `[BREAKING]` lines are found:
- "Skip — I'll handle these manually"
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
- After all selected skills complete (or if user chose Skip), proceed to Step 7.
# Step 7: Skill updates (part of updating NanoClaw)
-43
View File
@@ -1,43 +0,0 @@
name: Bump version
on:
push:
branches: [main]
paths: ['src/**', 'container/**']
jobs:
bump-version:
if: github.repository == 'nanocoai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@v4
- name: Bump patch version
run: |
# Skip the auto-bump when the pushed commits already changed the
# version themselves (e.g. a release PR that set a minor/major).
# Otherwise the bot would patch a deliberate 2.1.0 up to 2.1.1.
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qx 'package.json'; then
echo "package.json already changed in this push; skipping auto-bump."
exit 0
fi
pnpm version patch --no-git-tag-version
git add package.json
git diff --cached --quiet && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
VERSION=$(node -p "require('./package.json').version")
git commit -m "chore: bump version to $VERSION"
git pull --rebase
git push
-43
View File
@@ -1,43 +0,0 @@
name: Update token count
on:
workflow_dispatch:
push:
branches: [main]
paths: ['src/**', 'container/**', 'launchd/**', 'CLAUDE.md']
jobs:
update-tokens:
if: github.repository == 'nanocoai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: ./repo-tokens
id: tokens
with:
include: 'src/**/*.ts container/agent-runner/src/**/*.ts container/Dockerfile container/build.sh launchd/com.nanoclaw.plist CLAUDE.md'
exclude: 'src/**/*.test.ts'
badge-path: 'repo-tokens/badge.svg'
- name: Commit if changed
run: |
git add README.md repo-tokens/badge.svg
git diff --cached --quiet && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "docs: update token count to ${{ steps.tokens.outputs.badge }}"
git pull --rebase
git push
+10 -3
View File
@@ -4,18 +4,25 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **One approval contract across every hold.** All approval holds now share one hold-record contract (approver rule, dedup key) and ONE click-authorization rule (`mayResolve` in `src/modules/approvals/approver-rule.ts`), replacing three divergent click-auth copies. Module, sender-admission, and OneCLI holds persist that shape in `pending_approvals` (migration 020, with backfills); channel registration keeps its multi-step continuation table and exposes the same shape at the lifecycle boundary. Unknown-sender admission uses a master/detail model: its sessionless `sender_admit` master owns lifecycle/auth/dedup, while `pending_sender_approval_details` (migration 022) stores the structured messaging group, sender identity/name, and replay event. Master and detail are inserted atomically, detail cascades with its master, and the migration preserves already-pending cards from both the legacy standalone table and earlier feature-branch master rows. The read-only `approval_holds` view makes `ncl approvals list|get` one query across every pending stack, including structured `messaging_group_id`, `subject_user_id`, and `subject_name` fields. Observers see the full actionable lifecycle with zero touch points inside the flows: `registerApprovalRequestedHandler` (the creation-side sibling of `registerApprovalResolvedHandler`) fires once after successful card delivery from `requestApproval`, the OneCLI credential bridge, or channel registration (as a synthesized hold view), and every resolution announces through the approval-resolved observer (outcome `approve` | `reject` | `expire` | `sweep`, session nullable). This is a behavior-preserving refactor: unifying the three flows changes no click-authorization decision versus today.
- [BREAKING] **Channel install skills are now the single source of truth.** The setup wizard installs channels by applying the same `/add-<channel>` SKILL.md a coding agent would follow — a deterministic engine executes the skill's mechanical steps directly from the document, so wizard and skill can never drift, and anything the engine can't do falls back to an agent reading the prose. **Migration:** the bespoke non-interactive channel installers (`setup/add-<channel>.sh`, `setup/install-<channel>.sh`) and the per-channel wizard flows (`setup/channels/<channel>.ts`) are deleted — anything that shelled out to them should apply the skill instead: interactively via the coding agent (`/add-<channel>`) or the setup wizard, or programmatically (see [docs/skill-directives.md](docs/skill-directives.md) for how skills are applied without a human).
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes one decision function — `guard()` in `src/guard/` — before it executes: `allow`, `hold` (the existing approval flows), or `deny`. Today's checks are preserved verbatim as each action's decision; ncl commands and delivery actions cannot register without a guard, and approved replays re-enter carrying the approval row as a **grant** (the forgeable `approved: true` boolean is deleted) with the checks re-run live. Two deliberate outcome changes: **(a)** approving a held a2a message after its destination was revoked no longer delivers it — the requester is told "approved, but not delivered" and the host logs a warning; **(b)** a forged, already-consumed, or mismatched grant refuses the replay instead of executing.
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws instead of silently disarming the guard.
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` copy them in (the setup wizard drives the same skills) — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **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] **Explicit destinations and one-door task delivery.** Every `send_message` and `send_file` call now requires a named `to` destination; there is no single-destination or reply-in-place shortcut. In task sessions, only explicitly addressed tool calls deliver, while final output becomes the automatic run summary and `ncl tasks append-log` adds optional progress notes. Existing task DBs need no schema migration; legacy generated task instructions are normalized when read. **Migration:** rebuild the agent image, restart NanoClaw, update custom instructions that omit `to`, and clear or compact existing sessions.
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes one decision function — `guard()` in `src/guard/` — before it executes: `allow`, `hold` (the existing approval flows), or `deny`. Today's checks are preserved verbatim as each action's decision; ncl commands and delivery actions cannot register without a guard, and approved replays re-enter carrying the approval row as a **grant** (the forgeable `approved: true` boolean is deleted) with the checks re-run live. Two deliberate outcome changes: **(a)** approving a held a2a message after its destination was revoked no longer delivers it — the requester is told "approved, but not delivered" and the host logs a warning; **(b)** a forged, already-consumed, or mismatched grant refuses the replay instead of executing.
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws instead of silently disarming the guard.
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **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.
- **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.
- [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.
- [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 dependency pins in the `/add-<channel>` SKILL.mds 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.
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
- **New groups inherit an instance-wide default provider.** `DEFAULT_AGENT_PROVIDER` in `.env` (default `claude`) sets which provider newly created agent groups get at creation; provider stays a per-group DB property, overridable via `ncl groups config update --provider` + restart. Existing groups are untouched — no migration, no retroactive flips.
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
+8 -2
View File
@@ -69,7 +69,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) define each action's decision; ncl commands + delivery actions demand a guard at registration; approved replays carry the approval row as a grant and re-run the checks. Conformance test: `src/guard/conformance.test.ts` |
| `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/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry, hold-lifecycle observers (`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` — every stack announces creation + resolution through these) |
| `src/modules/approvals/approver-rule.ts` | `mayResolve` — the one click-authorization rule (approver rule `exclusive` \| `admins-of-scope`) for every hold |
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
@@ -85,6 +86,9 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` install with their channel) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `scripts/skill-apply.ts` | Deterministic SKILL.md applier — executes `nc:` directive fences; declare/emit core, journaled + idempotent |
| `scripts/skill-directives.ts` + `scripts/skill-policy.ts` | `nc:` grammar parser + lint; UI-free driver policy derived from document structure (gate confirm, URL offer) |
| `setup/lib/skill-driver.ts` + `setup/channels/run-channel-skill.ts` | Setup wizard's skill consumer: clack rendering of engine events + the generic channel-install flow |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
@@ -122,7 +126,7 @@ Trunk does not ship any specific channel adapter or non-default agent provider.
- **`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.
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. Channel skills carry these steps as `nc:` directive fences: setup applies them via the engine (`scripts/skill-apply.ts`), an agent applies the prose — same install either way. See [docs/skill-directives.md](docs/skill-directives.md).
**Channel defaults.** Each adapter declares its wiring-time defaults (`ChannelDefaults`: per DM/group context — engage mode/pattern, thread policy, unknown-sender policy — plus mention signaling). Exactly two levels: the adapter declaration, and the per-wiring override chosen at creation — no per-instance DB config table. Undeclared (stale) adapters resolve through a behavior-faithful fallback, so a trunk update alone changes nothing. See [docs/api-details.md](docs/api-details.md#channel-defaults) and `src/channels/channel-defaults.ts`.
@@ -293,6 +297,8 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
| [docs/skill-directives.md](docs/skill-directives.md) | `nc:` directive reference: fence grammar, the eight kinds, effects, guards, lint |
| [docs/skill-engine-seam.md](docs/skill-engine-seam.md) | Skill-engine consumer contract (wizard / pipeline / agent-relay) + boundary-rule rationale |
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
## Container Build Cache
+3 -1
View File
@@ -34,6 +34,8 @@ NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) — m
Every user should have clean and minimal code that does exactly what they need. Skills let users selectively add features to their fork without inheriting code for features they don't want.
A skill is a self-contained add-on: a `SKILL.md` with the apply steps written as prose a coding agent can run, plus whatever the skill carries (code files, tests, a `REMOVE.md` that reverses every change apply made — required exactly when apply leaves anything behind). A fork tracks its customizations as a **recipe** of skills, which is what keeps upgrades cheap. [docs/skills-model.md](docs/skills-model.md) explains the whole model — recipes, tests, upgrades; [docs/skill-guidelines.md](docs/skill-guidelines.md) is the authoring checklist.
### Skill types
#### 1. Channel and provider skills (registry branches)
@@ -53,7 +55,7 @@ Add a messaging channel or an agent provider. The SKILL.md contains the install
**Contributing a channel or provider skill:**
1. Fork `nanocoai/nanoclaw` and branch from `main`
2. Build the adapter following [docs/skill-guidelines.md](docs/skill-guidelines.md): a self-registering module, one appended barrel import, and a registration test that imports the real barrel
3. Add a SKILL.md in `.claude/skills/<name>/` with the fetch-and-copy steps, and a REMOVE.md that reverses every change
3. Add a SKILL.md in `.claude/skills/<name>/` with the fetch-and-copy steps, and a REMOVE.md that reverses every change. Plain prose steps are all that's required. A skill with a credential prompt or an interactive step should include a `## Troubleshooting` section.
4. Open a PR. We'll land the code on the registry branch from your work
See `/add-slack` for a good example. See [docs/skills-model.md](docs/skills-model.md) for why install is a fetch, never a merge.
+1 -1
View File
@@ -78,7 +78,7 @@ 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 executed by the agent, which can message you the results
- **Scheduled tasks**: recurring jobs executed by the agent, with optional [script gates](docs/scheduled-tasks.md) that avoid waking it when there is no work
- **Web access** — search and fetch content from the web
- **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.
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { buildCompactInstructions } from './compact-instructions.js';
describe('compaction delivery reminder', () => {
it('preserves final-output addressing in chat sessions', () => {
const instructions = buildCompactInstructions(['family'], null);
expect(instructions).toContain('<message to="name">');
expect(instructions).toContain('`family`');
});
it('preserves explicit-tool delivery in task sessions without teaching final-output blocks', () => {
const instructions = buildCompactInstructions(['family'], 'daily-digest-a1b2');
expect(instructions).toContain('send_message');
expect(instructions).toContain('explicit to destination');
expect(instructions).toContain('tasks/daily-digest-a1b2.md');
expect(instructions).not.toContain('<message to="name">');
});
});
@@ -10,25 +10,42 @@
* "command": "bun /app/src/compact-instructions.ts"
*/
import { getAllDestinations } from './destinations.js';
import { getTaskSeriesId } from './db/session-routing.js';
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name);
export function buildCompactInstructions(names: string[], taskId: string | null): string {
const deliveryReminder = taskId
? [
' "This is an isolated task run. If you need to send the user a message, use send_message with an explicit to destination.',
` Final output is not delivered; it becomes the automatic summary in tasks/${taskId}.md.`,
` Available destinations: ${formatDestinationNames(names)}."`,
]
: [
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${formatDestinationNames(names)}."`,
];
const instructions = [
'Preserve the following in the compaction summary:',
'',
'1. For recent messages, keep the full XML structure including all attributes:',
' - <message from="..." sender="..." time="..."> for chat messages',
' - <task from="..." time="..."> for scheduled tasks',
' - <webhook from="..." source="..." event="..."> for webhooks',
' The message content can be summarized if long, but the XML tags and attributes must remain.',
'',
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. At the END of the compaction summary, include this verbatim reminder:',
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
];
return [
'Preserve the following in the compaction summary:',
'',
'1. For recent messages, keep the full XML structure including all attributes:',
' - <message from="..." sender="..." time="..."> for chat messages',
' - <task from="..." time="..."> for scheduled tasks',
' - <webhook from="..." source="..." event="..."> for webhooks',
' The message content can be summarized if long, but the XML tags and attributes must remain.',
'',
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. At the END of the compaction summary, include this verbatim reminder:',
...deliveryReminder,
].join('\n');
}
console.log(instructions.join('\n'));
function formatDestinationNames(names: string[]): string {
return names.length > 0 ? names.map((name) => `\`${name}\``).join(', ') : '(none)';
}
if (import.meta.main) {
const names = getAllDestinations().map((destination) => destination.name);
console.log(buildCompactInstructions(names, getTaskSeriesId()));
}
@@ -142,22 +142,3 @@ 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;
}
@@ -1,12 +1,9 @@
/**
* Default reply routing for this session written by the host on every
* Current chat/thread routing for this session written by the host on every
* container wake (see src/session-manager.ts `writeSessionRouting`).
*
* Read by the MCP tools as the default destination for outbound messages
* when the agent doesn't specify an explicit `to`. This is what makes
* "agent replies in the thread it's currently in" work: the router strips
* or preserves thread_id based on the adapter's thread support, and we
* just read the fixed routing the host committed for this session.
* Read by MCP tools to preserve the current thread when an explicitly named
* destination resolves to the chat this session is bound to.
*/
import { getInboundDb } from './connection.js';
@@ -19,12 +16,20 @@ export interface SessionRouting {
export function getSessionRouting(): SessionRouting {
const db = getInboundDb();
try {
const row = db
.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1')
.get() as SessionRouting | undefined;
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
| SessionRouting
| undefined;
if (row) return row;
} catch {
// Table may not exist on an older session DB — fall through to defaults
// Table may not exist on an older session DB — fall through to defaults.
}
return { channel_type: null, platform_id: null, thread_id: null };
}
const TASK_THREAD_PREFIX = 'system:tasks:';
/** The task id encoded in this isolated task session's canonical thread id. */
export function getTaskSeriesId(): string | null {
const threadId = getSessionRouting().thread_id;
return threadId?.startsWith(TASK_THREAD_PREFIX) ? threadId.slice(TASK_THREAD_PREFIX.length) : null;
}
@@ -60,4 +60,17 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('`casa`');
});
it('gives task sessions only explicit-tool delivery instructions', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa', { kind: 'task', taskId: 'daily-briefing-a25c' });
expect(prompt).toContain('isolated task run');
expect(prompt).toContain('send_message({ to: "name"');
expect(prompt).toContain('tasks/daily-briefing-a25c.md');
expect(prompt).toContain('Only notify someone when the task asks');
expect(prompt).not.toContain('<message to=');
expect(prompt).not.toContain('default to addressing');
});
});
+25 -13
View File
@@ -21,6 +21,8 @@ export interface DestinationEntry {
agentGroupId?: string;
}
export type SessionMode = { kind: 'chat' } | { kind: 'task'; taskId: string };
interface DestRow {
name: string;
display_name: string | null;
@@ -79,31 +81,26 @@ export function findByRouting(
* per-agent-group and changes when the operator renames an agent, while
* the shared base is identical across all agents.
*/
export function buildSystemPromptAddendum(assistantName?: string): string {
export function buildSystemPromptAddendum(assistantName?: string, mode: SessionMode = { kind: 'chat' }): string {
const sections: string[] = [];
if (assistantName) {
sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n'));
}
sections.push(buildDestinationsSection());
sections.push(buildDestinationsSection(mode));
return sections.join('\n\n');
}
function buildDestinationsSection(): string {
function buildDestinationsSection(mode: SessionMode): string {
const all = getAllDestinations();
const lines = ['## Sending messages', ''];
if (all.length === 0) {
return [
'## Sending messages',
'',
'You currently have no configured destinations. You cannot send messages until an admin wires one up.',
].join('\n');
}
const lines = ['## Sending messages', ''];
if (all.length === 1) {
lines.push('You currently have no configured destinations. You cannot send messages until an admin wires one up.');
if (mode.kind === 'chat') return lines.join('\n');
} else if (all.length === 1) {
const d = all[0];
lines.push(`Your destination is \`${d.name}\`${destinationLabel(d)}.`);
} else {
@@ -112,7 +109,18 @@ function buildDestinationsSection(): string {
lines.push(`- \`${d.name}\`${destinationLabel(d)}`);
}
}
lines.push('');
if (mode.kind === 'task') {
lines.push(
'This is an isolated task run with no attached chat. Only notify someone when the task asks you to. For a user-visible message, call `send_message({ to: "name", text: "..." })`; for a file, call `send_file` with `to`. Always pass the explicit named destination.',
'',
`Your final output is not sent to the user. End with a concise work-log summary. It is recorded automatically in \`tasks/${mode.taskId}.md\`. Read that file when you need context from earlier runs. Use \`ncl tasks append-log --msg "…"\` only for optional mid-run notes.`,
);
return lines.join('\n');
}
lines.push(
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
);
@@ -122,7 +130,11 @@ function buildDestinationsSection(): string {
);
lines.push('');
lines.push(
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Always pass its explicit `to` destination. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
);
lines.push('');
lines.push(
'For a short turn, do not narrate. For longer work, send one acknowledgment and then updates only at meaningful milestones, especially before slow operations. Never narrate micro-steps; finish with the outcome, not a play-by-play.',
);
return lines.join('\n');
}
+33 -1
View File
@@ -13,7 +13,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
import { getPendingMessages } from './db/messages-in.js';
import { formatMessages, stripInternalTags } from './formatter.js';
import { formatMessages, stripInternalTags, stripLegacyTaskContract } from './formatter.js';
import { TIMEZONE, formatLocalTime } from './timezone.js';
beforeEach(() => {
@@ -62,6 +62,38 @@ describe('context timezone header', () => {
});
});
describe('task prompt compatibility', () => {
it('strips the generated #2981 delivery suffix without mutating stored data', () => {
const prompt =
'Send the daily digest\n\n' +
'[A task serves the user two separate ways — legacy generated delivery instructions]';
expect(stripLegacyTaskContract(prompt)).toBe('Send the daily digest');
});
it('strips the generated #2988 delivery suffix', () => {
const prompt = 'Check the feeds\n\n[Task delivery contract:\nlegacy generated instructions]';
expect(stripLegacyTaskContract(prompt)).toBe('Check the feeds');
});
it('leaves ordinary user prompts unchanged', () => {
const prompt = 'Explain [Task delivery contract:] as plain text';
expect(stripLegacyTaskContract(prompt)).toBe(prompt);
});
it('does not expose a legacy delivery contract in a formatted task run', () => {
insertMessage('task-1', 'task', {
prompt: 'Check the feeds\n\n[Task delivery contract:\nlegacy generated instructions]',
});
const result = formatMessages(getPendingMessages());
expect(result).toContain('Instructions:\nCheck the feeds');
expect(result).not.toContain('legacy generated instructions');
});
});
describe('multi-message chat batches', () => {
// Regression guard for #2555: an outer `<messages>` envelope around
// multiple chat messages caused the Claude Agent SDK to emit a synthetic
+27 -7
View File
@@ -98,11 +98,10 @@ 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;
/** Batch is a task run. One-door delivery: only an explicitly addressed tool
* delivers from a task session; final-text `<message to>` blocks are inert
* and the final text auto-appends to the series run log. */
taskRun: boolean;
}
/**
@@ -116,7 +115,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'),
taskRun: messages.length > 0 && messages.every((m) => m.kind === 'task'),
};
}
@@ -209,10 +208,31 @@ function formatTaskMessage(msg: MessageInRow): string {
if (content.scriptOutput) {
parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), '');
}
parts.push('Instructions:', content.prompt || '');
parts.push('Instructions:', stripLegacyTaskContract(content.prompt || ''));
return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
}
const LEGACY_TASK_CONTRACT_MARKERS = [
'\n\n[A task serves the user two separate ways —',
'\n\n[Task delivery contract:',
];
/**
* PR #2981 persisted its generated delivery contract inside each task prompt.
* New sessions receive the contract from their runtime system prompt instead.
* Strip only a known generated suffix, at read time, so existing task rows stay
* compatible without a session-DB migration or contradictory model guidance.
*/
export function stripLegacyTaskContract(prompt: string): string {
if (!prompt.trimEnd().endsWith(']')) return prompt;
let contractStart = -1;
for (const marker of LEGACY_TASK_CONTRACT_MARKERS) {
contractStart = Math.max(contractStart, prompt.lastIndexOf(marker));
}
return contractStart >= 0 ? prompt.slice(0, contractStart).trimEnd() : prompt;
}
function formatWebhookMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const source = content.source || 'unknown';
+6 -1
View File
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
import { loadConfig } from './config.js';
import { buildSystemPromptAddendum } from './destinations.js';
import { getTaskSeriesId } from './db/session-routing.js';
import { ensureMemoryScaffold } from './memory-scaffold.js';
// Providers barrel — each enabled provider self-registers on import.
// Provider skills append imports to providers/index.ts.
@@ -52,7 +53,11 @@ async function main(): Promise<void> {
// /workspace/agent/CLAUDE.md — the composed entry imports the shared
// base (/app/CLAUDE.md) and each enabled module's fragment. Per-group
// memory lives in /workspace/agent/CLAUDE.local.md (auto-loaded).
const instructions = buildSystemPromptAddendum(config.assistantName || undefined);
const taskId = getTaskSeriesId();
const instructions = buildSystemPromptAddendum(
config.assistantName || undefined,
taskId ? { kind: 'task', taskId } : { kind: 'chat' },
);
// Discover additional directories mounted at /workspace/extra/*
const additionalDirectories: string[] = [];
@@ -34,7 +34,7 @@ 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).
- **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. Each task run auto-logs its final text to the run log; `ncl tasks append-log --msg "…"` is for extra mid-run notes (host-timestamped, not a message).
- **Answering questions about the system** — query `ncl` rather than guessing.
### Access rules
@@ -67,9 +67,9 @@ 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 local 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"
# Add an optional progress note during a task run. The final response is logged automatically; the host stamps the local time.
# This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task run --id is auto-derived.
ncl tasks append-log --msg "one feed returned 403; continuing with the remaining feeds"
# Write commands (approval required)
ncl groups restart
@@ -1,22 +1,10 @@
## Sending messages
## Outbound tools
**Every response** must be wrapped in `<message to="name">...</message>` blocks — even if you only have one destination. Bare text outside of `<message>` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names.
### Mid-turn updates (`send_message`)
Use the `mcp__nanoclaw__send_message` tool to send a message while you're still working (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
- **Short turn (≤2 quick tool calls):** Don't narrate. Output any response.
- **Longer turn (multiple tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it, checking the logs now") so the user knows you got the message.
- **Long-running turns (long-running tasks with many stages):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
**Outcomes, not play-by-play.** When the turn is done, the final message should be about the result, not a transcript of what you did.
The runtime system prompt lists your destinations and explains how final output is handled in this session. Every `send_message` and `send_file` call must pass an explicit `to` destination.
### Sending files (`send_file`)
Use `mcp__nanoclaw__send_file({ path, text?, filename?, to? })` to deliver a file from your workspace. `path` is absolute or relative to `/workspace/agent/`; `filename` overrides the display name shown in chat (defaults to the file's basename); `text` is an optional accompanying message. Use this for artifacts you produce (charts, PDFs, generated images, reports) rather than dumping contents into chat.
Use `mcp__nanoclaw__send_file({ to, path, text?, filename? })` to deliver a file from your workspace. `path` is absolute or relative to `/workspace/agent/`; `filename` overrides the display name shown in chat (defaults to the file's basename); `text` is an optional accompanying message. Use this for artifacts you produce (charts, PDFs, generated images, reports) rather than dumping contents into chat.
### Reacting to messages (`add_reaction`)
+14 -35
View File
@@ -41,39 +41,14 @@ function destinationList(): string {
/**
* Resolve a destination name to routing fields.
*
* If `to` is omitted, use the session's default reply routing (channel +
* thread the conversation is in) the agent replies in place.
*
* If `to` is specified, look up the named destination. If it resolves to
* Look up the explicitly named destination. If it resolves to
* the same channel the session is bound to, the session's thread_id is
* preserved so replies land in the correct thread. Otherwise thread_id
* is null (a cross-destination send starts a new conversation).
*/
function resolveRouting(
to: string | undefined,
to: string,
): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } {
if (!to) {
// Default: reply to whatever thread/channel this session is bound to.
const session = getSessionRouting();
if (session.channel_type && session.platform_id) {
return {
channel_type: session.channel_type,
platform_id: session.platform_id,
thread_id: session.thread_id,
resolvedName: '(current conversation)',
};
}
// No session routing (e.g., agent-shared or internal-only agent) —
// fall back to the legacy single-destination shortcut.
const all = getAllDestinations();
if (all.length === 0) return { error: 'No destinations configured.' };
if (all.length > 1) {
return {
error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`,
};
}
to = all[0].name;
}
const dest = findByName(to);
if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` };
if (dest.type === 'channel') {
@@ -95,24 +70,26 @@ function resolveRouting(
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
description: 'Send a message to a named destination.',
inputSchema: {
type: 'object' as const,
properties: {
to: {
type: 'string',
description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.',
description: 'Destination name (e.g., "family", "worker-1").',
},
text: { type: 'string', description: 'Message content' },
},
required: ['text'],
required: ['to', 'text'],
},
},
async handler(args) {
const to = args.to as string;
const text = args.text as string;
if (!to) return err(`to is required. Options: ${destinationList()}`);
if (!text) return err('text is required');
const routing = resolveRouting(args.to as string | undefined);
const routing = resolveRouting(to);
if ('error' in routing) return err(routing.error);
const id = generateId();
@@ -134,23 +111,25 @@ export const sendMessage: McpToolDefinition = {
export const sendFile: McpToolDefinition = {
tool: {
name: 'send_file',
description: 'Send a file to a named destination. If you have only one destination, you can omit `to`.',
description: 'Send a file to a named destination.',
inputSchema: {
type: 'object' as const,
properties: {
to: { type: 'string', description: 'Destination name. Optional if you have only one destination.' },
to: { type: 'string', description: 'Destination name.' },
path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' },
text: { type: 'string', description: 'Optional accompanying message' },
filename: { type: 'string', description: 'Display name (default: basename of path)' },
},
required: ['path'],
required: ['to', 'path'],
},
},
async handler(args) {
const to = args.to as string;
const filePath = args.path as string;
if (!to) return err(`to is required. Options: ${destinationList()}`);
if (!filePath) return err('path is required');
const routing = resolveRouting(args.to as string | undefined);
const routing = resolveRouting(to);
if ('error' in routing) return err(routing.error);
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
@@ -1,13 +1,13 @@
## Task scheduling (`ncl tasks`)
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", ... })`.
Use `ncl tasks` for one-shot and recurring tasks. Each task runs in its own isolated session. Its runtime prompt supplies the task-only delivery and run-log contract.
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>`.
Common commands:
```bash
ncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"
ncl tasks create --name "ping" --prompt "Remind the user 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)
@@ -454,3 +454,89 @@ describe('isCorruptionError', () => {
expect(isCorruptionError('')).toBe(false);
});
});
// --- Task-run turn wiring: the REAL processQuery path (one-door) ---
// These drive the actual call sites (autoAppendTaskLog at result-handling,
// shouldNudgeTaskBlocks gating, and follow-up turn reset). Deleting the wiring
// — not just the helpers — goes red here.
const TASK_ROUTING = {
platformId: null,
channelType: null,
threadId: 'system:tasks:ser-1',
inReplyTo: 't1',
taskRun: true,
};
function taskLogRows(): Array<{ text: string }> {
return (
getOutboundDb()
.prepare("SELECT content FROM messages_out WHERE kind = 'task_log' ORDER BY seq")
.all() as Array<{ content: string }>
).map((r) => JSON.parse(r.content) as { text: string });
}
describe('task-run turn wiring (real processQuery)', () => {
it('auto-appends the final text as a task_log row', async () => {
async function* events(): AsyncGenerator<ProviderEvent> {
yield { type: 'init', continuation: 's1' };
yield { type: 'result', text: 'checked feeds — nothing new' };
}
const query: AgentQuery = { push: () => {}, end: () => {}, events: events(), abort: () => {} };
await processQuery(query, TASK_ROUTING, ['t1'], 'claude', undefined, 'prompt', undefined);
const logs = taskLogRows();
expect(logs).toHaveLength(1);
expect(logs[0].text).toBe('checked feeds — nothing new');
// and nothing was delivered as chat
expect(getUndeliveredMessages().filter((m) => m.kind === 'chat')).toHaveLength(0);
});
it('logs and conditionally nudges a second task run in the same open query', async () => {
const pushes: string[] = [];
async function* events(): AsyncGenerator<ProviderEvent> {
yield { type: 'init', continuation: 's1' };
// Turn 1 uses the legacy wrong door and consumes its one correction.
yield { type: 'result', text: '<message to="local-cli">fire one result</message>' };
yield { type: 'result', text: 'first delivery decision handled' };
// A SECOND task run lands while the query is open — the follow-up poller
// pushes it and must reset the per-turn correction state.
insertMessage('t2', 'task', { prompt: 'fire two' });
const deadline = Date.now() + 5000;
while (!pushes.some((p) => p.includes('fire two')) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
}
// Turn 2 repeats the mistake. This receives a second independent nudge
// only if the follow-up path reset taskBlockNudged.
yield { type: 'result', text: '<message to="local-cli">fire two result</message>' };
yield { type: 'result', text: 'second delivery decision handled' };
}
const query: AgentQuery = {
push: (m: string) => {
pushes.push(m);
},
end: () => {},
events: events(),
abort: () => {},
};
await processQuery(query, TASK_ROUTING, ['t1'], 'claude', undefined, 'prompt', undefined);
const nudges = pushes.filter((p) => p.includes('If and only if'));
expect(nudges).toHaveLength(2);
expect(nudges[0]).toContain('fire one result');
expect(nudges[1]).toContain('fire two result');
const logs = taskLogRows().map((l) => l.text);
expect(logs).toHaveLength(2);
expect(logs[0]).toContain('[undelivered → local-cli] fire one result');
expect(logs[1]).toContain('[undelivered → local-cli] fire two result');
expect(logs).not.toContain('first delivery decision handled');
expect(logs).not.toContain('second delivery decision handled');
});
});
+126 -24
View File
@@ -1,6 +1,12 @@
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, markScriptSkipped, type MessageInRow } from './db/messages-in.js';
import { hasIdenticalSend, writeMessageOut } from './db/messages-out.js';
import {
getPendingMessages,
markProcessing,
markCompleted,
markScriptSkipped,
type MessageInRow,
} from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import {
clearContinuation,
@@ -279,6 +285,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
thread_id: routing.threadId,
content: JSON.stringify({ text: `Error: ${errMsg}` }),
});
// The batch is still acked completed below (no redelivery). Without
// this line the only log trace of the errored turn is "Query error"
// followed by a "Completed" line that reads like success.
log(`Errored batch will be acked completed — ${processingIds.length} message(s), no redelivery`);
} finally {
clearCurrentInReplyTo();
}
@@ -340,6 +351,9 @@ export async function processQuery(
let queryContinuation: string | undefined;
let done = false;
let unwrappedNudged = false;
// Once-per-turn guard for the task-run "<message> block was not delivered"
// nudge — mirrors unwrappedNudged for chat turns.
let taskBlockNudged = false;
// Prompt queue for the exchange hook — each result event consumes the
// oldest unanswered prompt, except a wrapping-retry result, which answers
// the same prompt again. Unused (and unmaintained) when the provider
@@ -423,6 +437,7 @@ export async function processQuery(
const prompt = formatMessages(keep);
log(`Pushing ${keep.length} follow-up message(s) into active query`);
unwrappedNudged = false;
taskBlockNudged = false;
query.push(prompt);
archivePrompts.push(prompt);
markCompleted(keptIds);
@@ -487,8 +502,15 @@ export async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
if (sent === 0 && event.isError === true) {
const { sent, hasUnwrapped, taskBlocks } = dispatchResultText(event.text, routing);
const willRetryTaskBlocks = shouldNudgeTaskBlocks(routing.taskRun, taskBlocks, taskBlockNudged);
// One-door task delivery: the final text becomes the run log entry
// while explicit append-log calls remain optional additive notes.
// Errors included: a failed run's text belongs in its log, not chat.
// A corrective retry handles delivery only; its result is not a
// second run summary.
if (routing.taskRun && !taskBlockNudged) autoAppendTaskLog(event.text);
if (sent === 0 && event.isError === true && !routing.taskRun) {
// Non-retryable error turn (e.g. a 403 billing_error) with no
// <message> envelope: deliver the notice instead of dropping it as
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
@@ -507,7 +529,7 @@ export async function processQuery(
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
status: hasUnwrapped || willRetryTaskBlocks ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
@@ -520,13 +542,19 @@ export async function processQuery(
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
if (willRetryTaskBlocks) {
taskBlockNudged = true;
const names = getAllDestinations()
.map((d) => d.name)
.join(', ');
query.push(buildTaskBlockNudge(taskBlocks, names));
}
// A retry result (wrapping or task-block nudge) answers the SAME
// user prompt — keep it queued so the retry archives against it,
// not the nudge text.
if (!willRetryWrapping && !willRetryTaskBlocks) archivePrompts.shift();
}
} else {
archivePrompts.shift();
}
} else archivePrompts.shift();
}
}
} catch (err) {
@@ -605,11 +633,22 @@ function deliverErrorResult(text: string, routing: RoutingContext): void {
* The agent must always wrap output in <message to="name">...</message>
* blocks, even with a single destination. Bare text is scratchpad only.
*/
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
export interface TaskMessageBlock {
to: string;
body: string;
}
export function dispatchResultText(
text: string,
routing: RoutingContext,
): { sent: number; hasUnwrapped: boolean; taskBlocks: TaskMessageBlock[] } {
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
let match: RegExpExecArray | null;
let sent = 0;
// <message to> blocks left inert in a task run — drives the same-turn
// "use send_message" nudge in processQuery.
const taskBlocks: TaskMessageBlock[] = [];
let lastIndex = 0;
const scratchpadParts: string[] = [];
@@ -621,6 +660,18 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
const body = match[2].trim();
lastIndex = MESSAGE_RE.lastIndex;
// One-door delivery in task sessions: only the send_message tool delivers.
// A final-text <message to> block here is either an echo of a tool send the
// agent already made (the double-delivery class) or a send down the wrong
// path — never deliver it, keep it visible in the scratchpad/run log.
if (routing.taskRun) {
log(`Task run: <message to="${toName}"> block not delivered — task sessions send only via explicit tools`);
scratchpadParts.push(
`[not delivered — task sessions send only via the send_message tool; to="${toName}"] ${body}`,
);
taskBlocks.push({ to: toName, body });
continue;
}
const dest = findByName(toName);
if (!dest) {
log(`Unknown destination in <message to="${toName}">, dropping block`);
@@ -640,25 +691,76 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
}
const hasUnwrapped = sent === 0 && !!scratchpad;
// In a task run, plain final text is the NORMAL ending (it becomes the run
// log) — never treat it as an undelivered reply or nudge the agent to wrap it.
const hasUnwrapped = !routing.taskRun && sent === 0 && !!scratchpad;
if (hasUnwrapped) {
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
}
return { sent, hasUnwrapped };
return { sent, hasUnwrapped, taskBlocks };
}
/**
* Should this task-run result get the same-turn "your <message> block was
* not delivered use send_message" nudge? True at most once per turn
* (mirrors the unwrappedNudged flag for chat turns).
*/
export function shouldNudgeTaskBlocks(
taskRun: boolean,
taskBlocks: TaskMessageBlock[],
alreadyNudged: boolean,
): boolean {
return taskRun && taskBlocks.length > 0 && !alreadyNudged;
}
export function buildTaskBlockNudge(taskBlocks: TaskMessageBlock[], destinationNames: string): string {
const blocks = taskBlocks
.map(
({ to, body }) =>
`<undelivered_message to="${escapePromptXml(to)}">${escapePromptXml(body)}</undelivered_message>`,
)
.join('\n');
return (
'<system>The final-output content below was not delivered from this task run:\n' +
`${blocks}\n` +
'If and only if any of it still needs to be sent, call send_message with an explicit to destination. ' +
'If it was already sent or no notification is required, do not send it again. ' +
`Your destinations: ${escapePromptXml(destinationNames)}. ` +
'The original task result is already recorded in the run log; do not repeat it.</system>'
);
}
function escapePromptXml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Task runs: the final text is the automatic run summary. Explicit
* `ncl tasks append-log` calls are additive mid-run notes. Written as a
* `task_log` outbound row; the host appends it to the series' tasks/<id>.md
* with its usual timestamp stamp. Never delivered to anyone.
*/
export function autoAppendTaskLog(text: string): void {
// Run-log hygiene: an inert <message to> block never belongs in the log as
// raw XML — replace each with its inner text, marked undelivered, so the
// log stays readable prose.
const prose = text.replace(
/<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g,
(_m, to: string, body: string) => `[undelivered → ${to}] ${body.trim()}`,
);
const line = stripInternalTags(prose).replace(/\s+/g, ' ').trim().slice(0, 500);
if (!line) return;
writeMessageOut({
id: generateId(),
kind: 'task_log',
content: JSON.stringify({ text: line }),
});
log('Task run log auto-appended from final text');
}
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
@@ -666,7 +768,7 @@ function sendToDestination(dest: DestinationEntry, body: string, routing: Routin
const destRouting = resolveDestinationThread(channelType, platformId);
writeMessageOut({
id: generateId(),
in_reply_to: destRouting?.inReplyTo ?? (routing.taskFire ? null : routing.inReplyTo),
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
@@ -0,0 +1,227 @@
/**
* One-door delivery in task sessions.
*
* Every outbound tool call names its destination. In a task run, final output
* is inert delivery-wise and becomes the automatic run summary instead.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { closeSessionDb, getInboundDb, getOutboundDb, initTestSessionDb } from './db/connection.js';
import { getUndeliveredMessages, writeMessageOut } from './db/messages-out.js';
import { getTaskSeriesId } from './db/session-routing.js';
import { sendFile, sendMessage } from './mcp-tools/core.js';
import { autoAppendTaskLog, buildTaskBlockNudge, dispatchResultText, shouldNudgeTaskBlocks } from './poll-loop.js';
import type { RoutingContext } from './formatter.js';
function seedSessionRouting(channelType: string | null, platformId: string | null, threadId: string | null): void {
const db = getInboundDb();
db.exec(`CREATE TABLE IF NOT EXISTS session_routing (
id INTEGER PRIMARY KEY CHECK (id = 1),
channel_type TEXT, platform_id TEXT, thread_id TEXT
)`);
db.prepare(
'INSERT OR REPLACE INTO session_routing (id, channel_type, platform_id, thread_id) VALUES (1, ?, ?, ?)',
).run(channelType, platformId, threadId);
}
function seedDestination(name = 'family', channelType = 'telegram', platformId = 'telegram:99'): void {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES (?, ?, 'channel', ?, ?, NULL)`,
)
.run(name, name, channelType, platformId);
}
const taskRouting: RoutingContext = {
platformId: 'ag-1',
channelType: 'agent',
threadId: 'system:tasks:daily-digest-a1b2',
inReplyTo: 'run-1',
taskRun: true,
};
beforeEach(() => {
initTestSessionDb();
seedDestination();
});
afterEach(() => {
closeSessionDb();
});
describe('explicit outbound destinations', () => {
it('derives task mode from the canonical per-series thread without a DB migration', () => {
seedSessionRouting(null, null, 'system:tasks:daily-digest-a1b2');
expect(getTaskSeriesId()).toBe('daily-digest-a1b2');
seedSessionRouting('telegram', 'telegram:99', 'chat-thread');
expect(getTaskSeriesId()).toBeNull();
});
it('requires `to` in both outbound tool schemas', () => {
expect(sendMessage.tool.inputSchema.required).toContain('to');
expect(sendFile.tool.inputSchema.required).toContain('to');
});
it('never infers the only destination when `to` is omitted', async () => {
const messageResult = (await sendMessage.handler({ text: 'hello' })) as {
isError?: boolean;
content: { text: string }[];
};
const fileResult = (await sendFile.handler({ path: 'report.txt' })) as {
isError?: boolean;
content: { text: string }[];
};
for (const result of [messageResult, fileResult]) {
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('to is required');
expect(result.content[0].text).toContain('family');
}
expect(getUndeliveredMessages()).toHaveLength(0);
});
it('rejects an unknown explicit destination without falling back', async () => {
const messageResult = (await sendMessage.handler({ to: 'missing', text: 'hello' })) as {
isError?: boolean;
content: { text: string }[];
};
const fileResult = (await sendFile.handler({ to: 'missing', path: 'report.txt' })) as {
isError?: boolean;
content: { text: string }[];
};
for (const result of [messageResult, fileResult]) {
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Unknown destination "missing"');
expect(result.content[0].text).toContain('Known: family');
}
expect(getUndeliveredMessages()).toHaveLength(0);
});
it('delivers to the explicitly named destination', async () => {
seedSessionRouting(null, null, 'system:tasks:daily-digest-a1b2');
await sendMessage.handler({ to: 'family', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('telegram:99');
});
it('preserves the current thread for an explicitly named matching destination', async () => {
seedDestination('current-chat', 'discord', 'channel:1');
seedSessionRouting('discord', 'channel:1', 'thread-7');
await sendMessage.handler({ to: 'current-chat', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('channel:1');
expect(out[0].thread_id).toBe('thread-7');
});
});
describe('final-output blocks in a task run', () => {
it('keeps them inert and returns their destination and content for correction', () => {
const { sent, hasUnwrapped, taskBlocks } = dispatchResultText(
'<message to="family">digest is ready</message>',
taskRouting,
);
expect(sent).toBe(0);
expect(hasUnwrapped).toBe(false);
expect(taskBlocks).toEqual([{ to: 'family', body: 'digest is ready' }]);
expect(getUndeliveredMessages()).toHaveLength(0);
});
it('still delivers final-output blocks in chat sessions', () => {
const { sent, taskBlocks } = dispatchResultText('<message to="family">hi</message>', {
...taskRouting,
taskRun: false,
});
expect(sent).toBe(1);
expect(taskBlocks).toEqual([]);
expect(getUndeliveredMessages()).toHaveLength(1);
});
it('nudges at most once and only when a task result contains inert blocks', () => {
const blocks = [{ to: 'family', body: 'digest' }];
expect(shouldNudgeTaskBlocks(true, blocks, false)).toBe(true);
expect(shouldNudgeTaskBlocks(true, blocks, true)).toBe(false);
expect(shouldNudgeTaskBlocks(true, [], false)).toBe(false);
expect(shouldNudgeTaskBlocks(false, blocks, false)).toBe(false);
});
it('shows the exact content and makes re-send conditional', () => {
const nudge = buildTaskBlockNudge([{ to: 'family', body: '3 <new> posts & a warning' }], 'family, ops');
expect(nudge).toContain('to="family"');
expect(nudge).toContain('3 &lt;new&gt; posts &amp; a warning');
expect(nudge).toContain('If and only if');
expect(nudge).toContain('do not send it again');
expect(nudge).not.toContain('Re-send now');
});
it('records the original task result once, not the correction retry', () => {
let nudged = false;
const original = '<message to="family">digest</message>';
const first = dispatchResultText(original, taskRouting);
if (!nudged) autoAppendTaskLog(original);
nudged = shouldNudgeTaskBlocks(true, first.taskBlocks, nudged);
const retry = dispatchResultText('Delivery decision handled.', taskRouting);
if (!nudged) autoAppendTaskLog('Delivery decision handled.');
expect(shouldNudgeTaskBlocks(true, retry.taskBlocks, nudged)).toBe(false);
const rows = getOutboundDb().prepare("SELECT content FROM messages_out WHERE kind = 'task_log'").all() as {
content: string;
}[];
expect(rows).toHaveLength(1);
expect(JSON.parse(rows[0].content).text).toContain('[undelivered → family] digest');
});
});
describe('automatic task run summary', () => {
it('writes a task_log row from final text', () => {
autoAppendTaskLog('Checked the\nfeeds — nothing new.');
const rows = getOutboundDb().prepare("SELECT kind, content FROM messages_out WHERE kind = 'task_log'").all() as {
kind: string;
content: string;
}[];
expect(rows).toHaveLength(1);
expect(JSON.parse(rows[0].content).text).toBe('Checked the feeds — nothing new.');
});
it('marks legacy final-output blocks undelivered and never stores raw XML', () => {
autoAppendTaskLog('Digest done. <message to="family">3 new posts today</message> See you tomorrow.');
const row = getOutboundDb().prepare("SELECT content FROM messages_out WHERE kind = 'task_log'").get() as {
content: string;
};
const line = JSON.parse(row.content).text as string;
expect(line).not.toContain('<message');
expect(line).toContain('[undelivered → family] 3 new posts today');
expect(line).toContain('Digest done.');
});
it('is additive to an explicit append-log request', () => {
writeMessageOut({
id: 'cli-progress',
kind: 'system',
content: JSON.stringify({
action: 'cli_request',
requestId: 'cli-progress',
command: 'tasks-append-log',
args: { msg: 'progress note' },
}),
});
autoAppendTaskLog('final summary');
expect(getOutboundDb().prepare("SELECT 1 FROM messages_out WHERE kind = 'task_log'").all()).toHaveLength(1);
});
});
+33 -19
View File
@@ -206,9 +206,10 @@ Access layer: `src/db/agent-destinations.ts`.
### 1.11 `pending_approvals`
Two workflows share this table:
The common approval primitive, sender admission, and OneCLI credential bridge share this table:
- **Session-bound MCP approvals**`install_packages`, `add_mcp_server`. `session_id` is set.
- **Session-bound approvals**CLI mutations, self-modification, agent creation, and a2a sends. `session_id` is set.
- **Sender admission** — sessionless `sender_admit` rows with an in-flight `dedup_key`.
- **OneCLI credential approvals**`session_id` may be NULL; `agent_group_id` + `channel_type` + `platform_id` route the admin card.
```sql
@@ -226,14 +227,20 @@ CREATE TABLE pending_approvals (
expires_at TEXT,
status TEXT NOT NULL DEFAULT 'pending',
title TEXT NOT NULL DEFAULT '',
options_json TEXT NOT NULL DEFAULT '[]'
options_json TEXT NOT NULL DEFAULT '[]',
approver_user_id TEXT,
approver_rule TEXT NOT NULL DEFAULT 'admins-of-scope',
dedup_key TEXT
);
CREATE INDEX idx_pending_approvals_action_status ON pending_approvals(action, status);
CREATE UNIQUE INDEX idx_pending_approvals_dedup
ON pending_approvals(dedup_key) WHERE dedup_key IS NOT NULL;
```
- `status`: `pending` | `approved` | `rejected` | `expired`.
- `status`: `pending` | `approved` | `rejected` | `expired` | `awaiting_reason`.
- `approver_rule`: `exclusive` names exactly one resolver in `approver_user_id`; `admins-of-scope` uses the anchored group's admin chain and may also record the card recipient.
- `platform_message_id` lets the host edit the admin card in place after a decision.
- Access layer: `src/db/sessions.ts`; sweep + delivery: `src/onecli-approvals.ts`.
- Access layer: `src/db/sessions.ts`; lifecycle contract: `src/modules/approvals/primitive.ts`; sweep + delivery: `src/modules/approvals/onecli-approvals.ts`.
### 1.12 `unregistered_senders`
@@ -327,30 +334,26 @@ 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`
### 1.16 `pending_sender_approval_details`
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.
Action-specific detail beneath a sender admission's sessionless `pending_approvals` master (`action = 'sender_admit'`). The master is the only lifecycle record: it owns status, authorization, approver delivery metadata, timestamps, and generic dedup. This child stores fields needed to query the affected actor/channel and replay the held message. It is created in the same transaction as its master and cannot outlive it.
```sql
CREATE TABLE pending_sender_approvals (
id TEXT PRIMARY KEY,
CREATE TABLE pending_sender_approval_details (
approval_id TEXT PRIMARY KEY REFERENCES pending_approvals(approval_id) ON DELETE CASCADE,
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.
Migration 011 originally introduced a standalone `pending_sender_approvals` lifecycle table. Migration 020 converts its live rows into common masters and removes it; migration 022 creates this child and backfills details from the master payload snapshot. The payload remains a self-contained lifecycle/audit event, while this table is the structured sender query and continuation interface.
- 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`
- Access layer: `src/modules/permissions/db/pending-sender-approval-details.ts`
- **Writer:** `src/modules/permissions/sender-approval.ts`, through `requestApproval`'s atomic persistence callback
- **Readers:** sender approval continuation and the `approval_holds` view
### 1.17 `pending_channel_approvals`
@@ -392,6 +395,13 @@ CREATE TABLE agent_message_policies (
- 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.
### 1.19 `approval_holds` (view)
Read-only union of `pending_approvals` and synthesized `pending_channel_approvals` rows. Sender masters left-join their detail row so `messaging_group_id`, `subject_user_id`, and `subject_name` are first-class query columns; channel registration supplies its messaging group through the same column. This is the operator-facing answer to “what is pending now?” used by `ncl approvals list|get`.
- Migrations: `021-approval-holds-view.ts`, extended by `022-sender-approval-details.ts`
- Reader: `src/cli/resources/approvals.ts`
---
## 2. Migration system
@@ -417,14 +427,18 @@ Several early migrations were later renamed/retired and replaced by "module" fil
| 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) |
| 11 | `pending-sender-approvals` | `011-pending-sender-approvals.ts` | Legacy standalone sender approval table (converted and removed by migration 020) |
| 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` |
| 13 | `approval-render-metadata` | `013-approval-render-metadata.ts` | `title`, `options_json` columns on channel approvals and the legacy sender approval table |
| 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 |
| 19 | `wiring-threads-override` | `019-wiring-threads.ts` | Per-wiring thread-policy override |
| 20 | `holds-approver-rule` | `020-holds-approver-rule.ts` | Common approver rule + dedup key on `pending_approvals`; folds sender holds into the common primitive |
| 21 | `approval-holds-view` | `021-approval-holds-view.ts` | Read-only `approval_holds` union for all pending hold shapes |
| 22 | `sender-approval-details` | `022-sender-approval-details.ts` | One-to-one sender continuation/query detail beneath the common approval master; extends `approval_holds` with structured subject fields |
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
+4 -1
View File
@@ -105,7 +105,10 @@ These rules are enforced by convention in `src/session-manager.ts` and `containe
| `sessions` | central | `src/db/sessions.ts`, `src/session-manager.ts` | delivery, sweep, container runner |
| `pending_questions` | central | `src/db/sessions.ts` (via `ask_user_question`) | container response matcher |
| `agent_destinations` | central | `src/db/agent-destinations.ts`, migration 004 backfill | `writeDestinations()`, delivery ACL |
| `pending_approvals` | central | `src/db/sessions.ts`, `src/onecli-approvals.ts` | admin-card delivery, sweep |
| `pending_approvals` | central | `src/db/sessions.ts`, `src/modules/approvals/onecli-approvals.ts` | common approval lifecycle, admin-card delivery, sweep |
| `pending_sender_approval_details` | central | permissions sender-admission flow | sender continuation and structured pending-hold queries |
| `pending_channel_approvals` | central | permissions channel-registration flow | multi-step registration continuation |
| `approval_holds` (view) | central | migrations (read-only union) | `ncl approvals list|get` |
| `unregistered_senders` | central | `src/db/dropped-messages.ts` | ops tooling |
| `chat_sdk_*` | central | `src/state-sqlite.ts` | Chat SDK bridge |
| `schema_version` | central | `src/db/migrations/index.ts` | migration runner |
+167
View File
@@ -0,0 +1,167 @@
# Scheduled Tasks
Scheduled tasks run an agent prompt at a future time or on a recurring cron
schedule. Each task belongs to an agent group and runs in its own system
session, separate from normal chat sessions.
Run `ncl tasks create --help` for the complete and current CLI reference.
## Create a recurring task
From the host, pass the agent group that should own the task:
```bash
ncl tasks create \
--group <agent-group-id> \
--name "weekday briefing" \
--recurrence "0 9 * * 1-5" \
--prompt "Prepare the weekday briefing and send it to telegram"
```
The first run is calculated from the cron schedule. Cron expressions use the
NanoClaw installation timezone.
Inside an agent container, `--group` is filled in automatically with that
agent's group.
## Create a one-time task
One-time tasks use `--process-after` instead of `--recurrence`:
```bash
ncl tasks create \
--group <agent-group-id> \
--name "call reminder" \
--process-after "2026-07-14T18:00:00+03:00" \
--prompt "Remind me to call Dana"
```
`--process-after` accepts an ISO 8601 timestamp or a local time interpreted in
the installation timezone.
## Delivery and run logs
A scheduled task has no chat attached to it. If its result should reach a
user, the prompt must tell the agent where to send it. Use a destination name
available to that agent, such as `telegram` or `team-slack`.
NanoClaw also asks the agent to append a short work-log entry after each agent
run. View run counts, failures, and recent log entries with:
```bash
ncl tasks get <task-id> --group <agent-group-id>
```
## Manage and test tasks
```bash
ncl tasks list --group <agent-group-id>
ncl tasks update <task-id> --group <agent-group-id> --prompt "New prompt"
ncl tasks pause <task-id> --group <agent-group-id>
ncl tasks resume <task-id> --group <agent-group-id>
ncl tasks cancel <task-id> --group <agent-group-id>
ncl tasks delete <task-id> --group <agent-group-id>
```
`cancel` stops the live task but keeps its history. `delete` permanently removes
the whole task series and its history.
To test a task immediately without changing its schedule:
```bash
ncl tasks run <task-id> --group <agent-group-id>
```
`run` also works while a task is paused. It queues one extra run and does not
resume the recurring schedule.
## Script gates
A task can run a Bash script before waking the agent. This is useful for
frequent checks where most runs have nothing for the agent to do.
The script's last line of standard output must be JSON:
```json
{ "wakeAgent": false }
```
or:
```json
{ "wakeAgent": true, "data": { "alerts": 2 } }
```
- `wakeAgent: false` completes the run without calling the model.
- `wakeAgent: true` wakes the agent and adds `data` to its prompt.
Scripts run with Bash, a 30-second timeout, and a 1 MB output limit. The JSON
decision must be the final line written to standard output. Keep `data` small
and include only what the agent needs.
For example, save this as `check-marker.sh`:
```bash
marker=/workspace/agent/wake-next-task
if [ -f "$marker" ]; then
rm -f "$marker"
echo '{"wakeAgent": true, "data": {"reason": "marker found"}}'
else
echo '{"wakeAgent": false}'
fi
```
Test it before scheduling, then pass its contents to `ncl`:
```bash
bash check-marker.sh
ncl tasks create \
--group <agent-group-id> \
--name "marker check" \
--recurrence "*/15 * * * *" \
--prompt "Handle the condition reported by the script" \
--script "$(cat check-marker.sh)"
```
Store state that must survive between runs under `/workspace/agent`, the agent
group workspace.
Avoid putting secrets directly in task scripts. Prefer runtime credential
injection through OneCLI so credentials are not stored in the task definition.
## Frequency limit
An ungated recurring task that would fire more than four times in the next 24
hours is rejected. A task with a script gate is allowed to run more often
because `wakeAgent: false` uses no model tokens.
For an intentionally frequent task that has no script, see the explicit
override in `ncl tasks create --help` and confirm the token and quota cost
before using it.
## Script failures
A timeout, nonzero exit, missing decision, or invalid JSON counts as a failed
run. Consecutive failures delay the next recurring run by 2, 4, 8, 16, 32,
then 60 minutes. Further failures stay at the 60-minute delay.
After eight consecutive failures, NanoClaw pauses the series and writes the
reason to its run log. Fix the script, test it, then resume the task:
```bash
ncl tasks resume <task-id> --group <agent-group-id>
```
A valid `wakeAgent: false` decision is a successful run. It does not trigger
failure backoff.
## Template tasks
Agent templates can include recurring tasks and optional script gates. Template
tasks are created paused so installing a template never starts background work
without approval. See [Agent Templates](templates.md#recurring-tasks).
For implementation details, see
[Pre-Agent Scripts](agent-runner-details.md#pre-agent-scripts-tasks).
+14 -1
View File
@@ -183,6 +183,18 @@ 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.
## Channel installs are skill-driven
There are no per-channel setup scripts. Each channel's `/add-<channel>`
SKILL.md is the single source of truth: its `nc:` directive fences carry the
mechanical steps ([skill-directives.md](skill-directives.md)), and setup
applies them in-process — `runChannelSkill` in `setup/auto.ts` → the skill
driver → the directive engine. The bespoke non-interactive installers
(`setup/add-<channel>.sh` / `setup/install-<channel>.sh`) no longer exist;
the non-interactive path is the same document, with prompt vars pre-supplied
programmatically (`applySkill`'s `inputs`, or the `NC_INPUT_<VAR>` env
convention — [skill-engine-seam.md](skill-engine-seam.md) §6).
## File reference
| File | Role |
@@ -193,7 +205,8 @@ leaking the token to disk outweighs the debugging value.
| `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/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
| `setup/lib/skill-driver.ts` | Generic skill runner: applies a SKILL.md's `nc:` directives via the engine (`scripts/skill-apply.ts`) and renders its events through clack — prompts via `resolveInput`, spinners for step events, operator notes with the gate/URL-offer policy from `scripts/skill-policy.ts`. Owns all prompting/gating presentation; the engine only declares and emits. |
| `setup/channels/run-channel-skill.ts` | The generic channel-install entry: drives the channel's `/add-<channel>` SKILL.md through the skill driver, then owns the shared wire (agent name + operator role → `scripts/init-first-agent.ts`). One flow for every channel — no bespoke per-channel code. |
| `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
+1 -1
View File
@@ -42,7 +42,7 @@ Each adapter declares its own wiring-time defaults (`ChannelDefaults`, see [api-
1. **Adapter declaration** — a static const in the adapter module (and its `ChannelRegistration`, so offline scripts resolve it without credentials). Adapters are skill-installed and user-owned; install-wide changes mean editing the adapter copy. No DB config table.
2. **Per-wiring override** — the explicit value chosen at creation (`ncl` flag, wizard answer, card-flow value), stored on the row. Existing rows are never re-resolved; declarations are consulted only at creation, except threading, which stays live via `messaging_group_agents.threads` (`NULL` = inherit).
All creation paths (`ncl wirings`/`messaging-groups`, `setup/register.ts`, the router's auto-create, the channel-approval card flow, bootstrap scripts) go through the shared helpers in `src/channels/channel-defaults.ts`, so a platform's defaults are declared once and apply everywhere.
All creation paths (`ncl wirings`/`messaging-groups`, `setup/register.ts`, the router's auto-create, the channel-approval card flow, the bootstrap scripts `scripts/init-first-agent.ts` / `scripts/init-cli-agent.ts`) go through the shared helpers in `src/channels/channel-defaults.ts`, so a platform's defaults are declared once and apply everywhere.
**Shared-identity pattern.** When the platform identity the adapter connects as belongs to a human (WhatsApp shared-number mode: `ASSISTANT_HAS_OWN_NUMBER` unset), the adapter itself suppresses mention signals — it never sets `isMention` and declares `mentions: 'never'`, group defaults of a name-pattern (`\b{name}\b`) and `strict` sender policy. With no mention signal, the router never auto-creates messaging groups or fires approval cards for the human's own conversations — the spam dies at the source, with zero core conditionals. The pattern is entirely channel-local and reusable by any adapter riding a personal identity (iMessage, Signal) with no core involvement.
+136
View File
@@ -0,0 +1,136 @@
# nc: skill directives — authoring reference
The structured skill format: how a SKILL.md carries its mechanical steps as machine-applicable `nc:` directive fences.
**Who this is for.** This format is core tooling for the trunk channel/provider install skills — the ones the setup wizard drives (`/add-slack`, `/add-telegram`, …). Those skills carry `nc:` fences so a deterministic engine (`scripts/skill-apply.ts`) and the setup wizard can apply them programmatically, and the conformance suite holds them to it. **A contributed skill does not need any of this.** Contributions are held to the standard bar in [skill-guidelines.md](skill-guidelines.md) — prose an agent can run, tests, REMOVE.md. Adopting `nc:` fences in a contributed skill is welcome but entirely optional; if you do, run the lint (below) and follow this reference.
Engineering source of truth: the header comment in [`scripts/skill-directives.ts`](../scripts/skill-directives.ts) (grammar + lint) — this document is its author-facing distillation. The engine's consumer contract (what a wizard, pipeline, or agent-relay plugs into) is [skill-engine-seam.md](skill-engine-seam.md).
## Two readers, one document
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. An agent applies the prose; a tool applies the directives; the two describe the same install.
Two invariants follow, and both are non-negotiable:
- **Prose-primary.** With the `nc:` fences stripped, the SKILL.md must read as a normal skill — a coding agent following only the prose performs the same install. The prose never mentions the apply engine, the setup wizard, or programmatic application; a skill narrating its own tooling breaks the very degradation path the format exists for.
- **Degrade-to-agent.** Anything the engine can't do (a step it doesn't understand, a failed command, a missing streaming exec for an interactive step) bounces to an agent task — never a crash, never a silent drop. The agent reads the surrounding prose and applies the step the way skills have always worked.
## Fence syntax
````
```nc:<kind> <arg>... [key:value]...
<body line(s)>
```
````
- `<kind>` is one of the eight directives below.
- Bare tokens after the kind are positional args (e.g. `prompt`'s variable name).
- `key:value` tokens are attributes.
- The body's meaning is per-kind.
`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).
## The eight kinds
Every directive is idempotent — apply is safe to re-run, per the skills model.
### `copy [from-branch:<b>]`
Body: one path per line — `PATH` (source == destination) or `SRC -> DST`. Copies the file in; with `from-branch:` the source is fetched from a registry branch (`git show origin/<b>:<path>`). **Idempotency: skip when every destination is present; when any is missing, all listed files are (re)copied — copying overwrites.**
### `append to:<file> [at:<marker>]`
Body: the line(s) to add. Without `at:`, appends at end of file. With `at:<marker>`, inserts before the `// <<< <marker>` closing line of a dormant marker region (see `setup/index.ts`). **Idempotency: skip if already present.**
### `dep [manager:pnpm]`
Body: `pkg@<exact-semver>` line(s). Exact pins only — ranges are a lint error. **Idempotency: reinstalling a present pin is a no-op.**
### `run [effect:<e>] [capture:<spec>] [validate:<re>]`
Body: shell command(s), with `{{vars}}` substituted in. **Idempotency: the command must be re-runnable** — that's the author's contract.
`effect:` classifies the command so consumers can reason about it:
| Effect | Meaning |
|---|---|
| `build` | Compile step (e.g. `pnpm run build`) |
| `test` | Verification run (e.g. the registration test) |
| `fetch` | Network read that resolves data (e.g. an API call resolving an id) |
| `external` | Invokes an external helper/tool outside the tree |
| `wire` | Runs `ncl …` to wire collected input. No undo — the rows it creates are user runtime data, not reversed on skill remove |
| `restart` | Restarts the service so following `ncl` runs reach it. A caller that owns the restart (a rebuild, or a setup that restarts once) skips it via `ApplyOptions.skipEffects` |
| `step` | A long-running, operator-interactive step (a pairing code, a QR device-link) run through the streaming exec: its `=== NANOCLAW SETUP: … ===` status blocks render to the operator live. Degrades to an agent when no streaming exec is wired |
| `check` | A shell **predicate** (a precondition gate): mutates nothing — no journal, no capture. Zero exit passes silently; non-zero 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 |
`capture:` binds command output into vars (the twin of `prompt`):
- `capture:<var>` — binds the command's stdout to `{{var}}`.
- `capture:<var>=<dot-path>[,<var2>=<dot-path2>…]` — parses 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.
- On an `effect:step`, `capture:<var>=<FIELD>[,…]` binds the terminal status block's named fields instead — the structured twin of stdout capture.
`validate:<re>` shape-guards each captured value (e.g. `validate:^discord:`); a mismatch bounces to an agent — a command's output has no human to re-prompt, unlike `prompt`.
### `prompt <var> [secret] [validate:<re>] [flags:<re-flags>] [normalize:<how>] [reuse:<ENV_KEY>]`
Body: the question to ask. Binds the answer to `{{var}}`. **Idempotency: skip if the var is already satisfied** (via `inputs` or an earlier bind).
- `secret` — consumers must mask the value.
- `validate:<re>` — a regex enforced **at bind** for **every** value, programmatic `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 pipeline passing a malformed value fails loudly. Encode minimum lengths in the regex (`validate:^.{20,}$`).
- `flags:<re-flags>` — regex flags for `validate` (e.g. `flags:i`).
- `normalize:trim|rstrip-slash|lower` — a deterministic transform applied at bind, *before* validate, for both `inputs` and interactive answers.
- `reuse:<ENV_KEY>` — lets a re-run offer an existing `.env` value for a credential a **helper script** owns (written by an `effect:external`, not by `nc:env-set`) — the masked reuse offer that the usual `env-set`→ENV_KEY inference can't see. Consumed by interactive drivers only.
### `operator`
Body: instructions for the human operator. **Output-only** — it mutates nothing.
The SKILL.md is addressed to the coding agent; `operator` delineates the parts meant for the *human* (e.g. clicking through the Slack admin UI). Lead into it with agent-facing prose like "Tell the user:" so an agent relays it; a tool renders the body to the operator with `{{vars}}` substituted in.
The block carries **no presentation attributes**. 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` line(s) (`{{var}}` allowed) written to `.env`. **Idempotency: set-if-absent** — an existing key is left alone.
### `json-merge into:<file> key:<field>`
Body: a JSON object. Reads an array-of-objects JSON file and pushes the body object unless an element already has `body[key] === element[key]`. **Idempotency: push-if-absent, keyed by `key:`.**
## `when:` guards
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. One skill can thus express mutually-exclusive branches (e.g. a local vs. remote install mode) in document order while still running fully programmatically from `inputs`.
## `{{var}}` substitution
`{{name}}` references a var bound by a `prompt` or a `capture:`. Substituted into `run` bodies, `env-set` values, and `operator` text. An unresolved `{{var}}` defers the directive (and its consumers) rather than executing with a hole in it.
## Retired attrs and kinds (lint errors)
These were removed deliberately; authoring them is a **lint error**, so stale skills fail loudly instead of silently no-oping:
| Retired | Where its job went |
|---|---|
| `prompt min:<n>` | Encode in the regex: `validate:^.{20,}$` |
| `prompt error:<msg>` | The miss message derives from the question prose — write questions that describe the expected shape |
| `operator open:<url>` | Put the URL in the body prose; consumers offer to open it |
| `operator gate` | Whether to pause is derived from document structure (`scripts/skill-policy.ts`) |
| `label:<word>` (any directive) | Step labels derive from the preceding heading |
| `on-fail:<hint>` (any directive) | The failure hint is always the surrounding prose |
| `nc:env-sync` (whole kind) | Retired — nothing read the mirror it wrote (and it copied live tokens); adapters read `.env` directly |
## Lint
```bash
pnpm exec tsx scripts/skill-directives.ts .claude/skills/<name>/SKILL.md
```
Errors block (malformed fences, unknown kinds, retired attrs, non-exact dep pins, undefined `{{vars}}`). Two special cases worth knowing: a `@chat-adapter/*` dep pin must match the `chat` core version in the lockfile (the family moves together), and there are two warn-only checks. **Gate ambiguity**: an unguarded `nc:operator` followed by `when:`-guarded directives spanning more than one branch value — guard the operator or restructure, so the barrier decision can't key off a runtime-skipped directive. **Reference floor**: a skill with a secret prompt or an interactive step should carry a `## Troubleshooting` section, the human floor a reader scrolls to when a live step misbehaves.
Applying without writing (plan mode): `pnpm exec tsx scripts/skill-apply.ts <skillDir>`.
## See also
- [skill-guidelines.md](skill-guidelines.md) — the authoring checklist every skill meets (fences or not)
- [skill-engine-seam.md](skill-engine-seam.md) — the engine's consumer contract: wizard, pipeline, agent-relay
- [skills-model.md](skills-model.md) — why skills work this way at all
+650
View File
@@ -0,0 +1,650 @@
# The skill-engine seam: declare/emit vs. acquire/present
Status: IMPLEMENTED on `feat/structured-skill-format` (clean break, no compat shims) — kept as the boundary-rule rationale and the consumer-contract reference. Author-facing directive grammar: [skill-directives.md](skill-directives.md).
## 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, implemented
at `scripts/skill-inputs.ts`) 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
*Historical — this plan was executed on `feat/structured-skill-format`; kept as the record of how the refactor landed.*
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)
*Historical — executed alongside §8; line numbers reference the pre-refactor tree.*
- **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.)
+15
View File
@@ -61,6 +61,21 @@ Fetching from a registry branch is **additive, never a merge**. `git fetch origi
---
## Structured apply (nc: directives)
The trunk channel/provider skills — the ones the setup wizard drives — carry their apply steps in a second, machine-applicable form: `nc:<kind>` directive fences that a deterministic engine (`scripts/skill-apply.ts`) executes. Each change shape above has a directive: add-a-file = `copy`, append = `append`, dependency = `dep`, JSON edit = `json-merge`, plus `run`, `prompt`, `operator`, and `env-set` for commands, inputs, and human steps. Grammar and idempotency semantics: [skill-directives.md](skill-directives.md); the engine's consumer contract: [skill-engine-seam.md](skill-engine-seam.md).
**Scope of the requirement.** For those core skills, the directives are the floor: the conformance suite (`scripts/skill-conformance.test.ts`) applies each fence-carrying skill programmatically and holds it to full application. **A contributed skill is held only to the checklist in this document** — prose apply steps, tests, REMOVE.md. Carrying `nc:` fences is optional; a skill that does opt in takes on the conformance rules below.
For a fence-carrying skill, conformance means:
- **Prose-primary.** With the fences stripped, the SKILL.md reads as a normal skill; an agent following only the prose performs the same install. The prose never mentions the apply engine, the setup wizard, or programmatic application — narrating the tooling breaks the degrade path.
- **Degrade-to-agent is the failure contract.** Anything the engine can't do bounces to an agent task that applies the surrounding prose — so every directive must sit beside prose that stands on its own.
- **The lint passes**: `pnpm exec tsx scripts/skill-directives.ts .claude/skills/<name>/SKILL.md`. Retired presentation attrs (`min:`/`error:`/`open:`/`gate`/`label:`/`on-fail:`) are errors; the reference floor — a `## Troubleshooting` section on any skill with a secret prompt or interactive step — is a warning worth honoring regardless of fences.
- **Directives are idempotent** by construction (`copy` overwrites, `append` skips-if-present, `env-set` sets-if-absent, …) — which is the same re-runnable-apply rule every skill already has.
---
## Integration points
The integration point is wherever the skill reaches into existing code. Make it **minimal, colocated, and self-contained**:
+2
View File
@@ -36,6 +36,8 @@ A skill carries everything it needs:
Apply must be safe to re-run. Upgrades re-run skills, so a skill that half-applies twice is a bug.
**Two readers, one document.** The trunk channel and provider installs that the setup wizard drives are written so the wizard can apply the same SKILL.md a coding agent would follow — anything the wizard can't do mechanically falls back to the agent. This is core tooling, not part of the model's contract: a plain-prose skill is a first-class skill. (Details: [skill-directives.md](skill-directives.md).)
## Two kinds of skills
- **Capability skills** add something new: a channel, a provider, a tool, a dashboard.
+57 -4
View File
@@ -1,12 +1,12 @@
# Agent Templates
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` at one and
carries the agent's standing instructions, its MCP tool servers, its skills,
and optional recurring tasks, 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. **Templates
Templates are purely additive and require no DB migration. **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 public registry
@@ -55,6 +55,7 @@ is optional and defaults sensibly:
│ └── *.md
├── .mcp.json # optional: MCP servers (command + args), NO secrets
├── skills/<name>/ # optional: one folder per skill (SKILL.md + any references/), copied whole
├── tasks/*.md # optional: recurring tasks, created paused
└── README.md # recommended: per-template docs
```
@@ -64,6 +65,7 @@ is optional and defaults sensibly:
| `context/**/*.md` (others) | Extra context, copied into the agent's workspace with the same layout relative to `instructions.md` | No |
| `.mcp.json``mcpServers` | MCP tool servers (written verbatim to container config) | No |
| `skills/<name>/` | A skill, auto-triggered by its `description` | No |
| `tasks/*.md` | Recurring scheduled tasks, created paused pending user activation | No |
Notes:
@@ -76,6 +78,56 @@ Notes:
- Skills are copied into the agent's own skills overlay, keyed to that group,
never shared across groups.
### Recurring tasks
Each immediate Markdown file under `tasks/` defines one recurring task. The
filename becomes its readable name, the frontmatter supplies its cron schedule,
an optional script can decide whether to wake the agent, and the Markdown body
is the prompt:
```markdown
---
schedule: "*/15 * * * *"
script: |
if [ -f /workspace/agent/wake-next-task ]; then
echo '{"wakeAgent": true}'
else
echo '{"wakeAgent": false}'
fi
---
Investigate the alerts reported by the script and notify me if they are serious.
```
`schedule` is required. `script` is optional and may be a single-line or
multiline YAML string. The frontmatter accepts no other fields, so typos cannot
silently change behavior. Task files are template input, like `.mcp.json`: they
are not copied into the agent workspace after stamping.
Template tasks use the same creation path as `ncl tasks create`, including cron
validation, the install timezone, first-run calculation, isolated task sessions,
the run-log prompt, script behavior, and frequency limits. Ungated tasks are
limited to four fires in the next 24 hours; tasks with a script gate may run more
often. Templates do not expose the dangerous frequency override or one-time
tasks.
The script is passed unchanged to NanoClaw's normal task creation and execution
path. See [Scheduled Tasks](scheduled-tasks.md#script-gates) for the script
contract, testing workflow, frequency limit, and failure behavior. Avoid putting
secrets directly in scripts; prefer runtime credential injection through OneCLI.
Tasks start **paused**, so stamping a template never starts background work
without user consent. Until the setup welcome flow offers activation, inspect
and enable them with the existing task CLI:
```bash
ncl tasks list --group <agent-group-id> --status paused
ncl tasks resume <task-id>
```
Resuming preserves NanoClaw's normal pause/resume semantics: if the stored next
run passed while paused, the task is eligible immediately.
### Referencing extra context files
Extra `.md` files under `context/` (by convention in an `additional_context/`
@@ -167,5 +219,6 @@ repo, not this one. To add one: fork that repo, drop a folder at
`<category>/<template>/` with at least `context/instructions.md`, test it end to
end (copy it under `templates/` and run
`ncl groups create --template <category>/<template> --name Test`), confirm
no secrets are committed, and open a PR. The repo's README has the full anatomy,
any predefined tasks appear under `ncl tasks list --status paused`, confirm no
secrets are committed, and open a PR. The repo's README has the full anatomy,
category conventions, and checklist.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.47",
"version": "2.1.53",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
@@ -33,7 +33,8 @@
"better-sqlite3": "11.10.0",
"chat": "4.29.0",
"cron-parser": "5.5.0",
"kleur": "^4.1.5"
"kleur": "^4.1.5",
"yaml": "^2.9.0"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
+18 -7
View File
@@ -29,6 +29,9 @@ importers:
kleur:
specifier: ^4.1.5
version: 4.1.5
yaml:
specifier: ^2.9.0
version: 2.9.0
devDependencies:
'@eslint/js':
specifier: ^9.35.0
@@ -65,7 +68,7 @@ importers:
version: 8.58.2(eslint@9.39.4)(typescript@5.9.3)
vitest:
specifier: ^4.0.18
version: 4.1.4(@types/node@22.19.17)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0))
version: 4.1.4(@types/node@22.19.17)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))
packages:
@@ -1493,6 +1496,11 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1867,13 +1875,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0))':
'@vitest/mocker@4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)
vite: 8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)
'@vitest/pretty-format@4.1.4':
dependencies:
@@ -2929,7 +2937,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0):
vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -2941,11 +2949,12 @@ snapshots:
esbuild: 0.27.7
fsevents: 2.3.3
tsx: 4.21.0
yaml: 2.9.0
vitest@4.1.4(@types/node@22.19.17)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)):
vitest@4.1.4(@types/node@22.19.17)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.4
'@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0))
'@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.4
'@vitest/runner': 4.1.4
'@vitest/snapshot': 4.1.4
@@ -2962,7 +2971,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)
vite: 8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.17
@@ -2982,6 +2991,8 @@ snapshots:
wrappy@1.0.2: {}
yaml@2.9.0: {}
yocto-queue@0.1.0: {}
zwitch@2.0.4: {}
+2 -2
View File
@@ -20,7 +20,7 @@ A GitHub Action that calculates the size of your codebase in terms of tokens and
This counts tokens using [tiktoken](https://github.com/openai/tiktoken) and writes the result between HTML comment markers in your README:
The badge color reflects what percentage of an LLMs context window the codebase fills (context window size is configurable, defaults to 200k which is the size of Claude Opus). Green for under 30%, yellow-green for 30%-50%, yellow for 50%-70%, red for 70%+.
The badge color reflects what percentage of an LLMs context window the codebase fills (context window size is configurable, defaults to 1M which is the size of current Claude models). Green for under 30%, yellow-green for 30%-50%, yellow for 50%-70%, red for 70%+.
## Why
@@ -92,7 +92,7 @@ The action replaces everything between the markers with the token count.
|-------|---------|-------------|
| `include` | *required* | Glob patterns for files to count (space-separated) |
| `exclude` | `''` | Glob patterns to exclude (space-separated) |
| `context-window` | `200000` | Context window size for percentage calculation |
| `context-window` | `1000000` | Context window size for percentage calculation |
| `readme` | `README.md` | Path to README file |
| `encoding` | `cl100k_base` | Tiktoken encoding name |
| `marker` | `token-count` | HTML comment marker name |
+1 -1
View File
@@ -12,7 +12,7 @@ inputs:
context-window:
description: 'Context window size for percentage calculation'
required: false
default: '200000'
default: '1000000'
readme:
description: 'Path to README file'
required: false
+5 -5
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="240k tokens, 120% of context window">
<title>240k tokens, 120% 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="226k tokens, 23% of context window">
<title>226k tokens, 23% 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"/>
@@ -10,13 +10,13 @@
<a xlink:href="https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens">
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#e05d44"/>
<rect x="52" width="38" height="20" fill="#4c1"/>
<rect width="90" height="20" fill="url(#s)"/>
<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">240k</text>
<text x="71" y="14">240k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">226k</text>
<text x="71" y="14">226k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Regenerate repo-tokens/badge.svg for this repo.
Counts the committed tree at HEAD (never the working tree, so local
uncommitted files can't skew the number) with the same formatting and
color logic as action.yml. Run from the repo root:
pip install tiktoken && python3 repo-tokens/recount.py
"""
import subprocess
import tiktoken
CONTEXT_WINDOW = 1_000_000
BADGE_PATH = "repo-tokens/badge.svg"
LINK_URL = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
def included(path: str) -> bool:
"""The system-understanding surface: host + agent-runner source (tests
excluded), the container build surface, the service definition, CLAUDE.md."""
if path.endswith(".test.ts"):
return False
if path.startswith("src/") and path.endswith(".ts"):
return True
if path.startswith("container/agent-runner/src/") and path.endswith(".ts"):
return True
return path in (
"container/Dockerfile",
"container/build.sh",
"launchd/com.nanoclaw.plist",
"CLAUDE.md",
)
def main() -> None:
tracked = subprocess.run(
["git", "ls-tree", "-r", "--name-only", "HEAD"],
capture_output=True, text=True, check=True,
).stdout.splitlines()
files = [f for f in tracked if included(f)]
enc = tiktoken.get_encoding("cl100k_base")
total = 0
for path in files:
content = subprocess.run(
["git", "show", f"HEAD:{path}"],
capture_output=True, text=True, errors="ignore", check=True,
).stdout
total += len(enc.encode(content))
if total >= 100000:
display = f"{round(total / 1000)}k"
elif total >= 1000:
display = f"{total / 1000:.1f}k"
else:
display = str(total)
pct = round(total / CONTEXT_WINDOW * 100)
print(f"Files: {len(files)}, Tokens: {total}, Badge: {display} tokens · {pct}% of context window")
label_text, value_text = "tokens", display
full_desc = f"{display} tokens, {pct}% of context window"
cw = 7.0
label_w = round(len(label_text) * cw) + 10
value_w = round(len(value_text) * cw) + 10
total_w = label_w + value_w
if pct < 30:
color = "#4c1"
elif pct < 50:
color = "#97ca00"
elif pct < 70:
color = "#dfb317"
else:
color = "#e05d44"
lx = label_w // 2
vx = label_w + value_w // 2
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total_w}" height="20" role="img" aria-label="{full_desc}">
<title>{full_desc}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="{total_w}" height="20" rx="3" fill="#fff"/>
</clipPath>
<a xlink:href="{LINK_URL}">
<g clip-path="url(#r)">
<rect width="{label_w}" height="20" fill="#555"/>
<rect x="{label_w}" width="{value_w}" height="20" fill="{color}"/>
<rect width="{total_w}" height="20" fill="url(#s)"/>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="{lx}" y="15" fill="#010101" fill-opacity=".3">{label_text}</text>
<text x="{lx}" y="14">{label_text}</text>
<text aria-hidden="true" x="{vx}" y="15" fill="#010101" fill-opacity=".3">{value_text}</text>
<text x="{vx}" y="14">{value_text}</text>
</g>
</g>
</a>
</svg>'''
with open(BADGE_PATH, "w", encoding="utf-8") as f:
f.write(svg)
print(f"Badge written to {BADGE_PATH}")
if __name__ == "__main__":
main()
+4 -5
View File
@@ -29,7 +29,6 @@ import {
} from '../src/channels/channel-defaults.js';
import { DATA_DIR } from '../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initDb } from '../src/db/connection.js';
import {
createMessagingGroup,
@@ -132,11 +131,11 @@ async function main(): Promise<void> {
`# ${args.agentName}\n\n` +
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
// The operator's setup pick (NANOCLAW_PICKED_PROVIDER) when set; otherwise
// undefined, so initGroupFilesystem falls back to the instance default and
// stamps it onto the fresh config row.
provider: pickedProvider,
});
// Runtime provider lives on the config row, not the deprecated agent_provider.
if (pickedProvider && pickedProvider !== 'claude') {
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
}
// 3. CLI messaging group + wiring.
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
+7 -9
View File
@@ -243,15 +243,13 @@ async function main(): Promise<void> {
} else {
console.log(`Reusing agent group: ${ag.id} (${folder})`);
}
// Ensure the config row exists; defer workspace scaffolding to the first
// spawn (group-init), where the DB-resolved provider decides the surface
// (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory scaffold)
// — so a non-Claude group never gets stale CLAUDE.* files written here.
ensureContainerConfig(ag.id);
// Runtime provider lives on the config row, not the deprecated agent_provider.
if (pickedProvider && pickedProvider !== 'claude') {
updateContainerConfigScalars(ag.id, { provider: pickedProvider });
}
// Seed the config row, stamped with the effective provider: the operator's
// setup pick (NANOCLAW_PICKED_PROVIDER) when this runs inside a setup run,
// otherwise the persisted instance default. Workspace scaffolding is deferred
// to the first spawn (group-init), where the DB-resolved provider decides the
// surface (Claude: CLAUDE.local.md; a surfaces-owning provider: the memory
// scaffold). A reused group keeps its provider (INSERT OR IGNORE).
ensureContainerConfig(ag.id, pickedProvider);
const groupDir = path.resolve(GROUPS_DIR, folder);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
File diff suppressed because it is too large Load Diff
+882
View File
@@ -0,0 +1,882 @@
// 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) {
// The shell redirect can't create parent directories, and the dest
// may not exist on trunk (e.g. container skills that live only on
// the channels branch). Mirror the local-copy path's mkdir.
mkdirSync(dirname(join(root, destOf(l))), { recursive: true });
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}`);
}
+342
View File
@@ -0,0 +1,342 @@
// CI conformance for programmatic skill apply.
//
// Auto-discovers every .claude/skills/*/SKILL.md that carries nc: directive
// fences (discovery-based, not a hardcoded list — a new fence-carrying skill is
// covered the day it lands) and, per skill:
//
// 1. parse + validate + warn-lints all clean (errors AND advisory warnings —
// in-tree skills must stay warning-free);
// 2. per branch-scenario from the colocated apply-fixtures.json, drives
// applySkill end-to-end with stubbed exec/execStream/resolveRemote in a
// scratch root and asserts a fully-programmatic green run: nothing
// deferred, nothing bounced to an agent, balanced step events;
// 3. every when:-guard value is exercised by at least one scenario (checked
// via ApplyResult.vars — guard vars are non-secret prompts/captures);
// 4. static effect-ordering invariants: code mutations → build → test, and
// restart only after build+test. NOTE deliberately NOT "restart last" —
// real skills restart BEFORE their pairing effect:step (the adapter must
// be live to pair) and may write env after;
// 5. the dynamic run-health gate: a failure injected at the first
// fetch/check/external run must block every later restart/step/wire
// (bounced to an agent, never executed);
// 6. fixture hygiene: scenario input keys ⊆ the skill's prompt vars, every
// unguarded prompt answered by every scenario, and a skill with prompts
// MUST ship a fixture file (actionable failure otherwise).
//
// Everything is stubbed — no network, no git, no pnpm add — so this runs in
// milliseconds inside the normal vitest CI step.
import { describe, it, expect, afterAll } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { applySkill, fullyApplied, type ApplyEvent, type ApplyResult } from './skill-apply.js';
import {
parseDirectives,
validate,
resolveChatCoreVersion,
promptVar,
lintGateAmbiguity,
lintReferenceFloor,
type Directive,
} from './skill-directives.js';
const ROOT = process.cwd();
const SKILLS_DIR = join(ROOT, '.claude/skills');
const CHAT_VERSION = resolveChatCoreVersion(ROOT);
// ---------------------------------------------------------------------------
// Discovery: every skill whose SKILL.md opens an nc: fence.
// ---------------------------------------------------------------------------
const SKILLS = readdirSync(SKILLS_DIR).filter((n) => {
const p = join(SKILLS_DIR, n, 'SKILL.md');
return existsSync(p) && /^```nc:/m.test(readFileSync(p, 'utf8'));
});
// ---------------------------------------------------------------------------
// Fixtures: .claude/skills/<name>/apply-fixtures.json, colocated so a skill
// edit and its fixture update land in one diff. Prompt-less skills fall back
// to a single empty default scenario.
// ---------------------------------------------------------------------------
interface ExecStub {
match: string; // substring of the (var-substituted) command
stdout: string;
}
interface Scenario {
name: string;
inputs?: Record<string, string>;
exec?: ExecStub[];
stepFields?: Record<string, string>; // effect:step terminal-block fields
}
interface Fixture {
notes?: string;
coverageExclude?: string[]; // "var=value" guards final-vars coverage can't see
coverageExcludeReason?: string;
scenarios: Scenario[];
}
function loadFixture(name: string): Fixture | undefined {
const p = join(SKILLS_DIR, name, 'apply-fixtures.json');
if (!existsSync(p)) return undefined;
return JSON.parse(readFileSync(p, 'utf8')) as Fixture;
}
// ---------------------------------------------------------------------------
// Scratch project root: every append/env-set/json-merge target a trunk skill
// writes to must pre-exist (appendFileSync creates files but not directories,
// and marker appends need the dormant marker region).
// ---------------------------------------------------------------------------
const scratchRoots: string[] = [];
afterAll(() => {
for (const r of scratchRoots) rmSync(r, { recursive: true, force: true });
});
function scratchRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'skill-conformance-'));
scratchRoots.push(root);
mkdirSync(join(root, 'src/channels'), { recursive: true });
mkdirSync(join(root, 'src/providers'), { recursive: true });
mkdirSync(join(root, 'container/agent-runner/src/providers'), { recursive: true });
mkdirSync(join(root, 'setup/providers'), { recursive: true });
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, 'src/channels/index.ts'), '// channel adapter barrel\n');
writeFileSync(join(root, 'src/providers/index.ts'), '// provider barrel\n');
writeFileSync(join(root, 'container/agent-runner/src/providers/index.ts'), '// container provider barrel\n');
writeFileSync(join(root, 'setup/providers/index.ts'), '// setup provider barrel\n');
writeFileSync(
join(root, 'setup/index.ts'),
['const STEPS = {', ' // >>> nanoclaw:setup-steps', ' // <<< nanoclaw:setup-steps', '};', ''].join('\n'),
);
writeFileSync(join(root, 'container/cli-tools.json'), '[]\n');
return root;
}
interface RunOutcome {
res: ApplyResult;
cmds: string[];
streamed: string[];
events: ApplyEvent[];
}
async function runScenario(skillDir: string, sc: Scenario): Promise<RunOutcome> {
const root = scratchRoot();
const cmds: string[] = [];
const streamed: string[] = [];
const events: ApplyEvent[] = [];
const res = await applySkill(skillDir, root, {
inputs: sc.inputs ?? {},
// resolveRemote MUST be injected: the default shells out to real
// `git remote` + `git ls-remote` — network in CI, nondeterministic on forks.
resolveRemote: () => 'origin',
exec: (c) => {
cmds.push(c);
return sc.exec?.find((e) => c.includes(e.match))?.stdout;
},
execStream: async (c) => {
streamed.push(c);
return { ok: true, fields: sc.stepFields ?? {} };
},
onEvent: (e) => void events.push(e),
});
return { res, cmds, streamed, events };
}
const isString = (x: unknown): x is string => typeof x === 'string';
const SIDE_EFFECTS = new Set(['restart', 'step', 'wire']);
const SABOTEUR_EFFECTS = new Set(['fetch', 'check', 'external']);
const isSideEffectRun = (d: Directive | undefined): boolean =>
d?.kind === 'run' && isString(d.attrs.effect) && SIDE_EFFECTS.has(d.attrs.effect);
// ---------------------------------------------------------------------------
describe('skill discovery', () => {
it('finds the fence-carrying skills', () => {
// Sanity floor: discovery walking the wrong directory (or the fence regex
// regressing) must fail loudly, not silently skip the whole suite.
expect(SKILLS).toContain('add-slack');
expect(SKILLS).toContain('add-whatsapp');
expect(SKILLS.length).toBeGreaterThanOrEqual(10);
});
});
describe.each(SKILLS)('%s', (name) => {
const dir = join(SKILLS_DIR, name);
const md = readFileSync(join(dir, 'SKILL.md'), 'utf8');
const directives = parseDirectives(md);
const byLine = new Map(directives.map((d) => [d.line, d]));
const promptVars = new Set(
directives.filter((d) => d.kind === 'prompt').map((d) => promptVar(d)).filter(isString),
);
const guards = [...new Set(directives.map((d) => d.attrs.when).filter(isString))];
const fixture = loadFixture(name);
const scenarios: Scenario[] = fixture?.scenarios ?? [{ name: 'default', inputs: {} }];
it('parses + validates + warn-lints clean', () => {
expect(validate(directives, { chatVersion: CHAT_VERSION })).toEqual([]);
// Advisory warnings too: in-tree skills must stay warning-free.
expect(lintGateAmbiguity(directives)).toEqual([]);
expect(lintReferenceFloor(md)).toEqual([]);
});
it('fixture hygiene: prompts have a fixture, inputs match prompt vars, unguarded prompts always answered', () => {
if (promptVars.size > 0) {
expect(
fixture,
`${name} declares nc:prompt directives but has no apply-fixtures.json — add .claude/skills/${name}/apply-fixtures.json with a "scenarios" array: shaped fake inputs per prompt var (satisfying each validate: regex), exec stubs (substring match → stdout) for every capture run, and stepFields for any effect:step`,
).toBeDefined();
}
const unguarded = directives
.filter((d) => d.kind === 'prompt' && !isString(d.attrs.when))
.map((d) => promptVar(d))
.filter(isString);
for (const sc of fixture?.scenarios ?? []) {
for (const k of Object.keys(sc.inputs ?? {})) {
expect(promptVars.has(k), `scenario "${sc.name}" supplies input "${k}" which is not a prompt var of ${name} — stale fixture?`).toBe(true);
}
for (const v of unguarded) {
expect(
sc.inputs?.[v],
`scenario "${sc.name}" is missing unguarded prompt var "${v}" — it would defer and the apply could never be fully programmatic`,
).toBeDefined();
}
}
// coverageExclude entries must reference guards that actually exist.
const guardSet = new Set(guards);
for (const g of fixture?.coverageExclude ?? []) {
expect(guardSet.has(g), `coverageExclude "${g}" is not a when:-guard of ${name} — stale exclusion?`).toBe(true);
}
});
it.each(scenarios)('applies fully programmatically: $name', async (sc) => {
const { res, events } = await runScenario(dir, sc);
expect(res.agentTasks).toEqual([]); // nothing degraded to an agent
expect(res.deferred).toEqual([]); // every prompt satisfied AND validate-at-bind passed
expect(fullyApplied(res)).toBe(true);
// Balanced step brackets, all green.
const starts = events.filter((e) => e.type === 'step-start');
const ends = events.filter((e): e is Extract<ApplyEvent, { type: 'step-end' }> => e.type === 'step-end');
expect(starts.length).toBe(ends.length);
expect(ends.every((e) => e.ok)).toBe(true);
// Every resolved non-secret var is non-empty — an empty capture means an
// exec/stepFields fixture entry is missing (bindCapture binds '' when the
// stub returned nothing and no validate: catches it).
for (const [k, v] of Object.entries(res.vars)) {
expect(v, `resolved {{${k}}} is empty in scenario "${sc.name}" — add/fix the exec or stepFields fixture entry answering that capture`).not.toBe('');
}
});
it('covers every when:-guard value across scenarios', async () => {
if (guards.length === 0) return;
const excluded = new Set(fixture?.coverageExclude ?? []);
const results: ApplyResult[] = [];
for (const sc of scenarios) results.push((await runScenario(dir, sc)).res);
for (const g of guards) {
if (excluded.has(g)) continue;
const eq = g.indexOf('=');
const [v, val] = [g.slice(0, eq), g.slice(eq + 1)];
expect(
results.some((r) => r.vars[v] === val),
`no scenario exercises when:${g} in ${name} — add a scenario to apply-fixtures.json (or coverageExclude it with a reason)`,
).toBe(true);
}
});
// Static, document-order invariants. Deliberately NOT "restart last":
// telegram/whatsapp/signal restart BEFORE their pairing effect:step (the
// adapter must be live to pair) and whatsapp writes env after the restart.
// What DOES hold: code mutations land before the build, the build runs
// before the tests, and a restart never precedes the build or the tests
// that validate what it would load.
it('effect ordering: mutations → build → test; restart only after build+test', () => {
const firstBuild = directives.findIndex((d) => d.kind === 'run' && d.attrs.effect === 'build');
const firstTest = directives.findIndex((d) => d.kind === 'run' && d.attrs.effect === 'test');
if (firstBuild >= 0 && firstTest >= 0) expect(firstBuild).toBeLessThan(firstTest);
if (firstBuild >= 0) {
directives.forEach((d, i) => {
if (['copy', 'append', 'dep', 'json-merge'].includes(d.kind)) {
expect(i, `${d.kind} at line ${d.line} lands after the build — the build would not see it`).toBeLessThan(firstBuild);
}
});
}
directives.forEach((d, i) => {
if (d.kind === 'run' && d.attrs.effect === 'restart') {
if (firstBuild >= 0) expect(i, `restart at line ${d.line} precedes the build`).toBeGreaterThan(firstBuild);
if (firstTest >= 0) expect(i, `restart at line ${d.line} precedes the tests`).toBeGreaterThan(firstTest);
}
});
});
// A restart-shaped command on a bare `nc:run` (no effect:) would silently
// escape both skipEffects ownership and the run-health gate.
it('no restart-shaped command hides on a bare nc:run', () => {
for (const d of directives) {
if (d.kind !== 'run' || d.attrs.effect !== undefined) continue;
for (const cmd of d.body) {
expect(
/restart\.sh|kickstart|systemctl/.test(cmd),
`bare nc:run at line ${d.line} runs a restart-shaped command ("${cmd}") without effect:restart — it would evade the run-health gate`,
).toBe(false);
}
}
});
// Dynamic twin of the engine's run-health-gate unit tests, per real skill:
// inject a failure at the first fetch/check/external run a scenario reaches
// and assert no restart/step/wire executes afterwards — authoring that
// evades the gate (or a gate regression against real documents) fails here.
it('run-health gate: a failed fetch/check/external blocks every later restart/step/wire', async () => {
for (const sc of scenarios) {
let current: Directive | undefined;
let sabotagedLine = -1;
let streamedAfterSabotage = 0;
const sideEffectStartsAfterSabotage: number[] = [];
const root = scratchRoot();
const res = await applySkill(dir, root, {
inputs: sc.inputs ?? {},
resolveRemote: () => 'origin',
onEvent: (e) => {
if (e.type !== 'step-start') return;
current = byLine.get(e.line);
if (sabotagedLine >= 0 && isSideEffectRun(current)) sideEffectStartsAfterSabotage.push(e.line);
},
exec: (c) => {
if (
sabotagedLine < 0 &&
current?.kind === 'run' &&
isString(current.attrs.effect) &&
SABOTEUR_EFFECTS.has(current.attrs.effect)
) {
sabotagedLine = current.line;
throw new Error('conformance sabotage');
}
return sc.exec?.find((e) => c.includes(e.match))?.stdout;
},
execStream: async () => {
if (sabotagedLine >= 0) streamedAfterSabotage++;
return { ok: true, fields: sc.stepFields ?? {} };
},
});
if (sabotagedLine < 0) continue; // this scenario never reaches a saboteur-eligible run
// The sabotaged run itself bounced to an agent…
expect(fullyApplied(res)).toBe(false);
expect(res.agentTasks.some((t) => t.line === sabotagedLine)).toBe(true);
// …and no dangerous side effect fired on its own afterwards.
expect(sideEffectStartsAfterSabotage).toEqual([]);
expect(streamedAfterSabotage).toBe(0);
// Any unguarded later side effect must surface as a gated agentTask, so
// an agent finishes it from the prose once the failure is fixed.
const gatedExpected = directives.some(
(d) => isSideEffectRun(d) && !isString(d.attrs.when) && d.line > sabotagedLine,
);
if (gatedExpected) {
expect(res.agentTasks.some((t) => /earlier step did not complete/.test(t.reason))).toBe(true);
}
return; // one sabotaged scenario per skill is enough
}
});
});
+462
View File
@@ -0,0 +1,462 @@
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 the full channel payload', () => {
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',
'container/skills/slack-formatting/SKILL.md',
]);
});
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
@@ -0,0 +1,420 @@
// 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);
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { inputsFromEnv } from './skill-inputs.js';
import { applySkill, fullyApplied } from './skill-apply.js';
describe('inputsFromEnv (docs/skill-engine-seam.md §6)', () => {
it('maps NC_INPUT_<VAR> env keys onto prompt vars, ignoring unset and unrelated keys', () => {
const md = [
'```nc:prompt bot_token secret',
'Paste the token.',
'```',
'```nc:prompt owner_handle',
'Your handle?',
'```',
].join('\n');
const inputs = inputsFromEnv(md, {
NC_INPUT_BOT_TOKEN: 'xoxb-fake',
NC_INPUT_UNRELATED: 'ignored', // no matching prompt
PATH: '/usr/bin', // not NC_INPUT_-prefixed
// NC_INPUT_OWNER_HANDLE deliberately unset → omitted, not ''
});
expect(inputs).toEqual({ bot_token: 'xoxb-fake' });
});
it('errors on an uppercase collision instead of silently merging', () => {
const md = [
'```nc:prompt bot_token',
'Token?',
'```',
'```nc:prompt Bot_Token',
'Token again?',
'```',
].join('\n');
expect(() => inputsFromEnv(md, {})).toThrow(/NC_INPUT_BOT_TOKEN/);
});
// The round-trip proof for one real skill: env → inputsFromEnv → applySkill
// goes fully green for add-slack (webhook leg) with stubbed exec — the exact
// pipeline-consumer path the seam doc's §6 contract describes.
it('round-trips the env convention through a full programmatic apply of add-slack', async () => {
const skillDir = join(process.cwd(), '.claude/skills/add-slack');
const md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
const inputs = inputsFromEnv(md, {
NC_INPUT_CONNECTION: 'webhook',
NC_INPUT_BOT_TOKEN: 'xoxb-fake-token',
NC_INPUT_SIGNING_SECRET: '0123456789abcdef',
NC_INPUT_OWNER_HANDLE: 'U12345678',
});
expect(inputs).toEqual({
connection: 'webhook',
bot_token: 'xoxb-fake-token',
signing_secret: '0123456789abcdef',
owner_handle: 'U12345678',
});
const root = mkdtempSync(join(tmpdir(), 'skill-inputs-'));
try {
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"}\n');
const res = await applySkill(skillDir, root, {
inputs,
exec: (c) => {
if (c.includes('auth.test')) return '@nano in Acme';
if (c.includes('conversations.open')) return 'slack:D0FAKE';
},
resolveRemote: () => 'origin',
});
expect(res.deferred).toEqual([]);
expect(res.agentTasks).toEqual([]);
expect(fullyApplied(res)).toBe(true);
expect(res.vars.platform_id).toBe('slack:D0FAKE');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+32
View File
@@ -0,0 +1,32 @@
// inputsFromEnv — the pipeline consumer's input path (docs/skill-engine-seam.md §6).
//
// A CI/pipeline caller supplies prompt answers as environment variables using
// the `NC_INPUT_<VAR>` convention (prompt var uppercased). This helper parses
// the skill's prompt vars via parseDirectives and returns the `inputs` record
// applySkill consumes. It is a helper, not an engine feature — the engine never
// reads process.env for inputs; `inputs` stays the only env-agnostic seam.
//
// Var names are case-sensitive in the grammar, so uppercasing can collide
// (`bot_token` vs `Bot_Token` both map to NC_INPUT_BOT_TOKEN); a collision is
// an error, never a silent last-writer-wins.
import { parseDirectives, promptVar } from './skill-directives.js';
export function inputsFromEnv(md: string, env: Record<string, string | undefined> = process.env): Record<string, string> {
const inputs: Record<string, string> = {};
const byKey = new Map<string, string>(); // NC_INPUT_<VAR> → the prompt var that claimed it
for (const d of parseDirectives(md)) {
if (d.kind !== 'prompt') continue;
const v = promptVar(d);
if (!v) continue;
const key = `NC_INPUT_${v.toUpperCase()}`;
const prior = byKey.get(key);
if (prior !== undefined && prior !== v) {
throw new Error(`inputsFromEnv: prompt vars "${prior}" and "${v}" both map to ${key} — rename one (var names must be case-insensitively unique)`);
}
byKey.set(key, v);
const val = env[key];
if (val !== undefined) inputs[v] = val;
}
return inputs;
}
+192
View File
@@ -0,0 +1,192 @@
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: one gate — the install-in-Teams operator pauses before the DM-open fetches', () => {
// Operators in order (CLI-first flow): prerequisites, the detected-owner
// note, the wire-declined note (when:wire_owner=no), install-in-Teams.
// The finish-wiring handoff is prose only — the wizard wires inline from
// the resolved vars.
const d = decisions(loadSkill('teams'));
expect(d).toHaveLength(4);
expect(d.map((g) => g.needsConfirm)).toEqual([
false, // prereqs → prompt public_url (prompt is the barrier)
false, // detected-owner note → prompt wire_owner (prompt is the barrier)
false, // wire-declined note → install operator (last operator of the chain carries the barrier)
true, // install-in-Teams → the DM-open effect:fetch chain
]);
// Completed-work flavor (fetch/external, not effect:step).
expect(d[3].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'));
// shared-number ban warning → shared_confirm prompt (natural barrier);
// qr operator skips the pairing-code operator + step (guard-incompatible)
// and gates on the qr effect:step; pairing-code likewise; the dedicated
// chat-number block and the shared-mode closing note are prompt-followed
// or terminal.
expect(d.map((g) => g.needsConfirm)).toEqual([false, true, true, false, false]);
expect(d[1].flavor).toBe('readiness');
expect(d[2].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: raw bodies stay offer-free — the install link is a {{var}} until substitution', () => {
const md = loadSkill('teams');
// The install block's URL is {{install_link}} in the AUTHORED body — no
// candidate matches here; the offer materializes at runtime from the
// rendered body (proven in run-channel-skill.test.ts's fresh-create case).
expect(operatorBody(md, 3)).toContain('{{install_link}}');
expect(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // detected-owner note
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wire-declined note (entra link is schemeless on purpose)
expect(extractOfferUrl(operatorBody(md, 3))).toBeUndefined(); // install-in-Teams
});
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
@@ -0,0 +1,130 @@
// 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
@@ -1,121 +0,0 @@
#!/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
@@ -1,126 +0,0 @@
#!/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

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