Adds a per-provider `errorSubstitutions` array on AgentProvider — each
entry is a `(name, test, replace)` triple. The poll-loop iterates the
provider's rules (in declaration order) before delivering result text
or error text to the user; the first matching `test` regex wins and
its `replace` string is sent in place of the raw output. If no rule
matches, the original text passes through unchanged — there is no
fallback message.
Replaces the single-purpose `isAuthRequired` / `authRequiredMessage`
shape with a list of typed rules so providers can declare any number
of swap-this-for-that mappings (auth banners, rate-limit hints,
context-too-long errors, etc.). New rule types just add an entry.
ClaudeProvider ships one rule today: `auth-required`, matching Claude
Code's "Not logged in · Please run /login" / "Invalid API key …"
banners with a host-aware remediation (re-run setup or run `claude`
in the project directory).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removing the "Not logged in · Please run /login" detection and
substitution from this PR — narrowing scope to just the OneCLI
gateway transient-retry change. The login-message handling will be
addressed separately.
Reverts:
- AgentProvider.isAuthRequired / authRequiredMessage
- ClaudeProvider auth-required regex, classifier, and remediation text
- poll-loop writeAuthRequiredMessage helper + call sites
- claude.test.ts (auth-only test file)
OneCLI/wakeContainer changes (the remaining content of the PR) are
unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#1820.
The container agent-runner sets CLAUDE_CODE_AUTO_COMPACT_WINDOW
unconditionally on the container process env, with no way to override
it per-deployment without editing source. Read process.env first and
fall back to the existing 165000 literal when unset.
Default behavior is unchanged for installs that do not set the env
var. Operators running 1M-context models or emergency-tuning a live
deployment can now raise or lower the threshold from the host env.
Adds a paired `authRequiredMessage()` method to AgentProvider so
per-provider auth-failure remediation can differ. Claude returns the
Anthropic/`claude` instruction; future providers (Codex, OpenCode, …)
can return their own remediation text. The poll-loop calls
`provider.authRequiredMessage?.()` and falls back to a generic message
if a provider implements `isAuthRequired` without supplying its own
remediation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- wakeContainer now never throws — returns Promise<boolean>, catches
internally. Closes the regression risk for the 5 awaited callers in
agent-to-agent, interactive, and approvals/response-handler that the
previous version left unwrapped. Router uses the boolean to stop the
typing indicator on transient failure; host-sweep just awaits.
- Tighten AUTH_REQUIRED_RE: anchor to start-of-string with the specific
`·` (U+00B7) separator the CLI uses, so an agent that quotes the
banner mid-sentence in a normal reply doesn't trip the classifier.
- Log a one-line note from writeAuthRequiredMessage so substitutions
are visible when debugging "user got the credentials message but I
don't see why."
- Add unit tests for ClaudeProvider.isAuthRequired covering both banner
variants, trailing content, mid-sentence quoting, leading-prose
quoting, alternate separators, and unrelated text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for the case where credentials aren't usable:
1. Replace Claude Code's "Not logged in / Invalid API key · Please run
/login" output with a host-aware message. The user can't run /login
from chat, so the raw text is unhelpful. Provider gains an optional
isAuthRequired() classifier; the poll-loop substitutes the message
on both result-text and error paths.
2. Treat OneCLI gateway failure as a transient hard error instead of
spawning a credential-less container. The catch in container-runner
now propagates; router and host-sweep wrap wakeContainer to log and
leave the inbound row pending so the next 60s sweep tick retries.
Router also stops the typing indicator on failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the word "assistant" in `accentGreen` (#3fba50, added in #2103)
across the six channel adapters that ask "What should your assistant
be called?" — Discord, iMessage, Signal, Slack, Telegram, WhatsApp.
Mirrors the green emphasis on "you" in the display-name prompt: the
green word names the subject of the question (assistant vs operator)
so the operator parses it at a glance.
Adds an `accentGreen` helper (#3fba50) with the same TTY/NO_COLOR/
truecolor gating as the rest of the palette, then wraps the word
"you" in the "What should your assistant call you?" prompt so the
operator parses at a glance who the question is about — the user,
not the assistant. The mirror prompt that asks for the assistant's
name ("What should your assistant be called?") is left for a
follow-up.
Customize `brightSelect`'s render function so the focused option's
label paints in brand cyan during selection and the submitted answer
paints in dim cyan after the user moves on. Inactive options keep
their default rendering — only the cursor and submitted state pick
up the color, matching the body-text emphasis added in #2101.
Also migrate the one remaining `p.select` call site (the "What next?"
prompt after the first chat) to `brightSelect` so every menu in the
setup flow goes through the same render path. The shape of the call
matches what `brightSelect` already supports — message + options
with value/label/hint — so no feature is lost in the swap.
Reuses `brandBody` from #2101 for the cyan, so the prompt highlight
and the body prose share one definition of the brand body color.
Adds a `brandBody` helper in setup/lib/theme.ts that wraps prose in
brand cyan (#2BB7CE), with the same TTY/NO_COLOR/truecolor gating used
by `brand`/`brandBold`/`brandChip`. The helper splits multi-line input
and colors each line independently so the SGR sequence doesn't bleed
across clack's gutter prefix.
Routing:
- `note()` (the un-dim card wrapper from #2095) now passes
`brandBody` as its `format` callback, so card bodies render
cyan line-by-line.
- Every prose `p.log.{message,info,success,step,warn}` call in the
setup flow wraps its body argument in `brandBody`. Calls whose
body is explicitly `k.dim(...)` (failure transcript tails, log
paths, claude-assist response previews) are left alone — those
are the "preview/debug" cases the dim-policy comment in
theme.ts already carves out.
- Spinner-finish lines in windowed-runner / claude-assist color
only the message portion; the `(5s)` elapsed suffix stays dim.
Brand cyan accents (chips, wordmark, inline emphasis) are unchanged.
This PR only adds the body color.
A follow-up will add OSC 11 dark/light detection so light-mode
terminals get a brand blue (#2b6fdc) variant — opt-in upgrade with
no regression for the dark-mode default.
When pasting an invalid token, the old value stayed in the input
field. Pasting a new token appended to the old one instead of
replacing it, causing repeated validation failures.
Add clearOnError: true to all 8 password prompts across setup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When re-running setup on a machine that already has a .env with
channel tokens or OneCLI config, detect them early and offer to
reuse instead of prompting the user to paste everything again.
- Add detectExistingEnv() to parse .env and group known keys
- Add detectExistingDisplayName() to read display name from v2.db
- Defer display name prompt until actually needed (cli-agent or channel)
- Skip cli-agent and first-chat when groups are already wired
- Add token reuse checks to Telegram, Discord, Slack, Teams, iMessage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clack's `p.note` defaults to `format: e => styleText("dim", e)`, which
fades note bodies regardless of the project's stated readability stance
(see comment on `dimWrap` in setup/lib/theme.ts: "prose renders at the
terminal's regular weight"). The dim styling makes body copy hard to
read on dark terminals and visibly washes out brand-colored segments
embedded in cards (e.g. the chip + bold heading rows).
Add a `note()` helper in setup/lib/theme.ts that wraps `p.note` with a
pass-through formatter, and route every setup-flow `p.note` call site
through it: setup/auto.ts, every setup/channels/*.ts adapter, and the
two setup/lib/claude-* helpers.
Pre-styled segments (brandBold, brandChip, formatPairingCard,
formatCodeCard) now render at full strength instead of being faded
alongside surrounding prose.
Use script(1) to capture PTY output and extract OAuth token when
browser-based auth isn't available, with fallback code-paste flow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When setup fails and claude-assist kicks in, instead of silently
skipping when the CLI is missing or unauthenticated, interactively
offer to install it (via install-claude.sh) and sign in (via
claude setup-token) so the user can get diagnostic help immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- getDelay indexed by attempt (1-based) into a 0-indexed array, so the
leading 0 was unreachable and every "after a crash" delay was shifted
up one slot. Use attempt - 1 so the documented schedule (0s → 0s →
10s → 30s → 2min → 5min → 15min cap) actually holds.
- enforceStartupBackoff runs before initDb (which creates DATA_DIR), so
on a fresh checkout fs.writeFileSync hit ENOENT. write() now
mkdirSync's DATA_DIR first.
- shutdown() didn't run resetCircuitBreaker if teardownChannelAdapters
threw, so a graceful exit with a teardown error would be counted as a
crash on the next start. Wrap teardown in try/finally.
- Adds src/circuit-breaker.test.ts: state transitions, full schedule
(parameterized), reset-window expiry, malformed file, and the
fresh-install path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backs off on rapid restarts to avoid exhausting Discord gateway identify
limits and triggering Cloudflare IP bans. Resets on clean shutdown so only
crashes accumulate the counter. Also adds a troubleshooting section to
CLAUDE.md with the most useful diagnostic locations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Slack interactive buttons (channel approval cards) require Interactivity
to be enabled in the app settings. Without it, button clicks silently
fail to reach the host. Added the step to both the setup wizard
post-install checklist and the add-slack SKILL.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slack setup previously stopped after installing the adapter, leaving
users to manually discover /init-first-agent. When they DM'd the bot,
the channel-approval flow silently failed because no owner existed.
Now the Slack setup flow matches Discord/Telegram:
- Collects the operator's Slack member ID
- Opens a DM channel via conversations.open (requires im:write scope)
- Runs init-first-agent to establish ownership, wiring, and welcome DM
- Updates post-install note to focus on webhook URL (the only remaining step)
The welcome DM is delivered via chat.postMessage (outbound), which works
before Event Subscriptions are configured. The user sees the greeting
immediately; inbound replies require webhooks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slipped through during the #2035 rebase resolution — both #2030's import
and ours landed in the merge. TypeScript dedups by symbol so it didn't
fail the typecheck, but it's noise and would've eventually tripped a
linter rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original approach passed ANTHROPIC_AUTH_TOKEN into the container
as an env var and disabled the proxy for the custom host (NO_PROXY) —
which works, but bypasses OneCLI entirely for that credential. The
container holds the raw secret, the gateway loses audit/rotation, and
we lose the rest of the vault's protections for this cohort.
OneCLI-native version: store the token as a generic secret with header
injection (--header-name Authorization --value-format 'Bearer {value}'
+ host-pattern matching the base URL hostname). The container only
needs ANTHROPIC_BASE_URL plus a placeholder ANTHROPIC_AUTH_TOKEN — the
proxy rewrites the Authorization header on the wire.
setup/lib/setup-config.ts — adds --anthropic-auth-token alongside the
existing --anthropic-base-url.
setup/auto.ts — runAuthStep short-circuits the auth-method prompt when
both NANOCLAW_ANTHROPIC_BASE_URL and NANOCLAW_ANTHROPIC_AUTH_TOKEN are
set: creates the OneCLI generic secret, writes ANTHROPIC_BASE_URL to
.env (so the runtime reads it), and appends `import './claude.js';` to
src/providers/index.ts (so the provider only registers when the user
has configured a custom endpoint — no branching for everyone else).
src/providers/claude.ts — drops ANTHROPIC_AUTH_TOKEN/NO_PROXY
passthrough. Reads ANTHROPIC_BASE_URL from .env, sets a placeholder
ANTHROPIC_AUTH_TOKEN in container env so the SDK includes an
Authorization header for OneCLI to overwrite.
src/providers/index.ts — removes the unconditional import; setup
appends it on demand.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users with a custom Anthropic-compatible endpoint (ANTHROPIC_BASE_URL) were
getting 401s because the OneCLI proxy injects ANTHROPIC_API_KEY=placeholder
and forwards to api.anthropic.com, overriding the custom endpoint and key.
Add a claude provider host config that reads ANTHROPIC_BASE_URL,
ANTHROPIC_AUTH_TOKEN, and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC from .env
and passes them into the container. Also sets NO_PROXY for the custom host so
the OneCLI proxy doesn't intercept those requests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Matches the OneCLI CLI's own format expectation ("oc_... format" per
`onecli auth login --help`) so a malformed token gets caught at setup
time rather than at first vault call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the example.internal placeholder with the hosted gateway URL
so the advanced screen and --help suggest the canonical destination
out of the box.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without `onecli auth login`, setup-time CLI calls (e.g. `secrets list`
inside anthropicSecretExists, `secrets create` in runPasteAuth) hit a
secured remote vault unauthenticated and fail silently — the auth step
sees no existing Anthropic credential and prompts the user to add one
even when it's already in the remote vault.
Two auth surfaces matter here: the CLI's persistent store via
`onecli auth login --api-key`, and ONECLI_API_KEY in .env that the
runtime SDK reads at request time. We need both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single config registry that drives both CLI flags and an opt-in
advanced-settings screen, so power users can override defaults like
remote OneCLI host/token or alt Anthropic endpoints without burdening
the standard linear flow with extra prompts.
Why: advanced configurations didn't fit cleanly into the existing
sequenced setup. PR #2030 took the "add another prompt step" route for
remote OneCLI; this approach instead routes those overrides through a
single source of truth so adding the next knob (alt endpoint, custom
host pattern, …) doesn't mean another prompt-or-skip decision.
setup/lib/setup-config.ts — schema (typed entry list with surface
'flag' | 'flag+ui'), name derivation (camelCase → NANOCLAW_UPPER_SNAKE
+ --kebab-case), seeded with --onecli-api-host, --onecli-api-token,
--anthropic-base-url, plus existing NANOCLAW_SKIP / NANOCLAW_DISPLAY_NAME
as flag-only entries.
setup/lib/setup-config-parse.ts — argv parser (--key value, --key=value,
--no-bool, -- terminator), env reader, applyToEnv() bridge that writes
resolved values back to process.env so existing step code keeps reading
env vars unchanged. Also --help printer.
setup/lib/setup-config-screen.ts — interactive menu loop. Entries
render with current value as hint; selecting one opens the right prompt
type (text / password for secrets / confirm / brightSelect for enums);
"Done" returns to the main flow.
setup/auto.ts — parses argv first (--help short-circuits before any
render), folds env+flags into process.env, then offers a welcome menu:
"Standard setup" (default) vs "Advanced". The onecli step branches on
NANOCLAW_ONECLI_API_HOST: if set, skips the local-vs-fresh prompt
entirely, runs pollHealth pre-flight, then calls runQuietStep with
--remote-url. Token, when provided, writes through to ONECLI_API_KEY in
.env. Welcome copy tightened (drops the duplicate wordmark/tagline) so
the bash → clack handoff reads as one flow.
setup/onecli.ts — cherries the --remote-url implementation from PR
run()) and generalizes writeEnvOnecliUrl into a writeEnvVar helper so
ONECLI_API_KEY follows the same upsert path.
nanoclaw.sh — forwards "$@" to setup:auto so flags reach the parser;
trims the redundant "Setting up your personal AI assistant" subtitle
and the bootstrap teach line so the pre-clack section isn't competing
with the clack intro for the same role.
Token plumbing only fires in --remote-url mode; local installs are
unauthenticated against localhost and don't need it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Allow connecting to an OneCLI gateway running on another host instead
of installing one locally. Adds a third choice ('Connect to a remote
OneCLI') alongside reuse/fresh in the setup wizard, prompts for the
remote URL, validates reachability before proceeding, and passes
--remote-url to the onecli step.
In onecli.ts: extracts installOnecliCliOnly() for the remote path
(installs the CLI binary but skips the gateway), exports pollHealth
for use by auto.ts, and handles --remote-url to configure api-host
and write ONECLI_URL to .env without running the full gateway install.
Absorbs battle-tested knowledge from the v2 skill into the upstream
add-signal: registration paths (new number + linked device), CAPTCHA
flow, VoIP SMS-first timing, Java prereq, config-lock warning, wiring
SQL for groups, not_member silent-drop fix, GroupV2 groupId extraction
note, and UUID-based platform ID format.
Corrects a factual error in the upstream: DM platform IDs are
signal:{UUID} (ACI), not phone numbers.
Removes the now-redundant add-signal-v2 skill.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setup/register.ts had two bugs that prevented new channels from being
registered via `/manage-channels`:
1. createMessagingGroupAgent was called with the legacy field names
`trigger_rules` and `response_scope`. The SQL INSERT expects
`engage_mode` / `engage_pattern` / `sender_scope` / `ignored_message_policy`
(migration 010). Every register call failed with
`RangeError: Missing named parameter "engage_mode"` after the agent
and messaging group were partially created — leaving an orphaned pair.
Now mirrors scripts/init-first-agent.ts:wireIfMissing:
- Groups (is_group=1) default to engage_mode='mention' (bot only
responds when addressed).
- DMs (is_group=0) default to engage_mode='pattern' with '.' (respond
to every message).
- An explicit --trigger overrides the pattern regex.
2. The "normalize platform_id" block unconditionally prefixed
"<channel>:" even for native IDs like WhatsApp JIDs
("120363408974444974@g.us"), iMessage emails ("user@example.com"),
or Signal phones ("+15551234567") / Signal groups ("group:abc"). But
the router (src/router.ts:158) looks up messaging_groups by the raw
event.platformId from the adapter, which for these native adapters
never has a prefix. So the prefixed row was never matched — the
message was silently dropped with no "Message routed" log.
Extracted scripts/init-first-agent.ts:namespacedPlatformId into
src/platform-id.ts so both setup paths use the same heuristic (skip
the prefix for IDs containing '@', starting with '+', or starting
with 'group:'). Prevents future drift between the two paths.
Tested by: re-running `setup/index.ts --step register` for a WhatsApp
group JID, confirming the row is created with correct engage fields
and matching platform_id, then sending a test message and observing
"Message routed" with the right agent group.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /add-gcal-tool — a sibling of /add-gmail-tool that installs
@cocal/google-calendar-mcp with the same OneCLI stub-file pattern. Skill
applies the Dockerfile + TOOL_ALLOWLIST changes at install time; trunk
stays clean so users who never run the skill don't carry the calendar
MCP in their image.
Dropped the Phase 5 dry-run section since it hardcoded a per-install
image tag slug and duplicated Phase 4's live agent test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The upstream precedence fix (5845a5a) made agent_groups.agent_provider and
sessions.agent_provider authoritative for host-side provider contribution
(per-session mount, env passthrough), but those DB values don't propagate
into the group's container.json — and the in-container runner reads
`provider` from container.json, not from the DB. That caused a confusing
failure mode: flipping the DB column to 'codex', rebuilding, and
restarting still spawned a Claude runner because container.json had no
provider field. The old skill wording ("container receives AGENT_PROVIDER
from the resolved value") overstated the integration.
Update add-codex and add-opencode "Per group / per session" sections to
say: set `"provider": "<name>"` in the group's container.json — that's
the source the runner reads. Keep the DB columns documented for the
host-side contribution they actually drive, and spell out the
session → group → container.json → 'claude' fallback so the precedence
is still discoverable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of the SKILL.md already contains the Dockerfile + TOOL_ALLOWLIST
edit instructions with an "ALREADY APPLIED" short-circuit. Keeping those
edits out of trunk means users who never run /add-gmail-tool don't carry
the Gmail MCP package in their image.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On a fork PR, GITHUB_TOKEN is demoted to read-only regardless of the
workflow's permissions: block, so issues.addLabels() returns 403. The
label workflow silently works for PRs that skip the template (no
checkboxes ticked → no API call) and fails for PRs that actually
follow it — a hostile incentive against contributors who do the right
thing.
pull_request_target runs in the context of the base branch with full
declared permissions, which is the documented fix for this case. Safe
here because the workflow is metadata-only: it reads
context.payload.pull_request.body and calls addLabels. No checkout,
no PR-supplied code executes. A SECURITY comment is added above the
trigger to keep it that way.
Refs:
- https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target
- https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two long-line violations introduced in d121cd1 (isGroup plumbing)
exceed the printWidth limit. CI format:check fails on every PR
opened against main until this is fixed; the fix is isolated here
so no behavior change is mixed in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filenames in forwardAttachedFiles arrived from the source agent's
messages_out content and were used directly in path.join on both
source outbox read and target inbox write. A value like `../evil.sh`
could escape `inbox/<a2a-id>/` on the target session (and similarly
the source outbox on read), breaking session isolation — an
adversarial or hallucinating sub-agent could overwrite files in
a sibling session.
Adds isSafeAttachmentName(name) — exported so it's unit-testable —
which rejects empty, `.`, `..`, anything containing `/`, `\`, or
NUL, and anything path.basename would strip. Guard runs before any
I/O. Unsafe names are dropped with a warning log, same pattern as
missing-source-file handling; a bad filename in one attachment
doesn't kill the whole route's text delivery.
Addresses Codex Review P1 on qwibitai/nanoclaw#1967.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: `send_file(to='parent')` from a sub-agent wrote the bytes to
the sub-agent's own session outbox, but agent-to-agent routing copied
only the content JSON — the target's inbound message referenced
`files: ['x.png']` but the bytes lived in a session directory the
target couldn't mount. Parent agents orchestrating sub-agents (e.g.
Design Team delegating illustration work to an Illustrator sub-agent
on Codex) received file-reference messages with nothing to forward.
Fix: on route, if the source's content has `files`, copy each referenced
file from `<source>/outbox/<src-msg-id>/` to
`<target>/inbox/<a2a-msg-id>/`, and emit `attachments` (the existing
formatter convention — see formatter.ts:223) with `localPath` relative
to `/workspace/`. The target formatter already renders these as
`[file: <name> — saved to /workspace/inbox/<a2a-id>/<name>]`, so the
target agent sees the path and can call `send_file(path=…, to=…)` to
forward onward.
Convention matches what session-manager.ts:256 already does for
base64-encoded channel-inbound attachments — same inbox layout, same
content shape. Nothing on the formatter/agent side needed to change.
## Scope
- `forwardAttachedFiles(source, target)` — pure-ish helper that copies
files and returns the attachments array.
- `forwardFileAttachments(msg, …)` — wraps the helper for the route
path: parses content, copies files if present, merges into any
existing `attachments`, re-serialises.
- `routeAgentMessage` — uses the rewritten content when writing the
target's inbound row.
- Log line now includes `forwardedFileCount` for observability.
Missing source files are skipped with a warning rather than killing
the route — a bad filename in a batch shouldn't drop the
accompanying text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before, every provider stored its opaque continuation id under the
single outbound.db key `sdk_session_id`. Flipping a session's
agent_provider (e.g. Codex → Claude) meant the new provider read the
old provider's id at wake, handed it to its own SDK, and got a
"No conversation found" error that cost the user one sacrificed
message before the stale-session recovery path cleared the id.
This reshapes session_state so continuations are keyed
`continuation:<provider>` instead. Consequences:
- Per-provider continuations coexist. Flipping Claude → Codex → Claude
resumes the Claude thread exactly where it left off, with the
intervening Codex thread also still on file.
- No provider ever reads another provider's id. Switching costs no
sacrificed message and emits no transient error.
- Legacy installs are migrated forward on first startup:
migrateLegacyContinuation() adopts any pre-existing `sdk_session_id`
row into the current provider's slot (best guess — it was whichever
provider ran last), then deletes the legacy row unconditionally so
it can't poison a future provider's read.
runPollLoop now takes providerName alongside the provider instance,
and threads it through processQuery to setContinuation on init.
Tests: 9 new tests covering set/get isolation across providers,
clear-specificity, legacy-adoption, legacy-always-deleted,
prefer-existing-slot-over-legacy, and idempotency of a second
migration call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /add-gmail-tool — a Utility skill that installs Gmail as an MCP tool
in NanoClaw v2 using OneCLI for credential injection. No raw OAuth tokens
ever reach the container; the gateway swaps the "onecli-managed" stub
bearer for the real token at request time.
Scope (3 files):
- container/Dockerfile: pnpm global-install of
@gongrzhe/server-gmail-autoauth-mcp@1.1.11, pinned behind GMAIL_MCP_VERSION.
Also pins zod-to-json-schema@3.22.5 to avoid an ERR_PACKAGE_PATH_NOT_EXPORTED
crash: the MCP server's loose zod range resolves zod@3.24.x while
zod-to-json-schema@3.25.x imports the zod/v3 subpath that only exists in
zod>=3.25.
- container/agent-runner/src/providers/claude.ts: adds 'mcp__gmail__*' to
TOOL_ALLOWLIST so the agent can invoke the server's tools.
- .claude/skills/add-gmail-tool/SKILL.md: pre-flight checks (OneCLI Gmail app
connected, stubs present, mount allowlist covers ~/.gmail-mcp, agent
secret-mode), per-group wiring in container.json (mount + mcpServers),
verification steps, troubleshooting, removal instructions. Credits to
gongrzhe for the MCP server and the add-atomic-chat-tool / add-vercel
skill patterns.
Addresses #1500 (proxy Gmail OAuth through credential proxy) on the Gmail
side. Overlaps in intent with #1810 but stays surgical — no bundled
unrelated changes.
Tested end-to-end on Linux/Docker: CLI and WhatsApp self-chat agents can
list labels, search/read/send mail via OneCLI-injected tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 010-engage-modes (replace trigger_rules + response_scope with
engage_mode/engage_pattern/sender_scope/ignored_message_policy) updated
the schema and the production code paths, but missed setup/register.ts.
The step still constructed a payload with the dropped columns. On any
fresh v2 install, attempting to register a channel via:
pnpm exec tsx setup/index.ts --step register -- --platform-id ...
fails with: `Missing named parameter "engage_mode"`. This affects every
flow that calls the register step — the /add-<channel> skills,
/manage-channels, and the setup auto driver.
Map old → new:
- trigger_rules.pattern (string) → engage_mode='pattern',
engage_pattern=<pattern>
- requiresTrigger=false (no pattern) → engage_mode='pattern',
engage_pattern='.' (the "always" sentinel from migration 010)
- requiresTrigger=true (no pattern) → engage_mode='mention'
- response_scope='all' → sender_scope='all',
ignored_message_policy='drop' (conservative default matching the
migration backfill rule)
Tested by registering three Telegram channels (one DM, two groups) on a
fresh v2 install — all succeeded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolveProviderContribution read only containerConfig.provider (from each
group's container.json) and ignored both agent_groups.agent_provider and
sessions.agent_provider. The provider-install skills (opencode, codex)
and CLAUDE.md document those DB columns as the source of truth with
session-overrides-group precedence, but the code never consulted them —
so setting `agent_provider = 'codex'` on a group had no effect, and the
only way to route to a non-default provider was to edit the per-group
JSON directly. Discovered while wiring up Codex: DB update landed but
the spawned container kept running Claude.
Extract a pure `resolveProviderName(session, group, containerConfig)`
with the documented precedence:
sessions.agent_provider
→ agent_groups.agent_provider
→ container.json `provider`
→ 'claude'
`resolveProviderContribution` now calls it. The container.json fallback
stays so existing installs that only set provider in JSON keep working.
Empty strings treated as unset to avoid footguns when a DB-backed form
writes '' for "no override."
Added unit tests covering precedence, null-fallthrough, empty-string
fallthrough, and case normalization.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`bash nanoclaw.sh` can now offer Signal as a channel choice, scan the
signal-cli link QR in the terminal, and wire up the first agent end to
end — mirroring the WhatsApp and Telegram flows.
Pieces:
- setup/add-signal.sh — non-interactive installer. Fetches
src/channels/signal.ts + signal.test.ts from the channels branch,
appends the self-registration import, installs qrcode (for the
setup-flow QR render), and builds. Idempotent and standalone-runnable.
- setup/signal-auth.ts — step runner. Spawns `signal-cli link --name
NanoClaw`, watches stdout for the `sgnl://linkdevice?…` (or legacy
`tsdevice://`) URL, emits SIGNAL_AUTH_QR with it. On exit 0, runs
`signal-cli -o json listAccounts` and reports the new account via
SIGNAL_AUTH STATUS=success. Pre-check via listAccounts returns
STATUS=skipped if an account is already linked.
- setup/channels/signal.ts — interactive driver. Probes for signal-cli
(offering `brew install signal-cli` on macOS or linking GitHub
releases on Linux if missing), runs add-signal.sh, renders each
SIGNAL_AUTH_QR block as a terminal QR inside a clack spinner,
persists SIGNAL_ACCOUNT to .env + data/env/env, restarts the
service, then wires the first agent via init-first-agent.
- setup/index.ts: register `signal-auth` in the STEPS map.
- setup/auto.ts: add 'signal' to ChannelChoice, import the driver,
add it to the channel picker (after WhatsApp, hint "needs signal-cli
installed"), branch the dispatch, and map channelDmLabel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signal adapter source (src/channels/signal.ts + signal.test.ts) now
lives on the `channels` branch alongside all other channel adapters,
per the trunk/channels split documented in CLAUDE.md and CONTRIBUTING.md
("Trunk does not ship any specific channel adapter"). The /add-signal
skill fetches the file from origin/channels like every other channel.
This PR to main therefore carries only:
- .claude/skills/add-signal/{SKILL,VERIFY,REMOVE}.md — the skill itself
- scripts/init-first-agent.ts — unrelated infra fix that benefits any
native-ID channel (Signal, WhatsApp) by skipping the channel-prefix
on platform IDs that already have their own format
The fixed adapter source + tests were pushed to the channels branch in
a parallel commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getAskQuestionRender used to hardcode the card title and option labels
for pending_channel_approvals and pending_sender_approvals in the
DB-access layer, duplicating wording that already lived in the approval
modules. That caused a visible drift between the initial card title —
picked per event in channel-approval.ts ("📣 Bot mentioned in new chat"
vs. "💬 New direct message") — and the post-click render, which
always showed the constant "📣 Channel registration".
Mirror the pattern already used by pending_approvals: add title /
options_json columns on both pending_*_approvals tables via migration
013, have the approval modules write them at creation time, and let
getAskQuestionRender just SELECT.
- Migration 013 ALTERs the two tables to add title + options_json.
- PendingChannelApproval / PendingSenderApproval types and their
create functions grow the two fields.
- channel-approval.ts / sender-approval.ts normalize options once
and pass both title and options_json into the insert.
- getAskQuestionRender drops the hardcoded render objects and reads
the stored values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Correctness fixes:
- parseSignalStyles now uses a recursive walker so nested styles (e.g.
**bold with `code` inside**) produce correct offsets against the final
plain text. Previous impl recorded styles against intermediate text and
didn't reindex when later passes stripped prefix characters.
- *single-asterisk* maps to ITALIC (was BOLD, divergent from standard
Markdown). _underscore_ also maps to ITALIC.
- EchoCache keys on (platformId, text) so an outbound "hi" to Alice no
longer drops a real "hi" inbound from Bob.
- On TCP socket close, flip adapter connected=false and log a warning so
operators see lost daemon connections instead of silently failing sends.
- signalTcpCheck clears its 5s timeout on success so successful checks
don't leak a setTimeout handle.
Config hygiene:
- Rename SIGNAL_HTTP_HOST/PORT to SIGNAL_TCP_HOST/PORT (transport is TCP
JSON-RPC, not HTTP) and add SIGNAL_CLI_PATH for non-PATH installs.
- Remove unused readFileSync import.
- Log a warning in deliver() when outbound files are dropped (native
adapter doesn't forward attachments to signal-cli yet).
Tests:
- Nested style offset correctness
- *italic* and _italic_ ITALIC mapping
- Cross-recipient echo isolation
- Same-recipient echo still suppressed
- isConnected() flips on socket close
- Outbound-files warn-and-drop path
SKILL.md realigned to the add-telegram / add-whatsapp template: fetches
from the `channels` branch (not a `skill/*` branch), lists pre-flight
idempotency checks, adds Features / Troubleshooting sections. Added
VERIFY.md and REMOVE.md siblings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native Signal adapter using signal-cli TCP JSON-RPC daemon. No Chat SDK
bridge or npm dependencies — uses only Node.js builtins.
Features:
- DM and group message support
- Voice message detection (placeholder text; transcription via
/add-voice-transcription skill)
- Typing indicators (DMs only)
- Mention detection via text match
- Managed daemon lifecycle (auto-start/stop signal-cli)
- Echo suppression for outbound messages
Also fixes init-first-agent.ts to skip channel-prefixing for phone
numbers (+...) and Signal group IDs (group:...), which are native
platform IDs that adapters send without a channel prefix.
Install via /add-signal skill. Uses /init-first-agent for channel wiring.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The prior instruction told users to append "@openai/codex@${CODEX_VERSION}" to
a single combined `pnpm install -g` block. That block no longer exists on
main — the Dockerfile splits each global CLI (vercel, agent-browser,
claude-code) into its own RUN layer for cache granularity. Update the skill
to add a standalone RUN block for Codex that matches the existing pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the /add-opencode and /add-ollama-provider pattern. Copies the
add-codex SKILL.md from the providers branch onto trunk so the skill is
discoverable without a manual branch copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
createPendingQuestion and createPendingApproval both run before the
adapter delivery call. When delivery fails and the retry loop reinvokes
deliverMessage with the same questionId/approvalId, the second attempt
hit UNIQUE constraint on the pending_questions.question_id (or
pending_approvals.approval_id) and threw — so the retry never reached
the send step, and every subsequent retry failed the same way until
max-attempts marked the message permanently failed.
Switch both inserts to INSERT OR IGNORE. Return bool indicating whether
a new row was actually inserted so delivery.ts can avoid logging
"Pending question created" twice for the same card.
Symptom that surfaced this: a send-layer ValidationError on one attempt
followed by SqliteError on every subsequent attempt, with the user
seeing neither the card nor a follow-up. Seen in conjunction with the
Telegram 64-byte callback_data limit (fixed separately in
#1942/chat-sdk-bridge), but the idempotency gap applies to any
transient delivery failure — rate limits, network blips, adapter 5xx —
and is worth fixing on its own.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ask_question cards failed to deliver on Telegram whenever any option had
a non-trivial value (e.g. an ISO datetime, a URL, or a long token).
Telegram limits inline-keyboard callback_data to 64 bytes, and the
previous encoding embedded both the questionId and the full option
value in each button's actionId plus a second copy as value, producing
payloads well over the cap. The adapter threw ValidationError, delivery
was marked permanently failed, and the agent sat waiting on an answer
that never reached the user.
Fix:
- Button id is now `ncq:<questionId>:<index>` and button value is the
stringified index. Callback payloads shrink from ~100 bytes to ~40
and fit Telegram's cap for any option list with <100 items.
- Both callback-decode sites (Chat SDK `onAction` for Telegram/Slack/
etc., and the Discord Gateway interaction handler) resolve the
index back to the real option value via
`getAskQuestionRender(questionId)` before dispatching to the host's
onAction — so response handlers (pending_questions, pending_approvals)
are unchanged and still receive the canonical value.
- `resolveSelectedOption` helper has a backward-compat fallback:
non-numeric tails are treated as literal values so any card
delivered under the old encoding still resolves if the user clicks
it after deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-commit prettier reformatted this in the working tree but didn't
re-stage. Keeping it in a separate commit to avoid amending a prior
commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a container exits with an unresolved processing_ack claim, the
sweep's crashed-container cleanup would reset the matching inbound
message with tries++ and a future process_after. dueCount then dropped
to 0, so the wake step never fired — and the next sweep tick found the
same orphan claim, bumped tries again, and pushed process_after further
out. The message reached MAX_TRIES and was marked failed without any
container ever being spawned.
Two changes:
1. Reorder sweep so the wake step runs before crashed-container
cleanup. A fresh container clears orphan 'processing' rows on its
own startup (container/agent-runner/src/db/connection.ts), so once
we get it running the claim resolves itself.
2. Make resetStuckProcessingRows idempotent: if a message already has
process_after set to a future time, skip the retry bump. The wake
path will pick it up when the backoff elapses. Requires returning
process_after from getMessageForRetry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a container exits, its .heartbeat file is left behind with the
mtime of its last SDK activity. When the same session spawns a new
container, the host sweep's ceiling check reads that stale mtime and
kills the freshly-spawned container within seconds — before the new
instance has had time to touch the file itself.
The sweep already has a carve-out for "no heartbeat file" (treated as a
fresh spawn, given grace), so simply removing the orphan at spawn time
restores the intended semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Align the environment check with the v2 setup flow so existing wired agent groups are detected from data/v2.db instead of the retired v1 store. This prevents setup from reporting no registered groups on valid v2 installs and adds regression coverage for both v2 and pre-migration state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The initial /add-atomic-chat-tool merge added src edits directly to main.
That conflicts with the utility-skill pattern used elsewhere (e.g. /claw):
the skill folder should ship the file and SKILL.md should instruct copy +
idempotent edits at install time, not a git merge that carries src diffs.
- Move container/agent-runner/src/atomic-chat-mcp-stdio.ts →
.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts
- Revert the atomic_chat mcpServers entry in agent-runner index.ts
- Revert mcp__atomic_chat__* from TOOL_ALLOWLIST in providers/claude.ts
- Revert ATOMIC_CHAT_* env forwarding and [ATOMIC] log elevation in
src/container-runner.ts
- Empty .env.example back out
- Rewrite SKILL.md: copy the shipped file, then apply deterministic Edits
(index.ts, providers/claude.ts, container-runner.ts, .env.example)
with exact before/after snippets the installer agent can match.
Main is now back to its pre-PR state for the tool; /add-atomic-chat-tool
re-applies everything at install time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes local Atomic Chat models (OpenAI-compatible API at
127.0.0.1:1337/v1) as tools to the container agent. Adds
atomic_chat_list_models and atomic_chat_generate alongside
the existing Ollama skill.
Rebased on current main:
- MCP server registered in agent-runner index.ts using bun (no tsc
step in-image), sibling path to index.ts, env: {} with ATOMIC_CHAT_*
forwarded when set.
- allowedTools entry moved to providers/claude.ts TOOL_ALLOWLIST.
- SKILL.md: drop obsolete per-group copy step (single RO mount
supersedes it); use pnpm build.
Made-with: Cursor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getAskQuestionRender only checked pending_questions and
pending_approvals, missing the channel and sender approval tables.
Approval button clicks showed the raw value ("approve") instead of
the selectedLabel ("✅ Wired"). Extend the lookup to also check
pending_channel_approvals and pending_sender_approvals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Channel and sender approval cards showed raw platform IDs
(e.g. discord:1475578393738219540:...) instead of readable context.
Extract sender name from the event content for channel approvals,
and use the channel type name for sender approvals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The router hardcoded is_group=0 when auto-creating messaging groups,
causing channel mentions to be misclassified as DMs. The Chat SDK
bridge knows which handler fired (onDirectMessage vs onNewMention)
so thread the signal through InboundMessage → InboundEvent → router.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Discord puts the clicking user at interaction.member.user for guild
interactions but interaction.user for DM interactions. The Gateway
handler only checked interaction.member, so DM button clicks resolved
to an empty user ID and were silently rejected as unauthorized.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slack: interactive driver walks through app creation, validates the
bot token via auth.test, installs the adapter, and prints a
post-install checklist for the webhook URL + Event Subscriptions
config. No welcome DM since Slack needs a public URL before inbound
events work — the driver's own "finish in Slack" note replaces the
outro "check your DMs" banner.
iMessage: picks local (macOS) vs remote (Photon) mode. Local mode
opens the node binary's directory in Finder so the user can drag it
into Full Disk Access. Remote mode prompts for Photon URL + API key.
Asks for the operator's phone/email, then wires the first agent
including a welcome iMessage.
Both marked "(experimental)" in the askChannelChoice picker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two installs on the same host could trash each other's containers: the
reaper used `docker ps --filter name=nanoclaw-`, a substring match that
picked up every install's containers. A crash-looping peer (e.g. a legacy
v1 plist respawning ~6k times) would call cleanupOrphans on every boot and
kill the healthy install's session containers within seconds of spawn.
- Stamp `--label nanoclaw-install=<slug>` onto every spawned container.
- cleanupOrphans filters by that label; healthy peers are left alone.
- Setup preflight enumerates `com.nanoclaw*` launchd plists / nanoclaw
user systemd units, probes state/runs, and unloads any that are
crash-looping (state != running AND runs > 10) before installing
this install's service.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three UX tweaks after watching a user walk through setup:
1. Claude-assist "Run this command?" now defaults to Yes. After Claude has already been asked to diagnose + explained the fix, the vast majority of users want to run it — the No-default added friction without proportional safety.
2. claude-assist persists its session across failures in one setup run. First invocation captures session_id from the stream-json init event; subsequent invocations pass --resume <id>. Claude sees prior failures as conversation history instead of treating each hiccup as a blank-slate ticket.
3. First-chat flow no longer drops the user into a free-text chat loop by default. Instead: explain what the ping/pong check is doing, wait for the pong, then offer "Continue with setup" (recommended, default) or "Pause here and chat with your agent from the terminal" (opt-in). The free-text loop is still reachable, just not the default path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The upstream onecli.sh/cli/install script resolves the latest release via
api.github.com/repos/onecli/onecli-cli/releases/latest — anonymous callers
get throttled to 60 req/hour per IP, and once exhausted the installer dies
with "curl: (56) 403 / Error: could not determine latest release". Shared
IPs (corporate NAT, public Wi-Fi) hit this without ever running the
installer themselves. Reproduced locally: rate_limit remaining=0 → upstream
installer returns the exact user error.
Fallback path when upstream fails:
1. Resolve version via `curl -fsSL -o /dev/null -w '%{url_effective}' \
https://github.com/onecli/onecli-cli/releases/latest`. That endpoint
302s to /tag/vX.Y.Z — parses the version without an API call.
2. If the redirect probe also fails, install a pinned fallback version
(ONECLI_CLI_FALLBACK_VERSION, currently 1.3.0).
3. Download the archive from /releases/download/vX.Y.Z/… directly (the
CDN path isn't API-throttled), extract, and install to /usr/local/bin
or ~/.local/bin mirroring upstream's install-dir logic.
Gateway install (onecli.sh/install, docker-compose based) is untouched —
it doesn't hit the API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dimmed explanatory prose blocks were hard to read against dark terminals. Shift the weight ladder up a notch:
- dimWrap() no longer dims. Multi-line prose (the step-intro copy, etc.) renders at the terminal's regular weight.
- Spinner outcome labels (done/failed/skipped) are now bold via runUnderSpinner, so each step's headline reads stronger than the body copy around it.
- Un-dim two command-hint blocks in auto.ts (docker-group setfacl + service restart; the socket-error remediation commands) — those are commands the user may need to type.
Dim is still used where it helps — (Ns) spinner timings, URLs, short inline parentheticals — and for the preview/debug blocks dim is explicitly reserved for: dumpTranscriptOnFailure tail and claude-assist streams.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When corepack enable fails with EACCES (common when Node is installed to a system-writable prefix like /usr/local that the user doesn't own), we fall back to `npm install -g pnpm`. But npm's global prefix isn't always on the shell's PATH — users often set `npm config set prefix ~/.npm-global` to avoid sudo, and the resulting bin dir isn't picked up by `command -v`. Install succeeded, but pnpm "wasn't there" for the follow-up `pnpm install`.
Now after the npm fallback we query `npm config get prefix` and prepend `<prefix>/bin` to PATH. Mirror the same lookup in nanoclaw.sh right before `exec pnpm run setup:auto` — setup.sh's PATH mutation doesn't propagate back, and the hand-off needs pnpm visible too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Container step: duration hint + 3-line rolling output window with
60s stall detector that offers "keep waiting" vs "ask Claude"
- First chat: reframed as a try-out with sandbox-model explainer
(wakes on message, sleeps when idle, context persists)
- Timezone: auto-detected non-UTC zones now get an explicit
confirm from the user instead of silent persist
- Outro: added always-on warning + prominent "check your DM" banner
when a channel was configured; directive last line
- Discord: always show token-location reminder even when user says
they have one; new "do you have a server?" branch walks through
server creation if not
- All select prompts: custom brightSelect renderer keeps inactive
option labels at full brightness (was dim gray); adds @clack/core
as a direct dep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two NanoClaw installs on the same host used to fight over the shared `com.nanoclaw` launchd label / `nanoclaw.service` systemd unit and the `nanoclaw-agent:latest` docker tag — the second install silently rewrote the service pointer and rebuilt the image out from under the first. Introduces a deterministic per-checkout slug (sha1(projectRoot)[:8]) and namespaces everything off it:
- Service: `com.nanoclaw-v2-<slug>` / `nanoclaw-v2-<slug>.service`
- Image: `nanoclaw-agent-v2-<slug>:latest` (base), `nanoclaw-agent-v2-<slug>:<agentGroupId>` (per-group)
New shared helpers: src/install-slug.ts (host) + setup/lib/install-slug.sh (bash). Both compute the same slug so verify/probe/add-*.sh/build.sh/container-runner all agree. Any v1 `com.nanoclaw` service left on the host stays untouched and can coexist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: setup/onecli.ts ran `curl -fsSL onecli.sh/install | sh` unconditionally. For users with OneCLI already running and bound to a specific listener (host-accessible, shared with other apps), re-running the installer rebound the gateway and broke those consumers.
Now: auto.ts probes for an existing install (`onecli version` + `onecli config get api-host`). If detected, clack asks: use the existing instance (recommended) or install a fresh one. The new --reuse flag in the onecli step skips the installer, reads the configured api-host, writes ONECLI_URL to .env, and moves on without touching the running gateway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forks that keep the upstream nanoclaw repo under a non-origin remote name (typically `upstream`, with `origin` pointing at the user's fork) hit "git fetch origin channels failed" when adding a channel, because the fork doesn't carry the channels branch. New setup/lib/channels-remote.sh walks `git remote -v` for a url matching qwibitai/nanoclaw, auto-adds `upstream` if none matches, and honors NANOCLAW_CHANNELS_REMOTE as an override. Wired into the four add-*.sh scripts that setup:auto invokes (discord, telegram, whatsapp, teams).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Some Node installs (older nvm, node@22 keg-only on brew, minimal distro packages) don't ship corepack, so the bootstrap was dying with "corepack: command not found" before pnpm could land on PATH. Now guards the corepack call and falls back to `npm install -g pnpm@<pinned>`, reading the version from package.json's packageManager field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deletes the Claude-orchestrated /setup and /new-setup flows. The scripted installer (bash nanoclaw.sh → setup:auto) now handles bootstrap, container, OneCLI, auth, service, first agent, and optional channel wiring end-to-end with inline Claude-assisted recovery on failure. Keeps /setup as a one-line redirect so the trigger still resolves. Drops the opt-out diagnostics files that belonged to the old flow and updates cross-refs in add-wechat, migrate-nanoclaw, and update-nanoclaw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ground-up v2 rewrite supersedes all conflicting files from main. The one main-side fix (ONECLI_API_KEY forwarding, 8b5b581) already landed on v2 as 3db66c0. README preview banner dropped since v2 is now main.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trunk ships no channel adapters — /add-telegram installs the package on demand from the channels branch. This dependency was stale and pulled ~2 transitive packages into every fresh install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes transient analysis/proposal/checklist docs whose purpose is served once v2 ships: REFACTOR.md, docs/v1-vs-v2/, docs/checklist.md, docs/shared-source.md, docs/claude-md-composition.md, docs/module-contract.md, docs/DEBUG_CHECKLIST.md. Updates CLAUDE.md and docs/README.md index rows accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shared bash + node emitter in setup/lib/diagnostics.{sh,ts} reads/writes data/install-id so every event from a single install shares one distinct_id — bash-side setup_launched/setup_start, node-side auto_started, per-step started/completed, auth_method_chosen, channel_chosen, first_chat_ready/failed, setup_incomplete, setup_aborted, setup_completed. Opt-out via NANOCLAW_NO_DIAGNOSTICS=1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a structured drip-feed of capabilities (memory, agents, scheduling, research, code, UI, files, self-customization) and explicit sections on approvals, access control, and natural interaction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three MCP tool groups were orphaned from the ambient CLAUDE.md context
because they shipped no `*.instructions.md` alongside their source.
Backfill them so the composer picks them up as fragments on next spawn:
- core.instructions.md: add `send_file` (artifact delivery, path relative
to /workspace/agent/) and `add_reaction` (by `#N` id with emoji
shortcode name).
- interactive.instructions.md: `ask_user_question` (blocking
multiple-choice with selectedLabel/value option objects, 300s default
timeout) and `send_card` (non-blocking structured render with
fallbackText). Opens with a one-line framing of the contrast between
the two.
- agents.instructions.md: `create_agent` with how-it-works, when-to-use
(companions vs collaborators — persistent memory vs independent
parallel work), when-NOT-to-use (short tasks should use the SDK `Agent`
tool instead), and guidance for writing the seed instructions string.
No composer changes — scan in `src/claude-md-compose.ts` already picks up
any file matching `*.instructions.md` in the mcp-tools directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HTML comment at the top was aimed at maintainers opening the file,
but it's loaded verbatim into every agent's system prompt via the
`.claude-shared.md` import. Agents don't need the meta-explanation of
where the file is mounted or how identity gets injected — it's just
context-budget drag. Move the maintainer guidance out of the agent's
view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the passing CLAUDE.local.md mention with an explicit Memory
section: anything substantive the user shares must be stored so it's
retrievable later, with per-topic files indexed from CLAUDE.local.md.
Frames this as a core part of the agent's job — the quality of its
memory systems is a main signal of how useful it is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install_packages and add_mcp_server already did the right thing on approve
(install auto-rebuilt+killed, add_mcp_server just killed), so request_rebuild
was redundant plumbing agents sometimes called after an install — wasting an
admin approval round-trip. Delete it end-to-end:
- container/agent-runner/src/mcp-tools/self-mod.ts: remove requestRebuild
tool + registration; update install_packages description.
- src/modules/self-mod/{request,apply,index}.ts: drop handleRequestRebuild
+ applyRequestRebuild + registrations; rewrite the rebuild-failed notify
to point admins at retrying install_packages instead.
- src/modules/{approvals,self-mod}/{agent,project}.md and skill/self-
customize/SKILL.md: scrub agent-facing references; clarify that
add_mcp_server needs no rebuild (bun runs TS directly).
- docs/{module-contract,architecture-diagram,checklist,db-central,shared-
source,v1-vs-v2/*}.md, CLAUDE.md, pending-approvals migration comment,
approvals/index.ts docstring, REFACTOR.md: trailing references.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move every agent-specific instruction out of the shared container/CLAUDE.md
so the base is genuinely universal. Persona/identity now comes from the
system-prompt addendum (buildSystemPromptAddendum now takes assistantName
and prepends "# You are {name}"). Per-module instructions live alongside
each MCP tool source:
container/agent-runner/src/mcp-tools/core.instructions.md
container/agent-runner/src/mcp-tools/scheduling.instructions.md
container/agent-runner/src/mcp-tools/self-mod.instructions.md
composeGroupClaudeMd() scans that directory and emits `module-<name>.md`
fragments as symlinks to /app/src/mcp-tools/<name>.instructions.md (valid
via the existing RO source mount). Skill fragments renamed to
`skill-<name>.md` for naming consistency with `module-*` and `mcp-*`.
Mount tightening so composer-managed files can't be clobbered by agent
writes: nested RO mounts for /workspace/agent/CLAUDE.md and
/workspace/agent/.claude-fragments/. CLAUDE.local.md (per-group memory)
stays RW as the only writable CLAUDE.md-family file.
.gitignore: ignore CLAUDE.local.md, .claude-shared.md, .claude-fragments/
everywhere, and simplify groups/ rules to ignore the whole tree (per-
installation state, not tracked).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the selective-mode gotcha for auto-created OneCLI agents
(no secrets injected by default) with the CLI commands to inspect
and fix it. Note that approval policies are not configurable via
the SDK or `onecli@1.3.0` CLI — web UI only.
Replace stale `NANOCLAW_ADMIN_USER_IDS` / `src/access.ts` references
across CLAUDE.md, docs/architecture.md, docs/checklist.md, and
docs/module-contract.md. Admin gating now runs host-side in
src/command-gate.ts against `user_roles`; approver picks live in
src/modules/approvals/primitive.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a timezone step between cli-agent and channel wiring in setup:auto.
Autodetect via --step timezone; if it resolves to UTC or fails, confirm
with the user and accept either an IANA zone or a free-text description
(e.g. "New York"). Free-text falls through to a headless `claude -p`
call that returns a single IANA string, gated on the claude CLI being
on PATH.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prepend a Claude-addressed banner so that when an upgrader (or Claude on
their behalf) runs `git pull` / `git merge` from v1 and hits merge
conflicts, Claude aborts the merge and routes the user to
`bash migrate-v2.sh` instead of trying to resolve the rewrite by hand.
Fresh clones are explicitly told to ignore the banner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports the v1 fix from PR #1777 (originally 8b5b581 by @johnnyfish).
Cherry-pick did not apply cleanly because v2 reformatted the surrounding
code and split OneCLI usage into two sites — manual port was needed.
v2-specific adaptations:
- Also forward apiKey at the second OneCLI call site in
src/modules/approvals/onecli-approvals.ts (v2 split the approvals
module out of container-runner).
- Skipped the companion test-mock commit (38163bc) — it patches
src/container-runner.test.ts, which no longer exists in v2 (tests
consolidated into host-core.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: johnnyfish <jonathanfishner11@gmail.com>
The Chinese and Japanese READMEs still describe the v1 architecture
(setup flow, channel list, "main channel" concept, etc.). Add a notice
at the top of each pointing readers to the English README for current
content until the translations are refreshed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Major version for the v2 rewrite. CHANGELOG documents the breaking
changes users will hit on upgrade: new entity model, two-DB session
split, `bash nanoclaw.sh` as default install, channels/providers
relocated to sibling branches, three-level isolation, Apple Container
removed from default setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrite install flow around `bash nanoclaw.sh`, update What It Supports to
reflect the three-level isolation model and the real channel roster, fix
the Philosophy section (AI-native hybrid, skills over features via
channels/providers branches, Codex/OpenCode/Ollama as drop-in providers),
and replace the pre-v2 architecture diagram and key-files list with the
two-DB session split (`inbound.db` / `outbound.db`) and current src layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-install was a bare instruction block: "DM the bot, run
/manage-channels". Replace it with an explicit Done/Stuck-style
select backed by the handoff mechanism — Claude takes over, tails
logs/nanoclaw.log for the inbound, inspects data/v2.db for the
auto-created messaging_group row, runs scripts/init-first-agent.ts
with the discovered platform_id + AAD user id, and verifies end-to-end.
Operators who want to drive it themselves pick "I'll do it myself"
and get the same terse instructions as before. Default is the
handoff (recommended hint).
Why Teams and not the other channels: Telegram/Discord/WhatsApp
already have synchronous platform IDs we capture during setup, so
init-first-agent runs inline. Teams platform IDs only exist after
the first real inbound, so the wiring is necessarily deferred — and
that deferred work is exactly what the handoff handles best.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Teams is the most complex channel NanoClaw supports — no "paste a
token" shortcut exists. Operators walk through ~6 Azure portal steps
(app registration, client secret, Azure Bot resource, messaging
endpoint, Teams channel, manifest sideload). The driver makes each
step as guided as possible and gives the operator an explicit
escape to interactive Claude whenever they get stuck.
Handoff mechanism (reusable across channels):
- setup/lib/claude-handoff.ts: offerClaudeHandoff(ctx) spawns
`claude --append-system-prompt <context> --permission-mode acceptEdits`
with stdio: 'inherit', returns when Claude exits so the driver can
re-offer the same step. Context captures channel, current step,
completed steps, collected values (secrets redacted), and file refs.
- validateWithHelpEscape / isHelpEscape: wrap clack text/password
prompts so typing '?' triggers the handoff mid-paste.
- Parallel to the existing claude-assist.ts (which is failure-triggered
and runs claude -p for a one-shot command suggestion). This is the
user-initiated, interactive counterpart.
Teams driver (setup/channels/teams.ts):
- 6-step walkthrough, each a clack note + paste prompts + stepGate
select ("Done / Stuck — hand me off to Claude / Show me again").
- Collects TEAMS_APP_ID / TEAMS_APP_TENANT_ID / TEAMS_APP_PASSWORD /
TEAMS_APP_TYPE plus the operator's public HTTPS URL (advisory —
no tunnel automation yet).
- Emits the full Azure CLI invocation alongside the portal steps for
operators who prefer scripted creation.
- UUID/password prompts accept '?' as a help escape; select prompts
have an explicit 'Stuck' option that triggers the handoff.
Manifest generator (setup/lib/teams-manifest.ts):
- Builds data/teams/teams-app-package.zip in-process: manifest.json
(schema v1.16) with app ID injected, a 32×32 outline icon, a
192×192 brand-blue color icon, bundled with the system `zip`.
- Minimal hand-rolled PNG encoder (CRC32 table + zlib deflate) so we
don't need ImageMagick or vendored binary blobs.
- ~2.5KB zip, validates with `unzip -l`; icons verify as valid PNGs.
Installer (setup/add-teams.sh):
- Non-interactive mirror of add-discord.sh. Validates the four env
vars, copies adapter from origin/channels, installs
@chat-adapter/teams@4.26.0, upserts creds to .env + data/env/env,
restarts the service.
auto.ts: Teams option in askChannelChoice with 'complex setup' hint,
dispatch to runTeamsChannel.
Deferred (known limitation, operator instructed to finish manually):
- Wait-for-first-DM pairing to capture the auto-generated Teams
platform_id. Teams platform IDs are only discoverable after the
first inbound activity. The driver installs the adapter and stops
there; the operator DMs the bot, NanoClaw auto-creates the
messaging group, and they wire an agent via /manage-channels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The concurrent poll in processQuery filtered out messages with
mismatched thread_ids, causing a deadlock when the initial batch
(e.g. a host-generated welcome trigger with null thread_id) completed
but follow-ups arrived with a different thread_id (e.g. a Discord DM).
The query stayed open waiting for matching-thread pushes that never
came, blocking the poll loop indefinitely.
Thread routing is the router's concern — per-thread sessions already
isolate threads into separate containers; shared sessions intentionally
merge everything. Removed the filter.
Also fixed processing_ack: a result event (with or without text) means
the turn is done, but markCompleted only ran when event.text was truthy.
When the agent responded via MCP send_message (empty result text), the
initial batch stayed in 'processing' for the query's lifetime, creating
false stuck signals in the host sweep. Now marks completed on any result
event.
Belt-and-suspenders: init-first-agent welcome trigger now sets threadId
to the DM platform_id instead of null.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the per-group "written once at init, owned by the group" CLAUDE.md
with a host-regenerated entry point that imports:
- a shared base (`container/CLAUDE.md` mounted RO at `/app/CLAUDE.md`)
- optional per-skill fragments (skills that ship `instructions.md`)
- optional per-MCP-server fragments (inline `instructions` field in
`container.json`)
- per-group agent memory (`CLAUDE.local.md`, auto-loaded by Claude Code)
Principle: RW = per-group memory, RO = shared content. Source/skills/base
are shared; personality, config, working files, and Claude state stay
per-group.
Key changes:
- New `src/claude-md-compose.ts` — per-spawn composition +
`migrateGroupsToClaudeLocal()` one-time cutover.
- New `container/CLAUDE.md` — shared base, seeded verbatim from the
former `groups/global/CLAUDE.md`.
- `src/container-runner.ts` — swap `/workspace/global` mount for RO
`/app/CLAUDE.md`; call `composeGroupClaudeMd()` after
`initGroupFilesystem()`.
- `src/group-init.ts` — drop `.claude-global.md` symlink + initial
`CLAUDE.md` write; seed `CLAUDE.local.md` from `opts.instructions`.
- `src/index.ts` — call `migrateGroupsToClaudeLocal()` at startup.
- `src/container-config.ts` — add optional `instructions` field to
`McpServerConfig` (inline per-MCP guidance fragment).
- `container/Dockerfile` — drop dead `/workspace/global` mkdir.
- Remove obsolete `scripts/migrate-group-claude-md.ts`.
Migration (runs once at host startup, idempotent):
- Delete `.claude-global.md` symlinks in each group.
- Rename each `groups/<folder>/CLAUDE.md` → `CLAUDE.local.md`
(preserves existing per-group content as memory).
- Delete `groups/global/` directory.
Design docs: `docs/claude-md-composition.md` and `docs/shared-source.md`
(the latter is the sibling design discussion this refactor builds on).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the per-group agent-runner-src copy model with a single shared
read-only mount. Source and skills are now RO + shared; personality,
config, working files, and Claude state stay RW + per-group.
Key changes:
- Mount container/agent-runner/src/ RO at /app/src (all groups share one copy)
- Mount container/skills/ RO at /app/skills; per-group skill selection via
symlinks in .claude-shared/skills/ based on container.json "skills" field
- Mount container.json as nested RO bind on top of RW group dir
- Move all NANOCLAW_* env vars to container.json (runner reads at startup)
- New runner config.ts module replaces process.env reads
- Move command gate (filtered/admin) from container to host router
- Dockerfile: remove source COPY, split CLI installs (claude-code last),
move agent-runner deps above CLIs for better layer caching
- Add writeOutboundDirect for router denial responses
- Design doc at docs/shared-src.md
Not included (follow-up): DB migration to drop agent_provider columns,
cleanup of orphaned agent-runner-src directories.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously init-first-agent auto-granted global owner to the first
user wired through it and left every subsequent user as nothing — no
role, no membership. That worked for the bootstrap path but broke the
second channel's welcome DM: the access gate saw no role + no
membership and dropped the message with accessReason='not_member'.
Make the role explicit:
- scripts/init-first-agent.ts accepts --role owner|admin|member
(default: owner). Role drives the grant:
owner -> global owner (agent_group_id=null)
admin -> admin scoped to this agent group
member -> no role row, just membership
Idempotent via getUserRoles pre-check — safe on re-runs. addMember
runs unconditionally (INSERT OR IGNORE) so the access gate has a
row even for users who'd otherwise pass via role alone.
- setup/lib/role-prompt.ts — shared askOperatorRole(channel) prompt
with owner as the default pick. Self-host single-operator is the
dominant case, so the user's fingers default to Enter.
- Telegram / Discord / WhatsApp drivers all call askOperatorRole
before resolving the agent name and pass --role <choice> through.
Captured in progression log via setupLog.userInput('<channel>_role').
Summary output drops the fragile "promoted on first owner" hint in
favor of a dedicated role: line ("owner (global)" / "admin (scoped to
<ag-id>)" / "member") so re-runs make the current grant legible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a setup step fails — whether hard via fail() or soft via the
"What's left" / "Skipping the first chat" notes — offer to ask Claude
to diagnose. On consent, spawn `claude -p --output-format stream-json`
with a scrolling 3-line action window ("Reading x", "Running y") so
the 1–4 minute investigations feel active rather than hung. No hard
timeout: debugging can take time, Ctrl-C is the escape hatch.
The prompt is minimal: one-paragraph framing, failed step name + msg +
hint, and a list of file references (not contents). Claude's Read/Grep
tools fetch what they need. A per-step map in claude-assist.ts gives
the most relevant files per step; the rest is README + auto.ts +
logs/setup.log + the per-step raw log.
Claude responds with REASON + COMMAND lines. We show the reason in a
clack note, prefill the command via setup/run-suggested.sh (bash 4+
readline, 3.x fallback to Enter-to-run), and eval on the user's
confirm.
When the user runs a fix, fail() now offers to retry the failing step
rather than aborting. setup/logs.ts tracks successfully-completed step
names in-memory; fail() threads those as NANOCLAW_SKIP on a spawnSync
retry, so the child picks up exactly where the parent left off — no
rebuilding containers or reinstalling OneCLI.
Other polish in this change:
- fitToWidth + dimWrap in lib/theme.ts to prevent long spinner labels
from soft-wrapping (each terminal row stacks a stale copy otherwise).
- Shorter container step label ("Preparing your assistant's sandbox…")
so it fits on narrow terminals.
- Wordmark anchored in the clack intro line on every run.
- All 25 existing fail() call sites updated to await fail(...) since
fail is now async.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WhatsApp (community/Baileys) joins the setup:auto channel picker, with
the same clack-native UX discipline as Telegram and Discord:
- setup/channels/whatsapp.ts — driver. Collects auth method (QR terminal
or pairing code), runs the auth step, renders QR blocks in-place with
ANSI cursor-rewind on rotation so the terminal doesn't fill up with
stale codes, reads creds.me.id for the bot phone, restarts the service,
asks for the operator's personal phone (defaulting to the authed
number), writes ASSISTANT_HAS_OWN_NUMBER=true when they differ
(dedicated mode), and hands off to init-first-agent.
- setup/whatsapp-auth.ts — forked standalone auth step. Channels-branch
version had a browser-QR path with an HTTP server + <canvas> QR
renderer; stripped entirely (headless/SSH users hit dead ends too
often, and the extra deps complicate install). The remaining terminal
QR emits raw QR strings in WHATSAPP_AUTH_QR blocks so the parent
driver owns the rendering. Pairing-code path retained. Status blocks
now use the runner's vocabulary (success/skipped/failed) so spawnStep
sets ok correctly; WhatsApp-specific UI text ("WhatsApp linked", "You
chat") lives in the driver.
- setup/add-whatsapp.sh — non-interactive installer, mirror of
add-telegram.sh. Fetches the adapter + groups step from the channels
branch (whatsapp-auth.ts stays local, pair-telegram.ts pattern),
installs pinned baileys/qrcode/pino, registers the steps in
setup/index.ts's STEPS map. No service restart (adapter factory
returns null until creds exist).
Cross-channel fixes bundled:
- scripts/init-first-agent.ts: always addMember(user, agentGroup) for
the target user so subsequent wirings (not the first) pass the access
gate. Telegram wiring first → Discord/WhatsApp second was dropping
every inbound with accessReason='not_member' because only the first
user gets owner. namespacedPlatformId also passes through JID-format
raws (contains '@') so WhatsApp's bare <phone>@s.whatsapp.net matches
what the adapter stores.
- setup/service.ts: launchctl unload-then-load instead of bare load (bare
load errors 'already loaded' when a prior plist was cached, keeping
launchd on the OLD ProgramArguments even after the file on disk
changed). systemctl start → restart (start is a no-op on an active
unit, swallowing unit-file edits).
- setup/add-telegram.sh: removed the in-script open "tg://resolve"
block. The driver (setup/channels/telegram.ts) now owns the deep-link,
gated on a p.confirm so the browser can't steal focus unexpectedly.
- setup/channels/discord.ts + setup/channels/telegram.ts: every browser
open goes through confirmThenOpen (new shared helper in
setup/lib/browser.ts) — operator presses Enter before their browser
takes focus. Telegram switched from tg://resolve?domain= to
https://t.me/<bot> which works everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round-trip confirmation before first chat. After cli-agent wires up the
Terminal Agent, send `chat ping` through the CLI socket under a spinner
with 30s timeout (shared helper in setup/lib/agent-ping.ts, also used by
verify). Only after a real reply do we show "Your assistant is ready."
and enter the chat loop. Ping failures surface a targeted note
(socket_error vs no_reply) and skip the prompt — so users never type
into the void.
Checkout-mismatch detection. verify resolves the running service PID's
script path via `ps -p <pid> -o command=` and compares to projectRoot.
If the service is running from a sibling clone (common for developers
with multiple checkouts), SERVICE comes back as running_other_checkout
instead of running, AGENT_PING is skipped, and the failure note tells
the user exactly which bootout + bootstrap pair to run.
Native Claude Code install on demand. Only the subscription auth path
needs `claude`; the paste-token and paste-API-key paths don't. So
register-claude-token.sh now runs setup/install-claude.sh when `claude`
is missing (curl -fsSL https://claude.ai/install.sh | bash), then
prepends ~/.local/bin to PATH in-process so the rest of the script can
see the fresh binary.
Gutter-safe wrapping. wrapForGutter + dimWrap in lib/theme.ts hard-wrap
text to `process.stdout.columns - gutter` on word boundaries, measuring
visible length (ANSI-stripped). dimWrap applies the dim envelope per
line because clack resets styling at each line break when rendering
multi-line log content — a single outer dim() only colors the first
line. Applied to the long "why" notes before container + onecli, the
channel-skip info, the ping-failure note, and the checkout-mismatch
remediation.
Wordmark anchoring. printIntro always includes the NanoClaw wordmark in
the clack intro line, whether or not nanoclaw.sh already printed one in
bash. Worth ~1 line of redundancy so the brand stays visible at the top
of the clack session after bootstrap output scrolls out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of the Telegram flow but without a pairing step — Discord
exposes enough via the bot token that we only need one paste from the
operator, with every other identity field derived:
GET /users/@me → bot username (sanity check)
GET /oauth2/applications/@me → application id, verify_key
(public key), owner {id, username}
POST /users/@me/channels → DM channel id
After confirming "Is @<owner_username> your Discord account?" the flow
invites the bot to a server (OAuth URL + open + confirm, gating so the
welcome DM can actually reach the operator), installs the adapter, opens
the DM channel, and hands off to init-first-agent with
--channel discord --platform-id discord:@me:<dmChannelId>. The existing
init-first-agent welcome-over-CLI-socket path delivers the greeting
through the normal adapter pipeline — no Discord-specific code in the
welcome logic.
Fallbacks: if the app is team-owned (no owner object) or the operator
declines the confirmation, a Dev Mode walkthrough + user-id paste prompt
takes over.
Adds:
- setup/add-discord.sh (non-interactive installer, mirror of
add-telegram.sh minus pair-step registration)
- setup/channels/discord.ts (operator-facing flow)
- setup/auto.ts: Discord option in askChannelChoice + dispatch
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install-node.sh and install-docker.sh both require brew on macOS. On a
fresh Mac there's no brew, so the bootstrap spinner would bail with a
cryptic "Homebrew not installed" error. Move the prompt to nanoclaw.sh
as a pre-flight, so the user sees a clear ask — including the heads-up
that Xcode Command Line Tools come along for the ride (~5-10 min) —
before the spinner starts and brew's own sudo/CLT prompts appear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Content pass: every user-facing line is rewritten from the perspective
of someone trying NanoClaw for the first time. Phase labels and devops
framing are gone. Examples:
"Environment OK" → "Your system looks good."
"Container image ready" → "Sandbox ready."
"OneCLI installed" → "OneCLI vault ready."
"Anthropic credential" → "Claude account"
"Mount allowlist in place" → "Access rules set."
"Service installed/running" → "NanoClaw is running."
"Wiring the terminal agent" → "Setting up your terminal chat…"
"Setup complete" → "You're ready! Enjoy NanoClaw."
Long-running steps get a one-sentence "why" that teaches a NanoClaw
differentiator while the user waits:
bootstrap → "NanoClaw is small and runs entirely on your machine.
Yours to modify."
container → "Your assistant lives in its own sandbox. It can only
see what you explicitly share."
onecli → "Your assistant never gets your API keys directly. The
vault adds them to approved requests as they leave the
sandbox."
OneCLI is now named explicitly and framed as "your agent's vault" in
the install step, the paste-auth save step, the subscription-auth
banner, and their associated failure hints.
Auth split (option b: explicit step name on fail): the auth-method
choice moves from the bash menu in register-claude-token.sh into a
clack select. Only the subscription path still breaks out to the
interactive TTY for `claude setup-token`; paste-based OAuth tokens and
API keys stay in clack via p.password() and register directly via
`onecli secrets create`. register-claude-token.sh is scoped down to
the subscription flow only.
nanoclaw.sh: dropped the "Phase 1 / Phase 2" labels. The wordmark and
subtitle now print bash-side so setup:auto skips repeating them and
the flow reads as one continuous sequence. Bootstrap label is
"Installing the basics" with a dim gutter-line "why" preamble. pnpm's
`> nanoclaw@X setup:auto` preamble is suppressed via --silent.
Em-dash pass on user-facing copy: every em-dash that functions as an
em-dash in a user-visible string is replaced with period, semicolon,
comma, or parens. Code comments and JSDoc are untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
auto.ts had grown to 923 lines with ~10 interleaved responsibilities.
Split into three focused modules, keeping auto.ts as a pure step
sequencer:
- setup/lib/runner.ts (325 lines) — spawn + stream-parse + spinner-wrap
primitives. Exports: spawnStep, spawnQuiet, runQuietStep,
runQuietChild, runUnderSpinner (internal), StatusStream, types
(Fields, Block, StepResult, SpinnerLabels, QuietChildResult),
writeStepEntry, summariseTerminalFields, dumpTranscriptOnFailure,
fail(), ensureAnswer().
- setup/lib/theme.ts (39 lines) — brand palette (brand, brandBold,
brandChip) with USE_ANSI / TRUECOLOR gating, so both auto.ts and
channel flows can render the NanoClaw cyan without duplicating the
detection.
- setup/channels/telegram.ts (277 lines) — runTelegramChannel(displayName)
owns the full flow: BotFather instructions, token paste + validation
(via getMe), install script, pair-telegram streaming UI (code card +
attempt checkpoints), agent-name prompt, init-first-agent wiring.
auto.ts drops to 376 lines. main() reads as a clean sequence of
`if (!skip.has(X)) await Xstep(...)` blocks.
fail() now takes the step name explicitly — no module-level
failingStep state. Every call site is grep-friendly and self-contained
(fail('container', msg, hint)).
Typechecks clean. Smoke-tested end-to-end: intro, mounts step,
progression log, and outro all render the same as before the split.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
init-first-agent used to double-wire the CLI channel to every new DM
agent as a convenience for `pnpm run chat`, gated by --no-cli-bonus.
With the /new-setup-2 flow gone and a dedicated scratch CLI agent
created earlier in setup:auto, that bonus just stomps on CLI routing
the user already set up. Remove the CLI_CHANNEL/CLI_PLATFORM_ID
constants, ensureCliMessagingGroup, the --no-cli-bonus flag, and the
cli-bonus wiring block.
Pass the paired user's identity through to the welcome delivery so
the sender resolver sees the real owner (e.g. telegram:<id>) instead
of cli:local. Extend the CLI channel's admin-transport payload to
accept optional sender/senderId overrides — falls back to the old
cli/cli:local defaults when omitted, so existing callers are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents and implements the output contract from docs/setup-flow.md:
Level 1: clack UI — branded, concise, product content
Level 2: logs/setup.log — append-only, linear, structured entries for
humans + AI agents reviewing a run
Level 3: logs/setup-steps/NN-name.log — full raw stdout+stderr per step
Every scripted sub-step, including bootstrap, emits at all three levels.
Bootstrap now runs under a bash-side clack-alike spinner with live elapsed
time; its apt/pnpm output is captured to 01-bootstrap.log and summarised
as a progression entry. setup.sh's legacy log() routes to the raw log
instead of contaminating the progression log.
Telegram install becomes fully branded: setup/auto.ts owns the BotFather
instructions (clack note), token paste (clack password with format
validation), and getMe check (clack spinner). add-telegram.sh drops to a
non-interactive installer that reads TELEGRAM_BOT_TOKEN from env, logs to
stderr, and emits a single ADD_TELEGRAM status block on stdout.
The Anthropic credential flow is the one intentional break — register-
claude-token.sh still inherits the TTY for claude setup-token's browser
dance; it logs as an 'interactive' progression entry with the method.
setup/logs.ts centralises the level 2/3 formatting: reset, header, step,
userInput, complete, abort, stepRawLog. User answers (display name, agent
name, channel choice, telegram_token preview) log as their own entries so
the setup path is reconstructable from the progression log alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the scripted setup flow in a branded, friendly UI. Each step runs
under a clack spinner with elapsed time; child stdout/stderr is captured
quietly and dumped only on failure. Interactive children (token paste,
Anthropic OAuth) bypass the spinner and inherit the TTY.
- intro: NanoClaw wordmark + brand-cyan accent chip, truecolor with
kleur fallback and NO_COLOR / non-TTY awareness
- pair-telegram: emits PAIR_TELEGRAM_CODE / _ATTEMPT status blocks only;
auto.ts renders clack notes + "received X — doesn't match" checkpoints
- streaming status-block parser handles mid-step events without waiting
for the child to exit
- terminal-block detection now finds any block with a STATUS field
(handles MOUNTS emitting CONFIGURE_MOUNTS, etc.) and treats 'skipped'
as a success variant with an optional friendlier label
Also fixes a latent bash bug where `$VAR…` (unbraced followed by a
multi-byte Unicode character) pulled ellipsis bytes into the variable
name lookup and tripped `set -u`. Braced `${VAR}` in add-telegram.sh
and register-claude-token.sh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously setup:auto parsed pair-telegram's machine-readable status
blocks and rendered a banner on top. Fork the script instead: check
in setup/pair-telegram.ts with a focused 4-digit banner, a short
wrong-attempt line, and a single final PAIR_TELEGRAM status block
(kept so the parent driver still picks up PLATFORM_ID and
PAIRED_USER_ID via parseStatus).
Drop pair-telegram.ts from add-telegram.sh's copy list so the local
version isn't overwritten on re-runs. The other adapter files
(telegram.ts, telegram-pairing.ts, etc.) still come from the channels
branch.
Also fix a latent bug: auto.ts was reading ADMIN_USER_ID from the
success block, but the actual field name is PAIRED_USER_ID —
init-first-agent would have been called with --user-id "".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pair-telegram step emits PAIR_TELEGRAM_ISSUED / _NEW_CODE /
_ATTEMPT blocks meant for /setup skill parsing — dumping them raw in
setup:auto left the operator squinting at key/value clutter. Intercept
the stream line-by-line, suppress the block framing, and print just
the 4-digit code inside a box with a short instruction. Wrong-code
attempts and the final success block also get short human lines.
parseStatus still runs on the full buffered output at close so
PLATFORM_ID / ADMIN_USER_ID flow through unchanged to init-first-agent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Headless / SSH / WSL users won't have \`open\` or \`xdg-open\` wired up,
so the deep-link fails silently and they have no clue where to go.
Always print https://t.me/<username> so the URL is at least clickable
or copy-pasteable from the terminal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the token is in .env, call
https://api.telegram.org/bot<TOKEN>/getMe — if ok, extract the bot's
username and \`open tg://resolve?domain=<username>\` so the Telegram
desktop app lands on the bot chat. When pair-telegram prints the
4-digit code a moment later, the user just types it into the already-
open chat instead of hunting for their bot.
Falls back to https://t.me/<username> if the tg:// scheme isn't
registered, and just warns-and-continues if getMe fails (network
hiccup shouldn't block setup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pair-telegram only identifies the chat and operator — it returns
PLATFORM_ID and ADMIN_USER_ID but doesn't create the agent group,
grant owner, or send the welcome. scripts/init-first-agent.ts does
that, matching the pattern the /new-setup skill already uses for
channel wiring.
Also prompts for the agent's own name (default: Nano), overridable
via NANOCLAW_AGENT_NAME. displayName is hoisted out of the cli-agent
block so both cli-agent and channel wiring share the value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After cli-agent, prompt the user to connect a messaging app. For now
only Telegram is offered; "skip" falls through to the existing CLI
flow.
setup/add-telegram.sh runs the scriptable half of /add-telegram: fetch
the channels branch, copy the adapter + pair-telegram files, append
the self-registration import, install @chat-adapter/telegram@4.26.0
(pinned to match the skill), rebuild, collect TELEGRAM_BOT_TOKEN via
silent paste, write .env + data/env/env, and kick the service so the
new adapter is live. Idempotent throughout.
setup:auto then runs the existing `pair-telegram` step with
--intent main. The step emits the 4-digit code in its status stream,
which is already forwarded to stdout by runStep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verify now runs \`pnpm run chat ping\` silently and checks for a reply.
Emits AGENT_PING=ok|no_reply|socket_error|skipped; skipped when the
service isn't running or no groups are wired (those already fail the
verify via other checks). Kills the child after 90s so a wedged
container can't hang setup (chat.ts's own 120s timeout is too long
here). setup:auto surfaces AGENT_PING!=ok in its failure summary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Your agents" — the name is stored on the operator's user row and
applies to every future agent they wire up, not just this scratch CLI
one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before the cli-agent step, ask the operator what the agent should
call them (defaults to \$USER). The agent's own persona name is
hardcoded to "Terminal Agent" — this is the scratch CLI agent, not
one of the operator's real personas. NANOCLAW_DISPLAY_NAME still
skips the prompt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Explicitly tell the user that nothing appears on screen as they paste
and that a single Enter submits. "(Input is hidden for safety.)" was
ambiguous — users kept waiting for a visible confirmation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The timezone step blocked the scripted flow on headless servers where
the resolved TZ was UTC (interactive /setup confirms, setup:auto had
to bail). Drop it from the chain — host TZ defaults to whatever the
OS reports. Users who need an explicit override run the step on
demand: `pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New channel skill for personal WeChat, using Tencent's official iLink
Bot API (the same protocol @tencent-weixin/openclaw-weixin uses).
Region-restricted to mainland 微信 accounts — international WeChat
clients can't complete the QR flow.
Skill contents:
- Install steps copy the adapter from the `channels` branch (same
pattern as other /add-<channel> skills) and register it in
src/channels/index.ts.
- Post-login wiring helper at scripts/wire-dm.ts — lists unwired
WeChat messaging groups, prompts for an agent group, and inserts the
messaging_group_agents row with sender policy `request_approval` by
default (matches the router auto-create default so the admin gets an
approval card on the next unknown-sender DM).
- Channel Info documents how /new-setup Claude captures the
operator's user_id (from data/wechat/auth.json.operatorUserId) and
the first DM's platform_id (from the adapter's "WeChat inbound" log).
Also adds WeChat as option 15 in /new-setup's channel list so setup
wires into the existing /add-<channel> flow automatically.
Addresses https://github.com/qwibitai/nanoclaw/issues/1901.
Co-Authored-By: ythx-101 <226337373+ythx-101@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- container: install Docker via setup/install-docker.sh when missing,
distinguish socket EACCES from daemon-down so we bail fast instead of
polling 60s, and re-exec the step under `sg docker` when usermod hasn't
reached the current shell.
- auto: after the container step, re-exec the whole driver under `sg
docker` (with a NANOCLAW_REEXEC_SG guard) so onecli/service/verify also
get docker-group access without a re-login. Surface the new
docker_group_not_active error from the container step.
- service: when the systemd user manager has a stale group list, auto-
apply \`sudo setfacl -m u:\$USER:rw /var/run/docker.sock\` so the service
can start without waiting for the next login.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chains `cli-agent` (wraps scripts/init-cli-agent.ts) between service and
verify. Without this wiring, the socket at data/cli.sock accepts the
connection but there's no agent group routed to `cli/local`, so
`pnpm run chat` hangs waiting for a reply.
Defaults: display name from NANOCLAW_DISPLAY_NAME env, falling back to
\$USER then "Operator". Agent persona name from NANOCLAW_AGENT_NAME,
defaulting to the display name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node check now triggers setup/install-node.sh when missing/too old, and
COREPACK_ENABLE_DOWNLOAD_PROMPT=0 prevents the first-use prompt from
hanging the script when stdout is redirected to the log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single command end-to-end: `bash nanoclaw.sh` runs setup.sh for
bootstrap and hands off to `pnpm run setup:auto` on success. Passes
through NANOCLAW_TZ, NANOCLAW_SKIP, SECRET_NAME, HOST_PATTERN via env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs after the OneCLI install step and before mounts/service. Skips
silently when `onecli secrets list` already reports an Anthropic
secret, so re-running setup:auto on a configured install is a no-op.
Child process uses stdio:inherit so the menu + browser sign-in flow
work normally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bash helper that registers an Anthropic credential in OneCLI via three
paths: Claude subscription (runs `claude setup-token` under script(1)
for PTY capture), paste an existing sk-ant-oat… OAuth token, or paste
an sk-ant-api… API key.
On bash 4+ the `claude setup-token` command is pre-filled in the
readline buffer so Enter submits it. On bash 3.2 (macOS default
/bin/bash) we fall back to a plain confirmation prompt. Token
extraction strips ANSI + TTY-wrap line breaks and anchors on
sk-ant-oat…AA with a length cap (via perl; BSD grep caps {n,m} at 255).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The install half of the OneCLI step is fully scriptable (the gateway
and CLI install themselves via `curl | sh`, PATH + api-host + .env
updates are idempotent). Register the Anthropic secret is still
interactive — the auto driver leaves that for `/setup` §4 to handle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`pnpm run setup:auto` chains the deterministic setup steps (environment
→ timezone → container → mounts → service → verify) by spawning the
existing per-step CLI and parsing its status blocks. Config via env:
NANOCLAW_TZ, NANOCLAW_SKIP.
Credentials + channel install + /manage-channels stay interactive —
verify reports what's left and exits 0 rather than failing the driver.
Also have the container step try to start Docker when it's installed
but not running (open -a Docker on macOS, sudo systemctl start docker
on Linux) and poll `docker info` for up to 60s before giving up. Both
/setup and setup:auto pick this up automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple Container is no longer supported — the runtime abstraction in
src/container-runtime.ts is already Docker-only. Remove the remaining
setup-time branches that probed for it: the Apple Container runtime
option in the container build step, the APPLE_CONTAINER field emitted
by the environment check, and the `command -v container` probe in
verify. `--runtime docker` still parses for backwards compatibility
with the /setup skill.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs that surfaced together when a Discord response exceeded
2000 chars:
1. **Session id lost on mid-turn container exit.** `runPollLoop` was
calling `setStoredSessionId` only after `processQuery` returned. If
the container died between the SDK's `init` event (where session_id
arrives) and the stream completing, the id was never persisted. The
next wake called `getStoredSessionId()` → undefined and started a
fresh Claude session, dropping all prior context. Fix: persist
immediately in the `init` branch inside `processQuery`. The existing
post-query store becomes a harmless no-op.
2. **Silent truncation past adapter limits.** `chat-sdk-bridge.deliver`
handed full text straight to `adapter.postMessage`. Discord's adapter
hard-truncates at 2000 chars; Telegram's at 4096. Responses longer
than that were cut off without any signal to the user or host. Fix:
add `maxTextLength` to `ChatSdkBridgeConfig` and a `splitForLimit`
helper that breaks on paragraph → line → hard-char boundaries, then
posts chunks sequentially. Files ride on the first chunk; the
returned id is the first chunk's so edits and reactions still target
the reply head.
Channel adapter files (Discord, Telegram, …) live on the `channels`
branch — a companion PR wires `maxTextLength: 1900` for Discord and
`4000` for Telegram so the splitter actually engages in those installs.
Without wiring, behavior is unchanged.
Collapses the two-phase setup into a single linear skill: steps 1-6
(prereqs through end-to-end CLI ping) run straight through, steps 7-13
(naming, timezone, channel wiring, mounts, QoL, done) are skippable.
Drops the "chat now vs. continue" branch point — after the ping the
flow emits "Test Agent success, proceeding with setup" and continues
directly into the naming questions.
Also updates stale `/new-setup-2` header comments in setup/install-*.sh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the unknown-channel approval flow completes, seed a /welcome task
into the newly-wired session so the agent greets the new user on first
contact. The replayed /start (Telegram's default first-message) is filtered
by the agent-runner's command-command filter, so without an explicit
onboarding trigger the first interaction produced nothing.
Pin the destination by its local_name from agent_destinations to avoid the
agent picking the wrong named destination (previously it greeted the owner,
whose DM is in CLAUDE.md).
Also guard dispatchResultText against echoing trailing status lines when
the agent has already sent messages explicitly via send_message. Otherwise
a task-triggered flow that calls send_message then emits "welcome message
sent" produces a duplicate chat to the recipient.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapses the two-phase setup into a single linear skill: steps 1-6
(prereqs through end-to-end CLI ping) run straight through, steps 7-13
(naming, timezone, channel wiring, mounts, QoL, done) are skippable.
Drops the "chat now vs. continue" branch point — after the ping the
flow emits "Test Agent success, proceeding with setup" and continues
directly into the naming questions.
Also updates stale `/new-setup-2` header comments in setup/install-*.sh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The welcome DM used to be handed to the running service over the CLI
admin socket, which stamped `cli:local` as the sender. On a strict
messaging group (any fresh DM wired by init-first-agent), that tripped
the unknown-sender approval gate — the operator's own bootstrap script
ended up requesting its own approval. Fix by writing the welcome
directly into the session's inbound.db with a `System` sender; the
running service's host-sweep wakes the container on its next pass.
Also drop `--no-cli-bonus`. Now that init-cli-agent always wires
cli/local to the scratch CLI agent, every caller of init-first-agent
had to pass --no-cli-bonus to avoid double-wiring; the flag has become
mandatory, so it's just removed. The cli-bonus branch goes with it.
Skill docs updated in lockstep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
parseUserId now falls back to user.kind when the id prefix isn't a
registered adapter — Teams uses `29:` rather than `teams:`, so the
literal prefix wouldn't resolve the channel adapter for cold DMs.
init-cli-agent no longer claims the first-owner slot on `cli:local`.
The CLI identity is scratch; owner promotion belongs to
init-first-agent once the real channel user is wired.
Ports the emacs channel skill to match the other channel skills:
copy src/channels/emacs.ts + emacs/nanoclaw.el from the channels branch,
append the self-registration import, enable via EMACS_ENABLED, and wire
through the register setup step. Documents the v2 entity model (single
messaging group, platform_id="default") and drops the v1 auto-register /
symlink behavior that the old adapter did.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These shipped with the old v1 architecture and are no longer needed:
- add-reactions, add-voice-transcription, add-image-vision, add-pdf-reader,
use-local-whisper — Chat SDK channels handle these natively now;
the WhatsApp native (Baileys) adapter on the channels branch covers
attachments and reactions out of the box.
- add-compact — no longer needed.
- add-telegram-swarm — Chat SDK Teams adapter handles multi-bot identity.
- channel-formatting — Chat SDK does per-channel formatting natively.
- add-gmail — was built on a legacy MCP server; deprecated.
add-emacs and use-native-credential-proxy are kept and will be ported
to the current architecture in follow-up commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Agent SDK's default binary resolution picks the musl-linked native
binary (claude-agent-sdk-linux-arm64-musl), which cannot execute on the
Debian-based container image (glibc). Explicitly set
pathToClaudeCodeExecutable to /pnpm/claude — the pnpm global symlink
that resolves to the correct glibc binary regardless of architecture.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Agent SDK's IPC protocol must match the Claude Code version. Also
allowlist @anthropic-ai/claude-code in only-built-dependencies so its
postinstall script runs during Docker build — without this, the native
binary is never installed and the SDK fails at spawn time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pre-commit hook reflowed imports on files changed in the previous commit.
Unrelated format drift on other files intentionally left unstaged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- InboundEvent gains an optional replyTo; router stamps the row's address
fields from it when set, so replies can route to a different channel than
the one the inbound came in on.
- ChannelSetup adds onInboundEvent for admin-transport adapters that build
the full event themselves.
- CLI wire format accepts {text, to, reply_to}. Routed messages go through
onInboundEvent and do not evict an active chat client.
- init-first-agent hands the DM welcome to the running service via
data/cli.sock — synchronous wake, no sweep wait. Fails loudly if the
service is down; no silent fallback.
- Split the CLI scratch-agent bootstrap into scripts/init-cli-agent.ts;
init-first-agent is DM-only.
Agents cannot set replyTo: it lives only on the inbound/router seam and is
consumed once when writing messages_in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Twelve idempotent install scripts matching install-telegram.sh's shape,
one per channel in /new-setup-2's pick list (except Emacs, which uses
a git-merge install flow). Each bundles preflight + fetch + copy +
register + pnpm install + build so a single allowlisted bash call can
replace a chain of permission prompts. Linear's also patches
chat-sdk-bridge.ts for catchAll forwarding; Matrix's runs the
post-install ESM-extension dist patch; WhatsApp-native's covers the
deterministic install portion only — QR/pairing auth still lives in
/add-whatsapp.
Scripts only; new-setup-2/SKILL.md integration deferred pending a
decision on whether to generalize the set-env pattern from 712a0e1
across the Chat SDK channels (each /add-<channel>/SKILL.md's
Credentials section has a similar unapprovable shell chain).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Telegram clients send /start when a user first DMs a bot (and when they
tap "Start" on a bot profile). It's a platform handshake, not a
user-intended prompt — forwarding it to the agent wastes a turn and
produces a confused response.
Matches the existing filter pattern for /help, /login, /logout,
/doctor, /config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three allowlist-friendly setup helpers so /new-setup and /new-setup-2
don't hit unmatchable commands during a fresh install:
- setup/install-node.sh — idempotent Node 22 install wrapper (macOS via brew,
Linux via NodeSource + apt). Replaces the raw `curl | sudo -E bash -` flow
whose stdin-consuming `bash -` segment can't be pre-approved.
- setup/install-docker.sh — same pattern for Docker (brew --cask on macOS,
get.docker.com on Linux + usermod).
- setup/set-env.ts — generic `--step set-env` that writes KEY=VALUE to .env
(and optionally syncs to data/env/env) so channel-install flows don't
invent `grep && sed && rm` pipelines, which split at each && and can't be
tightly allowlisted.
new-setup-2's Telegram path now uses set-env for TELEGRAM_BOT_TOKEN and
explicitly skips /add-telegram's Credentials section. new-setup step 1 and
step 2 now call the install wrappers; the raw curl/apt entries are gone from
the allowed-tools list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Timezone and host-mount prompts now go through AskUserQuestion for a
cleaner UI; channel selection stays plain-prose but is numbered (14
options exceeds the 4-option AskUserQuestion cap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inserts a Timezone step (new step 3) that runs --step timezone and, if
the resolver lands on UTC, asks the user to confirm before leaving UTC
in .env; re-runs with --tz <answer> if they give a real IANA zone.
Renumbers subsequent steps accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch Bash(bash setup/install-telegram.sh) to a prefix match so trailing
flags or redirections don't fall through to approval prompts. Add the
common read-only coreutils (tail, head, grep) the model reaches for to
cap noisy build output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the /add-telegram preflight + install commands into
setup/install-telegram.sh so /new-setup-2 can run the adapter install
programmatically when the user picks Telegram, then hand off to
/add-telegram for credentials and pairing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the router sees a mention or DM on a messaging group that isn't wired
to any agent, it now escalates to an owner for approval instead of silently
dropping. Mirrors the existing unknown-sender approval pattern (ACTION-ITEMS
item 22).
Schema (migration 012):
- `messaging_groups.denied_at TEXT NULL` — timestamp set on deny so future
mentions stop escalating. ALTER TABLE ADD COLUMN, FK-safe (unlike the
rebuild that bit migration 011).
- `pending_channel_approvals` — PK on `messaging_group_id` gives free
in-flight dedup. One card per channel, no spam on rapid retries.
Router:
- New hook `setChannelRequestGate(mg, event) => Promise<void>`, invoked
from the no-wirings branch when the message was addressed to the bot
(isMention=true). Hook is fire-and-forget.
- Checks `mg.denied_at` before escalating — denied channels drop silently
and do not re-prompt.
- The two "no-wirings" branches (fresh auto-create and existing mg with
no agents) are consolidated into one escalation path that calls the
gate once. Without the module, behavior is log + record (no regression).
Permissions module:
- `channel-approval.ts::requestChannelApproval` — MVP picker: target
agent is `getAllAgentGroups()[0]`, card names it explicitly ("Wire it
to <Andy>?"). Approver via existing `pickApprover` + `pickApprovalDelivery`
primitives.
- Response handler: same click-auth pattern as sender-approval (clicker
must be the designated approver OR have admin privilege over the
target agent group).
- Approve defaults per the feature spec:
engage_mode = 'mention-sticky' for groups, 'pattern' + '.' for DMs
sender_scope = 'known'
ignored_message_policy = 'accumulate'
session_mode = 'shared'
DM vs group inferred from the original event's threadId (non-null →
group) because the auto-created mg has a placeholder is_group=0 until
the adapter fills it in.
- Triggering sender is auto-added to agent_group_members so sender_scope=
'known' doesn't bounce the replayed message into a sender-approval
cascade.
- Deny: stamps messaging_groups.denied_at, clears pending row.
- Failure modes — no owner, no agent groups, no reachable DM — log and
drop without creating a pending row, letting a future attempt try
again (same as sender-approval).
9 new integration tests cover every branch: mention triggers card, DM
triggers card, dedup, approve creates correct wiring + admits sender +
replays, approve-on-DM uses pattern/'.' defaults, deny sets denied_at
and future mentions drop silently, unauthorized clicker rejected,
no-owner drops, no-agent-groups drops.
168 tests pass (was 159; +9).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to b159722. That shrank the bridge's shouldEngage to a flood
gate + coarse sticky-subscribe signal. This completes the move —
policy lives exclusively in the router, the bridge is transport-only,
and the conversations map + ChannelSetup.conversations +
ChannelAdapter.updateConversations are all gone.
Key shifts:
1. Subscribe moves from bridge to router.
Bridge used to call `thread.subscribe()` from its onNewMention /
onDirectMessage handlers based on a coarse "any mention-sticky wiring
exists on this channel" check. That forced the decision before the
router could apply per-wiring engage logic, and it relied on the
conversations map being current (staleness risk).
ChannelAdapter gains `subscribe?(platformId, threadId)`. The Chat
SDK bridge implements it via SqliteStateAdapter.subscribe(threadId)
(idempotent — a repeat call on an already-subscribed thread is a
no-op). The router's fan-out loop calls it once per message when
the first mention-sticky wiring actually engages. Precise, not
coarse.
2. Short-circuit the drop path with one combined query.
New `getMessagingGroupWithAgentCount(channelType, platformId)` does
the messaging_groups lookup AND counts wirings in a single SELECT,
using the existing UNIQUE(channel_type, platform_id) index on
messaging_groups and UNIQUE(messaging_group_id, agent_group_id) on
messaging_group_agents for the JOIN. No new indexes needed.
routeInbound now short-circuits:
- No messaging_groups row AND not addressed (no mention/DM)
→ return silently. One DB read, nothing written. This is the
Discord-bot-in-a-big-guild case; we no longer auto-create rows
for every plain message in every channel the bot can see.
- Messaging group exists but no wirings AND not addressed
→ return silently. One DB read.
- Otherwise fall through to sender resolution + fan-out as before.
Behavioral change: plain chatter on unwired channels no longer gets
dropped_messages audit rows, which used to bloat the table. Audit
still fires on addressed-to-bot drops where the admin cares
("someone @-mentioned us but nobody's wired").
3. Bridge is now purely transport.
Deleted entirely: ConversationConfig, ChannelSetup.conversations,
ChannelAdapter.updateConversations?, bridge's `conversations` map,
buildConversationMap, shouldEngage, EngageSource, engageDecision,
bridge.updateConversations method, src/index.ts
buildConversationConfigs. Four handlers reduce to "resolve channel
id, build InboundMessage with isMention, call onInbound". Net
~130 LOC deleted from the bridge.
Collateral: the conversations-map staleness problem is gone. The
upcoming channel-registration feature doesn't need any map-refresh
plumbing — when an approval creates a new wiring, the next message
hits the DB fresh and just works.
Bridge tests prune to the narrow platform-adjacent surface (openDM
delegation, subscribe presence). Host-core test that asserted the
old "auto-create on every unknown message" behavior updates to
reflect the new escalation-gated semantics: plain messages on
unknown channels don't auto-create, mentions do.
159 tests pass (was 172 — net -13, almost entirely from
bridge-engage-mode tests that covered logic now owned by the router
and exercised through host-core.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New /new-setup-2 skill, invoked when the user picks "continue setup"
at the end of /new-setup. Linear rollthrough; every step skippable:
1. What should the agent call you?
2. What's your agent's name?
3. Messaging channel (plain list, no AskUserQuestion) — invokes the
matching /add-<channel> skill, captures platform IDs from its
output, then wires via init-first-agent.ts with --no-cli-bonus.
On success, emits the encouragement line verbatim.
4. Quality-of-life picks (dashboard, compact, karpathy-wiki, plus
macos-statusbar only when the probe reports PLATFORM=darwin).
5. Wrap-up.
scripts/init-first-agent.ts gains a --no-cli-bonus flag. In DM mode,
the bonus "wire new agent to CLI" call is skipped when set. Used by
/new-setup-2 so the throwaway CLI-only agent from /new-setup retains
clean single-agent ownership of CLI routing instead of being duelled
by the real agent on the same channel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 (CLI agent wiring + first chat) is now invisible to the user.
No prompts, no narration — just silent wiring with INFERRED_DISPLAY_NAME
and a background ping. On the ping's return, emit one line:
Your agent is up, running and ready to go!
Step 7 becomes a branch point via AskUserQuestion: either keep chatting
via CLI (prints two how-to-chat options: the `!pnpm run chat` bang
method inside Claude Code, and the separate-terminal form), or continue
to /new-setup-2 for the post-install flow (naming, messaging channel,
QoL).
The CLI agent at this stage is a scratch agent — its only job is to
verify the end-to-end pipeline works. The real name capture happens in
/new-setup-2 when the user wires a messaging channel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this change the bridge and the router both owned engage_mode
policy. Bridge's shouldEngage had a full switch over mention /
mention-sticky / pattern + source-based rules + engage_pattern regex
test + ignored_message_policy accumulate fallback. Router's
evaluateEngage had the same switch against the same fields. Two
parallel logic paths with subtle vocabulary differences (bridge: "which
SDK handler fired"; router: "what isMention says"). Every time we
touched one we had to reason about the other — the Telegram
hasMention bug and the "pattern mode silently drops in group chats"
bug were both drift between the two.
Refactor to one place. Router keeps all per-wiring policy — engage
mode, pattern regex, sender scope, ignored-message policy — unchanged.
Bridge drops to a coarse flood gate + subscribe signal:
- forward: does this channel have ANY wiring? Forward if yes.
Unknown channels still forward for subscribed/mention/dm (they may
be newly auto-created, or will trigger the coming
channel-registration flow). Unknown channels DROP for new-message
so we don't flood from every unsubscribed thread the bot happens
to sit in.
- stickySubscribe: any mention-sticky wiring on the channel AND the
source is mention or dm. Coarse union — subscribe is idempotent
and one call serves every sticky wiring.
The `text` param on shouldEngage is gone (pattern regex lives in the
router now). Four bridge handler sites simplify accordingly. messageToInbound
still carries the SDK-confirmed isMention flag through to the router
unchanged.
Behavioral delta: pure-mention-wired channels (no pattern, no
accumulate) will now see every plain group message reach the router
before being dropped there, where before the bridge dropped at the
transport boundary. Extra DB lookup per dropped message in this
specific case; acceptable for the cleaner seam and can be optimized
back at the bridge if it ever matters in practice.
Bridge tests prune the 10 engage_mode-specific cases that covered
logic now owned by evaluateEngage in the router (host-core.test.ts
covers it end-to-end through routeInbound). Bridge tests keep only
what's bridge-specific: the flood gate and the stickySubscribe
coarse union.
172 tests pass (was 182 — net -10 redundant bridge tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
signal-sdk launches signal-cli without --config, so attachments land at
~/.local/share/signal-cli/attachments/ (XDG default) rather than
data/signal/. Document this in the Channel Info section and add a
troubleshooting entry explaining the symptom (voice messages silently
skipped, no transcript), how to confirm (ps aux | grep signal-cli), and
the automatic fallback the adapter uses.
The approval click handler trusted row.approver_user_id as the actor
regardless of who actually clicked the card. A random user who received
the forwarded card could click Approve and get the stranger admitted
to the agent group — their click was simply not checked.
Separately, payload.userId arrives as the raw platform userId from
Chat SDK onAction (e.g. "6037840640"), not the namespaced form
("telegram:6037840640") that matches users(id). Without namespacing,
users-table lookups miss.
Namespace the clicker id with payload.channelType, then authorize: the
clicker must be either the designated approver OR have
owner / admin privilege over the agent group (hasAdminPrivilege covers
owner, global admin, scoped admin). Unauthorized clicks return true
(claim the response so the registry doesn't log it as unclaimed) but
take no action — the pending row stays in place so a legitimate
approver can still act on it.
Existing tests passed a pre-namespaced userId directly, masking the
first bug. Fixed the fixtures to match production plumbing and added
two tests: one asserts a random bystander's click is rejected (row
stays pending, no member added), the other asserts a global admin can
approve even when they weren't the designated approver.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The router's mention / mention-sticky engage check was regex-matching
@<agent_group.name> (e.g. @Andy) against message text. Platforms don't
work that way — users address bots via the bot's platform username
(@nanoclaw_v2_refactr_1_bot on Telegram, user-id mentions on Slack /
Discord). The regex matched only coincidentally and never on Telegram,
so mention-mode wirings silently never fired there.
Two parallel mention detectors existed: the Chat SDK's onNewMention,
which correctly resolves the bot's platform identity, and the router's
hasMention text regex, which ignored the SDK verdict and invented its
own heuristic. The router's detector was wrong in principle — the agent
group's display name is a NanoClaw-side nickname, not a platform
address.
Thread the SDK signal through: InboundMessage gains an optional
`isMention` field, the bridge sets it from each handler (onNewMention →
true, onDirectMessage → true, onSubscribedMessage → message.isMention,
onNewMessage(/./) → false), src/index.ts forwards it into InboundEvent,
and evaluateEngage now checks `isMention === true` for mention modes.
hasMention deleted entirely — there is only one source of truth for
"did the user mention this bot": the platform / SDK.
Agent-name-in-text matching for disambiguating multiple agents wired to
one chat is a separate feature; users can express it today with
engage_mode='pattern' + the agent's name as the regex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
decideStuckAction treated a missing heartbeat file as heartbeatAge =
Infinity, which always exceeded the 30-minute ceiling. Result: every
freshly-spawned container got killed within seconds of spawn on the
first sweep pass because it hadn't produced an SDK event yet (heartbeat
is only touched on SDK events inside processQuery, not on boot).
Skip the ceiling branch when heartbeatMtimeMs === 0. Containers that
genuinely never wrote a heartbeat because they died are caught by the
separate "container process not running" cleanup path. Containers that
boot, claim a message, but hang at the gate are caught by the
claim-stuck check below — which correctly fires regardless of heartbeat
presence once claimAge exceeds tolerance.
Updates the "absent heartbeat → kill-ceiling" test (which was encoding
the bug) and adds a companion that the claim-stuck path still fires for
absent-heartbeat containers with aged claims.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the /add-signal-v2 skill: a native Signal channel adapter wrapping
signal-sdk (signal-cli under the hood). No bot API — NanoClaw registers
as a full Signal account on a dedicated number or as a linked device.
Features: text, group & DM routing, voice transcription via whisper.cpp,
attachments, emoji reactions, @mention detection, quote-reply detection.
Troubleshooting note updated: GroupV2 group ID lives at
envelope.dataMessage.groupV2.id — not groupInfo.groupId (GroupV1/legacy).
A warm container picks up every pending messages_in row on each poll tick
and calls markProcessing → agent.query → markCompleted. Before this, that
included trigger=0 rows (ignored_message_policy='accumulate' context),
causing the agent to wake and potentially respond to messages the wiring
had explicitly opted out of engaging on — defeating accumulate's "store
as context, don't engage" contract.
Gate the main poll loop with `messages.some(m => m.trigger === 1)` —
mirrors host-side countDueMessages which is already gated. If the batch
has no wake-eligible row, sleep and leave them pending. They ride along
via the same getPendingMessages query the next time a real trigger=1
lands, which is the intended accumulate behavior.
The concurrent active-turn poll (line ~290) is unchanged on purpose —
once the agent has engaged, pushing in accumulate rows mid-turn as
additional context is desired.
initTestSessionDb was missing the trigger and series_id columns on
messages_in, out of sync with the live migration. Added both so the new
tests (and any future trigger-aware tests) can run.
Four tests cover the data contract: trigger=0 rows are returned by
getPendingMessages (so they ride along), the gate predicate correctly
identifies accumulate-only batches, mixed batches pass the gate, and the
schema default of 1 applies when the column is omitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The router + session DB were already fully plumbed for
ignored_message_policy='accumulate' — fan-out in routeInbound calls
deliverToAgent(wake=false) for non-engaging agents on accumulate wirings,
writeSessionMessage writes trigger=0, countDueMessages filters trigger=1,
container formatter includes all messages regardless of trigger. But the
Chat SDK bridge dropped non-engaging messages before the router ever saw
them, so accumulate was dead on arrival for every adapter that goes
through the bridge.
Expose ignored_message_policy on ConversationConfig, project it in
buildConversationConfigs, and widen shouldEngage's "forward" decision to
"engage OR accumulate" with the union taken across all wirings on a
conversation. stickySubscribe stays gated on a real engage — subscribing
a thread we'd only silently accumulate on would misrepresent the bot's
presence.
shouldEngage return shape is now { forward, stickySubscribe } — engage
was an internal concept the caller never needed, and conflating it with
forward was the source of this bug.
7 new tests cover: non-engaging messages forwarding under accumulate,
mixed drop/accumulate wirings taking the union, accumulate not
triggering sticky subscribe, unknown-conversation drop precedence over
accumulate, and drop policy preserving existing behavior on engaging
messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chat SDK dispatch (per handling-events.mdx) is exclusive and prioritized:
subscribed → onSubscribedMessage; unsubscribed + mention → onNewMention;
unsubscribed + pattern match → onNewMessage. We never registered the third,
so engage_mode='pattern' silently dropped every message in unsubscribed
group threads — the SDK simply never surfaced them anywhere.
Register chat.onNewMessage(/./, …) and route it through shouldEngage with
a new 'new-message' source. Unknown-conversation policy drops for this
source (would otherwise flood from every unwired channel the bot can see).
mention / mention-sticky wirings ignore 'new-message' — they require an
explicit @mention to start a conversation. Pattern wirings evaluate
normally.
Extracted shouldEngage from a closure to an exported function with an
EngageSource type so it's unit-testable. Added 17 tests covering every
source × engage-mode combination, unknown-conversation behavior, invalid
regex fail-open, and multi-wiring union.
Accumulate (ignored_message_policy='accumulate') is still not plumbed —
the bridge drops non-engaging messages entirely instead of forwarding
them as context-only. That requires a trigger: 0 | 1 field on
InboundMessage → router → writeSessionMessage (schema already has the
column). Separate change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The engage modes shipped in #1869 included `pattern` (regex match any
message) and the `accumulate` ignored-message policy, but neither could
fire in group chats because Chat SDK only surfaces:
- DMs (onDirectMessage)
- @mentions in unsubscribed threads (onNewMention)
- every message in subscribed threads (onSubscribedMessage)
A bot sitting in a Discord/Slack channel hears *nothing* from a plain
message unless the thread is already subscribed. So `pattern '.'` on a
group wiring → silent. `pattern /urgent/i` → silent. `mention +
accumulate` → the non-mention messages that should be stored as context
were never received, so nothing to accumulate.
Fix: call `chat.subscribe(platformId)` at setup time for every wiring
whose `engageMode === 'pattern'` or `ignoredMessagePolicy === 'accumulate'`.
Failures logged + swallowed per-conversation so one un-subscribable
channel doesn't crash startup.
## Knock-on: SDK stops firing onNewMention once subscribed
Per SDK types:1468, `onNewMention` only fires in unsubscribed threads.
Once we pre-subscribe a channel for a pattern wiring, subsequent mentions
arrive as `onSubscribedMessage` with `message.isMention === true`.
Before: a `mention` wiring coexisting with a `pattern` wiring in the
same channel would silently stop firing after pre-subscribe.
After: `shouldEngage` accepts the `isMention` flag independently from
`source`, so the `mention` mode matches on (dm OR mention-new OR
subscribed-with-isMention). Source shape changed
`'subscribed' | 'mention' | 'dm'` → `'subscribed' | 'mention-new' | 'dm'`
to make the "unsubscribed-mention event" distinction explicit.
## New fields
- `ConversationConfig.ignoredMessagePolicy` — projected from the
messaging_group_agents row so the bridge knows which wirings need
pre-subscription. buildConversationConfigs in src/index.ts populates
it.
Tests: host 153/153, container 46/46. No new tests yet — the subscribe
call path needs a Chat mock, deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original 011 also rebuilt `messaging_groups` to flip the
`unknown_sender_policy` column DEFAULT from "strict" to "request_approval".
On live DBs the DROP TABLE step fails SQLite's foreign-key integrity
check because `sessions`, `user_dms`, and `pending_sender_approvals` all
reference `messaging_groups(id)`. `PRAGMA foreign_keys=OFF` /
`defer_foreign_keys` can't be toggled inside the implicit migration
transaction, so the rebuild can't be made to apply cleanly.
The default-flip was cosmetic anyway: every `createMessagingGroup`
callsite passes `unknown_sender_policy` explicitly. Router auto-create
was already updated to hardcode "request_approval" (router.ts:151), and
setup / seed scripts pick per context.
Changes:
- Migration 011 now only creates the `pending_sender_approvals` table +
index. The rebuild block is gone.
- Reference `SCHEMA` in src/db/schema.ts updated to reflect what the
DB actually has: DEFAULT 'strict' (from migration 001), with a note
about the effective policy applied at insert sites.
Discovered on v2 post-merge during live restart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The engage-mode gating added in #1869 read `message.content` from the
Chat SDK's ChatMessage in all three inbound handlers (onSubscribedMessage,
onNewMention, onDirectMessage). ChatMessage exposes the user-visible
string as `.text` — `.content` exists on the underlying nested structure
but isn't the plain-text field. Result: `shouldEngage` always saw an
empty string, pattern gating never matched, non-wildcard regex wirings
silently dropped every inbound.
Fix: use `message.text` in all three gates.
Discovered during live smoke-test on v2 post-merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match v1 behavior: drop getApiHost() (which was returning the CLI default
https://app.onecli.sh) and always extract the gateway URL from the install
script's stdout, then apply it via onecli config set api-host and .env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the gap item 5 left open: request_approval presupposes a wired
channel, so unknown-channel cases (new DM, @mention in unwired group,
bot added to fresh group) short-circuit at no_agent_wired before the
approval flow runs.
Design:
- Owner-sender auto-wire fast path (exactly one agent group → wire
silently; multiple → card)
- Card with one button per existing agent group + "Create new" + "Ignore"
- New pending_channel_approvals table, UNIQUE(messaging_group_id)
- nca- action-id prefix paralleling nsa- / ncq-
- Handler lives alongside handleSenderApprovalResponse
- "Create new" sub-flow is intentionally open scope
Cross-reference added to item 5 so the scope boundary is explicit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an unknown sender writes into a wired messaging group, surface the
situation to an admin instead of silently dropping. Flow:
1. Router → access gate → handleUnknownSender (policy='request_approval')
2. Fire-and-forget requestSenderApproval: pickApprover + pickApprovalDelivery
pick a reachable admin DM; deliver an Approve / Deny card; insert a
pending_sender_approvals row carrying the original InboundEvent JSON.
3. In-flight dedup: UNIQUE(messaging_group_id, sender_identity) — a retry
from the same stranger while pending is silently dropped, not re-carded.
4. Admin clicks → Chat SDK bridge → onAction → host response-registry.
The new handleSenderApprovalResponse in the permissions module claims
responses whose questionId matches a pending_sender_approvals row.
5. approve: addMember(stranger, agent_group) + replay the stored event via
routeInbound — the second attempt clears the gate because the user is
now known.
6. deny: delete the pending row. No denial persistence (ACTION-ITEMS item 5
decision) — a future attempt triggers a fresh card.
Schema:
- Migration 011 adds pending_sender_approvals (id, mg_id, agent_group_id,
sender_identity, sender_name, original_message JSON, approver_user_id,
created_at, UNIQUE(mg_id, sender_identity)).
- Also flips messaging_groups.unknown_sender_policy default from 'strict'
to 'request_approval' (rebuild-table). Existing rows unchanged — only
the default applied to new rows flips.
- Router auto-create for unknown platform/chat drops the hardcoded
'strict' override; schema default applies.
- src/db/schema.ts reference updated to match.
Why default-flip: users wire their DM during setup and don't discover that
'strict' means "silent drop of everyone not in user_roles/members". The
approval flow is the safe default — the admin sees the stranger, explicitly
decides. 'public' stays opt-in for truly open channels.
Failure modes (row NOT created so a future attempt can try again):
- No eligible approver configured (fresh install before first owner).
- No reachable DM for any approver.
- Delivery adapter missing.
Tests (src/modules/permissions/sender-approval.test.ts, 4 cases):
- First unknown message → card delivered + row created
- Retry while pending → dedup'd (1 card, 1 row)
- Approve → member added + message replayed + container woken
- Deny → row cleared + no member added
Closes: ACTION-ITEMS item 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the opaque trigger_rules JSON + response_scope enum on
messaging_group_agents with four explicit orthogonal columns:
engage_mode 'pattern' | 'mention' | 'mention-sticky'
engage_pattern regex source; required when mode='pattern';
'.' is the "always" sentinel
sender_scope 'all' | 'known'
ignored_message_policy 'drop' | 'accumulate'
Inbound routing becomes a fan-out — every wired agent is evaluated
independently. A match gets its own session + container wake. A miss
with accumulate keeps the message as context-only (trigger=0) in that
agent's session, so when the agent does eventually engage it sees the
prior chatter.
## Schema
- Migration 010 (`engage-modes`): adds the 4 new columns, backfills
from trigger_rules.pattern + requiresTrigger + response_scope, drops
the legacy columns.
- messages_in gains `trigger INTEGER NOT NULL DEFAULT 1` (session DB
schema + `migrateMessagesInTable` forward-compat).
- countDueMessages gates waking on `trigger = 1`.
## Routing
- `pickAgent` (returns one) → loop over all wired agents. Per agent:
evaluate engage_mode; run access gate + sender-scope gate; on full
match → resolveSession + writeSessionMessage(trigger=1) + wake. On
miss with accumulate → writeSessionMessage(trigger=0), no wake. On
miss with drop → skip.
- New `findSessionForAgent(agentGroupId, mgId, threadId)` scopes
session lookup by agent so fan-out doesn't cross sessions.
- `messageIdForAgent` namespaces inbound message ids by agent_group_id
so PRIMARY KEY doesn't collide across per-agent session DBs.
## Adapter layer
- `ConversationConfig` replaces `triggerPattern` + `requiresTrigger`
with `engageMode` + `engagePattern`.
- Chat SDK bridge stores `Map<platformId, ConversationConfig[]>` (multi-
agent per conversation) and applies union gating pre-onInbound:
* onSubscribedMessage: engage if any wiring keeps firing in
subscribed state (mention-sticky or pattern)
* onNewMention: engage on mention; only subscribes the thread if
at least one wiring is `mention-sticky`
* onDirectMessage: engage per mode; sticky follows same rule
- Bridge no longer unconditionally calls `thread.subscribe()`.
## Sender scope
- Permissions module registers a second hook `setSenderScopeGate` that
runs per-wiring after the existing access gate. `sender_scope='known'`
requires canAccessAgentGroup(); `'all'` is a no-op. Not installed →
no-op everywhere (default allow).
## Container side
- Host passes `NANOCLAW_MAX_MESSAGES_PER_PROMPT` (reuses existing
MAX_MESSAGES_PER_PROMPT config; was dead code from v1).
- `getPendingMessages` queries `ORDER BY seq DESC LIMIT N`, reverses to
chronological order for the prompt — accumulated context rides along
with trigger rows up to the cap.
- `MessageInRow` gains `trigger: number` so the container can tell them
apart in downstream code (container still processes both; only the
host uses `trigger=0` for don't-wake).
## Defaults (per ACTION-ITEMS item 1 decision)
- DM (is_group=0): `engage_mode='pattern'`, `engage_pattern='.'` (always)
- Threaded group: `engage_mode='mention-sticky'` (seed-discord)
- Non-threaded group / CLI: pattern '.' in bootstrap scripts
## Tests
- src/host-core.test.ts: 3 new cases — fan-out (2 agents, 2 sessions,
2 wakes), accumulate (trigger=0 + no wake), drop (no session created).
- Existing 10 host-core tests still pass.
- Migration 010 runs on an empty DB in 0-row path — verified.
Closes: ACTION-ITEMS items 1, 4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the two overlapping old mechanisms (30-min setTimeout kill in
container-runner, 10-min heartbeat STALE_THRESHOLD reset in host-sweep)
with message-scoped stuck detection anchored to the processing_ack claim
age + an absolute 30-min ceiling that extends for long-declared Bash
tools.
Old model problems:
- IDLE_TIMEOUT setTimeout fired on plain wall-clock time; slow-but-alive
agents got killed at 30min regardless of activity
- 10-min STALE_THRESHOLD in the sweep was unreliable — the heartbeat is
only touched on SDK events, so legitimate silent tool work (sleep 30,
long WebFetch, npm install) looked identical to a hung container
- Two overlapping sources of truth for "when to let go of a container"
New model:
- Host sweep is the single source of truth.
- Container exposes a new `container_state` single-row table in outbound.db
(schema added; container writes, host reads). PreToolUse hook writes
current_tool + tool_declared_timeout_ms (read from Bash's tool_input);
PostToolUse / PostToolUseFailure clear it.
- Sweep decides with a pure helper `decideStuckAction`:
* absolute ceiling — kill if heartbeat age > max(30min, bash_timeout)
* per-claim stuck — kill if any processing_ack row has claim_age >
max(60s, bash_timeout) AND heartbeat hasn't been touched since claim
* otherwise ok
Kill paths reset leftover processing rows with exponential backoff,
reusing the existing retry machinery.
Tool blocklist expanded:
- AskUserQuestion (SDK placeholder; we have mcp__nanoclaw__ask_user_question)
- EnterPlanMode, ExitPlanMode, EnterWorktree, ExitWorktree (Claude Code UI
affordances; would hang in headless containers)
PreToolUse hook is also defense-in-depth: if a disallowed tool name slips
through, it returns `{ decision: 'block' }` so the agent sees a clear
error instead of appearing stuck.
Removed:
- container-runner.ts: IDLE_TIMEOUT setTimeout, resetIdle callback on
activeContainers entry, resetContainerIdleTimer export.
- delivery.ts: the resetContainerIdleTimer call on successful delivery.
- poll-loop.ts: IDLE_END_MS + its setInterval. Keeping the query open is
cheaper than close+reopen (no cold prompt cache). Liveness is now a
host-side concern.
- host-sweep.ts: 10-min STALE_THRESHOLD_MS + getStuckProcessingIds in the
stale-detection path (still exported for kill reset).
Tests:
- src/host-sweep.test.ts — 9 tests for decideStuckAction covering: fresh
heartbeat, absolute ceiling, absent heartbeat, Bash-timeout extension
(both ceiling and per-claim), claim age below tolerance, heartbeat
touched after claim, unparseable timestamps.
Ref: docs/v1-vs-v2/ACTION-ITEMS.md items 9, 6a, 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The agent needs to perceive times in the user's timezone, not UTC.
Dropping this in the v1→v2 port produced a class of bugs where the agent
would schedule tasks for the wrong hour, suggest dinner at midnight, etc.
This restores v1 parity.
Container side:
- New container/agent-runner/src/timezone.ts mirrors src/timezone.ts with
isValidTimezone / resolveTimezone / formatLocalTime, plus:
* TIMEZONE constant resolved at load from process.env.TZ (host sets this
from src/container-runner.ts:254)
* parseZonedToUtc(input, tz) — treats a naive ISO as wall-clock time in
`tz`, returns the corresponding UTC Date. Strings with Z or offset
are passed through.
- formatter.ts:
* formatMessages() now prepends <context timezone="IANA"/>\n — matches
v1 src/v1/router.ts:20-22
* formatSingleChat uses formatLocalTime(ts, TIMEZONE) instead of a
home-rolled HH:MM 24h formatter → outputs like "Jun 15, 2026, 8:00 AM"
* reply_to="<id>" attribute + <quoted_message from="X">Y</quoted_message>
element — matches v1 format exactly; old <reply-to/> shape is gone
* stripInternalTags() exported for the dispatch path to reuse
- poll-loop.ts uses the exported stripInternalTags() instead of inline regex.
- mcp-tools/scheduling.ts:
* schedule_task/update_task descriptions now explicitly document that
processAfter accepts either UTC or naive local time (interpreted in
the user's TZ from the context header)
* handlers normalize through parseZonedToUtc() and store a UTC ISO
Host side:
- src/modules/scheduling/recurrence.ts passes { tz: TIMEZONE } to
CronExpressionParser.parse. Without this, "0 9 * * *" fires at 09:00
UTC instead of 09:00 user-local — this was the v1 behavior
(src/v1/task-scheduler.ts:20-49).
Tests:
- container/agent-runner/src/timezone.test.ts — mirror of src/timezone.test.ts
+ new parseZonedToUtc cases
- container/agent-runner/src/formatter.test.ts — context header, reply_to,
quoted_message, XML escaping, stripInternalTags (ported from v1
formatting.test.ts)
- src/modules/scheduling/recurrence.test.ts — cron TZ respected, completed
rows only cloned when recurrence is set
Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 18 + timezone-formatting-v1-recreation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These three constants were carried over from v1's polling + IPC architecture
and have zero callers in the v2 runtime:
- POLL_INTERVAL (2000ms) — v1 message loop; replaced by event-driven
delivery + delivery.ts's ACTIVE_POLL_MS (hardcoded 1000ms)
- SCHEDULER_POLL_INTERVAL (60000ms) — v1 task scheduler; replaced by
host-sweep.ts's SWEEP_INTERVAL_MS (hardcoded 60_000)
- IPC_POLL_INTERVAL (1000ms) — v1 file-based IPC; meaningless in v2's
session-DB architecture
Grep confirms no imports in src/, container/, or tests. Docs/SPEC.md
updated to match.
Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/v1-vs-v2/: full v1→v2 regression analysis (SUMMARY + 21 per-module
docs + ACTION-ITEMS rollup with decisions + timezone recreation spec).
- container/agent-runner/scripts/sdk-signal-probe.ts: empirical harness
used to characterise Claude Agent SDK event/hook/stderr timing for the
stuck-detection design in item 9.
- src/channels/chat-sdk-bridge.ts: document the conversations Map staleness
in a code comment; fix deferred to when dynamic group registration lands
(ACTION-ITEMS item 17).
No runtime behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Pin @opencode-ai/sdk and opencode-ai CLI both to 1.4.17; warn against
latest (1.14.x has a breaking session API rewrite incompatible with
the current provider code)
- Add step 7: propagate provider files into existing per-group overlays
(data/v2-sessions/*/agent-runner-src/providers/) which override the
image at runtime and are never auto-updated by rebuilds
- Add build cache gotcha: prune builder if "Unknown provider" after rebuild
- Document ANTHROPIC_BASE_URL as required for non-anthropic providers,
with correct base URL per provider (DeepSeek, OpenRouter examples)
- Add OPENCODE_SMALL_MODEL to all examples
- Document OneCLI credential grant (set-secrets replaces, not appends)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrite SKILL.md with tested setup: OAuth app with client credentials
(recommended), bridge catchAll patch for platforms without @-mention,
LINEAR_TEAM_KEY for team-based routing, webhook setup with delay note,
private vs public sender policy, and wiring example.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update SKILL.md with tested setup: dedicated bot account prerequisite,
GITHUB_BOT_USERNAME env var for @-mention detection, private vs public
repo sender policy guidance, member registration for strict mode,
per-thread session mode, and wiring example.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The probe now returns a real snapshot from second zero, so every step
consults real probe fields instead of falling back to "run every step
blindly" when Node isn't installed. Also drops the redundant
CLI_AGENT_WIRED field (it gated the last step on its own end-state) and
scopes timezone out of the probe (timezone is not part of /new-setup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
onecli step:
- Poll /api/health (was /health) so the step's health check matches
the probe's. On hosted OneCLI (app.onecli.sh) the old path returned
non-ok, flagging the gateway as "degraded" even though install
succeeded.
- Drop the "try `onecli start`" hint — no such subcommand exists and
it sent the skill off chasing fabricated commands. A failed health
poll is demoted to a soft warning; the auth step surfaces a real
outage via `onecli secrets list`.
SKILL.md step 4: rewrite to match the /setup skill's pattern — the
user generates the token themselves, picks dashboard or CLI to
register it with OneCLI, and the skill verifies via `auth --check`.
Tokens no longer travel through chat.
Co-Authored-By: Koshkoshinsk <daniel.milliner@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Probe now emits HOST_DEPS (ok|missing) based on whether
node_modules/better-sqlite3/build/Release/better_sqlite3.node exists
— the canonical proof that `pnpm install` ran and the native build
step succeeded. Step 1 (Node bootstrap) skips when HOST_DEPS=ok
instead of always re-running setup.sh. Probe now genuinely routes
step 1 the same way it routes every other step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two flow fixes:
1. Add "Ordering and parallelism" section making explicit that step 4
(auth) must block until step 3 (OneCLI) is complete — auth writes
the secret into the vault, so firing an AskUserQuestion while
OneCLI is still installing asks the user for a credential the
system can't store. Step 2 (container build) is safe to run past
step 4, joined before step 6 (first CLI agent).
2. Drop the per-step quoted one-liners. They duplicated Claude's own
natural narration ("While those build, let's get your credential
set up." → immediately echoed by the scripted "Your agent needs an
Anthropic credential..."). Each step now has a short description
instead; Claude narrates in its own voice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes to the fresh-install path:
1. setup.sh: when `corepack enable` runs as a non-root user against a
system-wide Node install (apt-installed to /usr/bin), it fails EACCES
trying to symlink /usr/bin/pnpm, leaving pnpm off PATH. Retry with
sudo when pnpm is still missing — gated to Linux/WSL so macOS
Homebrew prefixes aren't polluted with root-owned shims.
2. SKILL.md step 1: if the probe reports STATUS: unavailable (Node not
installed), install Node BEFORE invoking `bash setup.sh`. The old
flow ran setup.sh first as a diagnostic, which always failed fast,
installed Node, then re-ran — two bootstraps for no reason.
Combined: fresh Linux box now goes Node install -> single setup.sh run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chained `&& / ||` inline command tripped Claude Code's per-operation
permission check. Move the Node-missing fallback into setup/probe.sh so
the skill's `!` block is a single `bash setup/probe.sh` call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port probe to zero-dep plain ESM (setup/probe.mjs) so /new-setup can
inject dynamic context on a fresh machine where pnpm/node_modules
don't yet exist. Skill falls back to a STATUS: unavailable block if
Node itself isn't on PATH, and the flow treats that as "run every
step from 1" (each step is idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shortest path from zero to a working two-way agent chat via the CLI
channel. Renders `!`pnpm exec tsx setup/index.ts --step probe`` at the
top for dynamic context injection — Claude sees current system state
before generating its first response and routes each subsequent step
(skip/ask/run) off the probe snapshot. Pre-approves the Bash patterns
it needs via `allowed-tools` so setup runs without per-step prompts.
Lives alongside /setup for now; will replace it once proven.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single upfront parallel scan the SKILL.md renders via `!`...`` so Claude
sees system state before generating its first response. Each field maps
to a routing decision (skip/run/ask) for a downstream step.
Reports: OS, SHELL, DOCKER + IMAGE_PRESENT, ONECLI_STATUS + ONECLI_URL,
ANTHROPIC_SECRET, SERVICE_STATUS, CLI_AGENT_WIRED, INFERRED_DISPLAY_NAME,
TZ_STATUS + TZ_ENV + TZ_SYSTEM. Runs in ~200ms on a fully-set-up host.
Not a replacement for per-step idempotency — each step keeps its own
checks since probe is a snapshot and can go stale by execution time.
Uses /api/health (OneCLI's actual endpoint). Anthropic secret check
uses the CLI client so it works whenever onecli is installed, even if
the direct HTTP health probe fails (different network paths).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aggregates the loose OneCLI install, secret registration, and first-agent
wiring commands from /setup into three new dispatcher steps. Adds
--cli-only mode to init-first-agent so /new-setup can reach a working
2-way CLI chat with the bare minimum.
- setup/onecli.ts: idempotent install + PATH + api-host + .env, polls /health
- setup/auth.ts: --check verifies secret; --create --value registers it
- setup/cli-agent.ts: wraps init-first-agent --cli-only
- scripts/init-first-agent.ts: --cli-only mode; DM mode unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new operational skill that routes any agent group to a local
Ollama instance instead of the Anthropic API. Ollama speaks the
Anthropic /v1/messages endpoint natively, so no new provider code is
needed — just env var overrides and a model setting in the shared
settings file.
The skill also documents and applies two prerequisite source changes:
- ContainerConfig gains env and blockedHosts fields (container-config.ts)
- container-runner wires those fields as -e and --add-host Docker flags
- Dockerfile home dir set to chmod 777 so containers running as the
host uid can write ~/.claude config (discovered during implementation)
docs/ollama.md covers the architecture, OneCLI proxy bypass rationale,
network isolation via blockedHosts, model selection tradeoffs for Apple
Silicon, and revert instructions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single forward-looking reference that replaces the two untracked planning
docs (REFACTOR_PLAN.md + REFACTOR_EXECUTION.md) which had become a mix of
historical PR timeline and still-relevant decisions.
Keeps only what's actionable going forward:
- Module tiers, the four registries, and the module distribution model
(architecture summary).
- Remaining work: Phase 5 (v2 → main) and the modules-branch decision.
- Operational patterns worth preserving (standing checks, TDZ rule,
branch-sync file-presence diff procedure, prettier drift pattern).
- 17 curated open questions across design, distribution, core slotting,
and documentation.
Canonical references (docs/module-contract.md, docs/architecture.md, etc.)
are linked but not duplicated. This doc is transient — retire when the
refactor is fully behind us.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First default channel that ships with main. Unix-socket adapter + thin
client; plugs into the running daemon rather than spawning its own host.
## src/channels/cli.ts
- ChannelAdapter with channelType='cli', platformId='local'.
- setup() unlinks any stale socket, listens on $DATA_DIR/cli.sock (mode 0600
so only the local user can connect).
- On client connect: reads newline-delimited JSON ({"text": "..."}) and
calls config.onInbound('local', null, {id, kind:'chat', content, ts}).
- deliver() writes {"text": <body>} back to the connected socket; silently
no-ops when no client is attached (outbound row still persists).
- Single-client policy: a second connection supersedes the first with a
[superseded] notice.
- teardown() closes the client, closes the server, removes the socket file.
## scripts/chat.ts + pnpm run chat
One-shot client:
- pnpm run chat <message...>
- Connects to the socket, writes one JSON line with the message.
- Reads replies; exits 2s after the first reply lands (hard timeout 120s).
- ENOENT/ECONNREFUSED prints a hint to start the daemon.
## scripts/init-first-agent.ts
- Fix stale imports after earlier module extractions (permissions +
agent-to-agent moved their DB helpers into modules/).
- After wiring the DM channel, also create cli/local messaging_group
(unknown_sender_policy='public' — local socket perms handle auth) and
wire it to the same agent. User can `pnpm run chat` immediately.
## package.json
- Add "chat": "tsx scripts/chat.ts" script.
## Validation
- pnpm run build clean.
- pnpm test — 137 host tests pass.
- bun test in container/agent-runner — 17 pass.
- Service boot logs: "CLI channel listening" + "Channel adapter started
channel=cli type=cli". Clean SIGTERM shutdown; socket file removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Outbox extraction (delivery.ts → session-manager.ts)
File I/O for outbound attachments now lives in session-manager.ts alongside
the symmetric inbound extractAttachmentFiles. delivery.ts no longer touches
the filesystem — it hands buffers to the adapter and calls clearOutbox on
success.
- New `readOutboxFiles(agentGroupId, sessionId, messageId, filenames)` and
`clearOutbox(agentGroupId, sessionId, messageId)` in session-manager.ts.
- deliverMessage in delivery.ts loses ~35 lines of fs/path code and its
`fs`/`path` imports.
## Dead-code sweep
TypeScript's --noUnusedLocals surfaced several cruft imports. Fixed:
- src/container-runner.ts: drop unused `markContainerIdle` import; drop
unused `session` parameter from `buildContainerArgs` signature.
- src/delivery.ts: drop unused `getSession`, `writeSessionMessage`,
`wakeContainer` imports.
- src/host-sweep.ts: drop unused `updateSession`, `outboundDbPath` imports.
- container/agent-runner/src/poll-loop.ts: drop unused `config`,
`processingIds` params from `processQuery`.
- Test files: drop unused imports in channel-registry.test, db-v2.test,
host-core.test.
Skipped: `conversations` state in chat-sdk-bridge.ts (never read but
tangled with public `updateConversations` method; cleaning it risks a
merge conflict with the channels branch at the next sync).
## Validation
- `pnpm run build` clean
- `pnpm test` — 137 host tests pass
- `bun test` in container/agent-runner — 17 tests pass
- Service boots (`NanoClaw running`, `OneCLI approval handler started`)
and shuts down cleanly on SIGTERM
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes approvals to the default tier with a public API (requestApproval +
registerApprovalHandler) that other modules consume. Self-modification
(install_packages / request_rebuild / add_mcp_server) moves into a new
optional module that registers delivery actions + matching approval handlers
via the new API.
## Approvals (default tier)
- Adds `src/modules/approvals/primitive.ts` exporting `requestApproval`,
`registerApprovalHandler`, `notifyAgent`. Absorbs `pickApprover` /
`pickApprovalDelivery` / `channelTypeOf` from the deleted `src/access.ts`.
- Rewrites `response-handler.ts` to dispatch to registered approval handlers
on approve (action-keyed Map). Reject path is centralized.
- Drops the three self-mod-specific delivery-action registrations from
`approvals/index.ts`; they belong to self-mod now.
- `onecli-approvals.ts` now imports picks from the primitive instead of
`src/access.ts`.
## Self-mod (optional tier)
- New `src/modules/self-mod/` with request handlers (validate input + call
requestApproval) and apply handlers (orchestration on approve).
- `apply.ts` owns updateContainerConfig + buildAgentGroupImage + killContainer
calls. Self-mod depends on approvals (via registerApprovalHandler +
requestApproval + notifyAgent) and on core (container-runner, container-config).
- Registers 3 delivery actions + 3 approval handlers at import time.
## Other changes
- `src/access.ts` and `src/access.test.ts` deleted. Tests split across
`src/modules/approvals/picks.test.ts` (approver selection) and
`src/modules/permissions/permissions.test.ts` (access + roles + DM).
- `src/modules/index.ts` barrel: approvals loads before self-mod so
registerApprovalHandler is bound when self-mod registers at import time.
## Validation
- `pnpm run build` clean
- `pnpm test` — 137 host tests pass
- `bun test` in container/agent-runner — 17 tests pass
- Service starts; boot log shows `OneCLI approval handler started`,
`NanoClaw running`; clean SIGTERM shutdown
Resolves the transitional tier violation flagged in PR #5 where core
imported from the permissions optional module via `src/access.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last extraction of Phase 3. Moves inter-agent messaging + create_agent +
destination projection into src/modules/agent-to-agent/. Core retains:
- `channel_type === 'agent'` dispatch in delivery.ts, guarded by
hasTable('agent_destinations') + dynamic import into module.
- Channel-permission ACL in delivery.ts, guarded by hasTable, with
inlined SQL (no module import from core).
- writeDestinations call in container-runner.ts, guarded by hasTable +
dynamic import into module.
- createMessagingGroupAgent's destination side effect in db/messaging-groups.ts,
guarded by hasTable. This is a documented transitional tier violation
(core imports from optional module), analogous to src/access.ts.
Migration `004-agent-destinations.ts` renamed to `module-agent-to-agent-
destinations.ts` preserving `name: 'agent-destinations'` so existing DBs
don't re-run it.
delivery.ts: 600 → 449 lines. handleSystemAction's last switch case gone
(just registry + default log-and-drop). notifyAgent helper removed (only
create_agent used it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #5 review flagged three behavior changes that shouldn't have slipped
in. This commit reverts each to match the pre-refactor behavior exactly.
1. User upsert ordering. Split the router hook into two setters:
setSenderResolver (runs before agent resolution) and setAccessGate
(runs after). Restores the pre-PR sequence where the users row is
upserted even if the message is dropped by wiring or trigger rules.
2. dropped_messages audit. Moved src/modules/permissions/db/dropped-messages.ts
back to src/db/dropped-messages.ts. The table is core audit infra, not
permissions-specific. Router re-writes rows for no_agent_wired and
no_trigger_match; the access gate writes rows for policy refusals.
3. Permissionless container fallback. Dropped. poll-loop restores the
original deny-all check when NANOCLAW_ADMIN_USER_IDS is empty.
Module contract doc updated with the two-hook shape.
Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering both hooks and clean SIGTERM shutdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves user-roles / users / agent-group-members / user-dms /
dropped-messages / user-dm / canAccessAgentGroup into
src/modules/permissions/. Module registers a single inbound-gate that
owns sender resolution, access decision, unknown-sender policy, and
drop-audit recording.
Router slimmed from 357 → 179 lines; the inline fallback chain
(extractAndUpsertUser / enforceAccess / handleUnknownSender /
recordDroppedMessage) is gone — without the permissions module core
defaults to allow-all with userId=null.
container-runner's admin-ID query is now inline SQL guarded by
sqlite_master on user_roles, keeping core free of any import from the
permissions module. The container-side formatter falls back to
permissionless mode when NANOCLAW_ADMIN_USER_IDS is empty: every sender
with an identifiable senderId is treated as admin.
Module contract doc formalizes the tier model and the dependency rule
(core ← default modules ← optional modules). One transitional violation
flagged: src/access.ts (core) imports from the permissions module for
its remaining approver-picking helpers; resolves in the planned PR #7
re-tier.
Validation: host build clean, 137/137 host tests, 17/17 container
tests, typecheck clean, service boots to "NanoClaw running" with
permissions module registering its gate and clean SIGTERM shutdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the scheduling surface — 5 delivery actions (schedule_task,
cancel_task, pause_task, resume_task, update_task), handleRecurrence,
applyPreTaskScripts, and task DB helpers — out of core and into
src/modules/scheduling/ (host) and container/agent-runner/src/scheduling/
(container).
First PR to fill the MODULE-HOOK markers introduced in PR #2:
- src/host-sweep.ts MODULE-HOOK:scheduling-recurrence now dynamically
imports handleRecurrence from the module each sweep tick.
- container/agent-runner/src/poll-loop.ts MODULE-HOOK:scheduling-pre-task
dynamically imports applyPreTaskScripts before the provider call.
When the marker block is empty (scheduling uninstalled), `keep`
falls back to `normalMessages` so non-task messages still flow.
The 5 task cases are removed from delivery.ts's handleSystemAction
switch — the registry now routes them. Task DB helpers moved out of
src/db/session-db.ts (which kept `nextEvenSeq` as a named export so
the module can uphold the host-writes-even-seq invariant). Test suite
split to match: scheduling-specific tests live in the module.
No migration — tasks are messages_in rows with kind='task'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #3 introduced a circular-import temporal-dead-zone bug that didn't
surface in unit tests but crashed the service at startup:
src/index.ts imports './modules/index.js' for side effects
→ src/modules/interactive/index.ts calls registerResponseHandler()
→ that function is declared in src/index.ts
→ but src/index.ts's const responseHandlers = [] hasn't been
initialized yet (we're in the middle of its module-init)
→ ReferenceError: Cannot access 'responseHandlers' before initialization
Same issue for registerResponseHandler itself (the function reference
resolves to undefined) and for onShutdown in the approvals module.
Caught when the operator started the service and systemd flagged the
process as crashing in auto-restart loop.
Fix: extract responseHandlers + registerResponseHandler + shutdownCallbacks
+ onShutdown into src/response-registry.ts, which has no dependencies on
src/index.ts or on modules. index.ts re-exports the same surface for any
existing consumers; modules import directly from response-registry.js.
The bug was latent because:
- Unit tests import pieces, never src/index.ts's main() flow.
- Host builds clean because TypeScript doesn't catch runtime circular
init order.
- Only surfaces when the ES module loader actually executes src/index.ts
as the entry point.
Verified: service boots on Linux host with approvals + interactive
loaded; OneCLI handler starts via onDeliveryAdapterReady callback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 / PR #3 of the module refactor. Moves the approval and interactive-
question flows out of core and into src/modules/, wired through the response
dispatcher and delivery action registries.
New modules:
- src/modules/interactive/ — registers a response handler that claims
pending_questions rows, writes question_response to the session DB, wakes
the container. createPendingQuestion call stays inline in delivery.ts
(guarded by hasTable) per plan.
- src/modules/approvals/ — registers 3 delivery actions (install_packages,
request_rebuild, add_mcp_server), a response handler for pending_approvals
(including OneCLI action fall-through), an adapter-ready hook that boots
the OneCLI manual-approval handler, and a shutdown hook that stops it.
OneCLI implementation (src/onecli-approvals.ts) moves into the module.
Core lifecycle hooks added (narrow, not registries):
- onDeliveryAdapterReady(cb) in delivery.ts — fires when setDeliveryAdapter
runs (or immediately if already set). Used by approvals for OneCLI boot.
- onShutdown(cb) in index.ts — fires on SIGTERM/SIGINT. Used by approvals
for OneCLI teardown.
- getDeliveryAdapter() getter in delivery.ts — for live-flow adapter access
in registered delivery actions.
Core shrinks: delivery.ts 911 → 665 lines, index.ts 405 → 224 lines.
dispatchResponse now logs "Unclaimed response" instead of falling through
to an inline handler — the inline fallback moved into the two modules.
Migration files renamed to the module-<name>-<short>.ts convention:
- 003-pending-approvals.ts → module-approvals-pending-approvals.ts
- 007-pending-approvals-title-options.ts → module-approvals-title-options.ts
Migration.name fields unchanged so existing DBs treat them as already-applied.
Degradation verified: emptying the modules barrel builds clean and 137/137
tests pass. Actions would log "Unknown system action"; button clicks would
log "Unclaimed response".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive change — existing code paths still run via inline fallbacks.
Prepares core for per-module extractions in PR #3 onward.
Four registries added with empty defaults:
- delivery action handlers (delivery.ts)
- router inbound gate (router.ts)
- response dispatcher (index.ts)
- MCP tool self-registration (container/agent-runner/src/mcp-tools/server.ts)
Default modules moved to src/modules/ for signaling:
- src/modules/typing/ (extracted from delivery.ts)
- src/modules/mount-security/ (moved from src/mount-security.ts)
Both are imported directly by core — no hook, no registry. Removal
requires editing core imports.
Migrator now keys applied rows by name (uniqueness) so module
migrations can pick arbitrary version numbers. Stored version column
is auto-assigned as an applied-order sequence.
sqlite_master guards added around core calls into module-owned tables
(user_roles, agent_destinations, pending_questions). No-ops today;
load-bearing after the owning modules are extracted.
MODULE-HOOK markers placed at scheduling's two skill-edit sites
(host-sweep.ts recurrence call, poll-loop.ts pre-task gate). PR #4
replaces the marked blocks when scheduling moves to its module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lockfile was pinned to 0.2.0 while package.json already declared
^0.3.1. The code depends on types added in 0.3.x (ApprovalRequest,
ManualApprovalHandle, configureManualApproval), so the host build
was failing on v2. Refreshing the lockfile resolves it.
0.3.1 was published 2026-04-10, well clear of minimumReleaseAge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codifies the interface between core and modules: the four registries
(delivery actions, inbound gate, response dispatcher, MCP tool
self-registration), default modules (typing, mount-security),
guarded-inline fallbacks, MODULE-HOOK skill-edit markers, and module
migration naming.
Authoritative reference for downstream extraction PRs and install
skills. See REFACTOR_PLAN.md for broader context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes src/v1/ (37 files) and container/agent-runner/src/v1/ (3 files)
along with the v1 reference note in CLAUDE.md and the now-obsolete
tsconfig exclude. v1 was already out of the runtime path; this just
removes the dead weight.
~8,800 LOC removed, zero runtime change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a callout for the Chat SDK + approval dialogs preview with a
collapsible containing the fork/checkout instructions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chat.openDM dispatches via inferAdapterFromUserId, which only recognizes
Discord/Slack/Teams/gChat formats and throws for everything else —
breaking approval delivery on Telegram (numeric IDs) and the other
direct-addressable channels the bridge now wraps. Delegate straight to
adapter.openDM + channelIdFromThreadId, and only expose openDM when the
underlying adapter implements it. Preserves the adapter's native
platform_id encoding (e.g. "telegram:<chatId>") so user_dms caches align
with the messaging_groups rows onInbound wrote.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Bun migration (c5d0ef8) dropped the in-image tsc build step, so
/app/src/mcp-tools/index.js never exists — only index.ts. The spawn
config in container/agent-runner/src/index.ts still pointed at
index.js and invoked it with `node`, which can't execute TypeScript
anyway. Net effect: every session failed to start the `nanoclaw`
MCP server, so scheduling, send_to_agent, interactive questions,
and self-mod tools were silently absent from the agent's toolset.
Matches entrypoint.sh and src/container-runner.ts, which already
use `exec bun run /app/src/index.ts` for the same reason.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The active poll (1s, running sessions) and sweep poll (60s, all active
sessions) both call deliverSessionMessages, and a running session is in
both result sets. Without locking they race on the same outbound row:
both read it as undelivered, both call the channel adapter, both
markDelivered. INSERT OR IGNORE hides the DB collision but the user has
already received the message twice.
Adds a per-session inflight guard; the second concurrent caller skips
and picks up any leftover work on the next poll tick.
Also makes outbox cleanup in deliverMessage best-effort: the message is
already on the user's screen, so a cleanup throw must not propagate to
the retry path (which would resend).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renamed 12 docs/v2-*.md → docs/*.md (already in index from earlier git mv).
Rewrote CLAUDE.md to describe the codebase as just "the codebase" rather
than "v2"; added a "Channels and Providers (skill-installed)" section
reflecting the new model and updated the docs index links.
Agent (general-purpose) cleaned the 12 doc bodies:
- Dropped "NanoClaw v2" / "v2 schema" / "(v2)" prose throughout
- Rewrote inter-doc cross-references docs/v2-X.md → docs/X.md
- Architecture, agent-runner-details: collapsed v1↔v2 comparison tables
into present-tense facts; added notes that trunk only ships `claude`
and that channel adapters are skill-installed from the `channels` branch
- Setup-wiring, checklist: dropped v1→v2 migration items that no longer
apply
- Frozen runtime paths preserved: data/v2.db, data/v2-sessions/,
container name nanoclaw-v2
git grep confirms remaining `\bv2\b` matches in docs/ are only those
runtime paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cleans up the prose-level v2 references that the rename commit didn't
touch. Skills now describe themselves and the codebase without "v2"
versioning language. /add-X-v2 cross-references in setup, init-first-agent,
and manage-channels updated to /add-X.
Runtime path identifiers (data/v2.db, data/v2-sessions/, container name
nanoclaw-v2) deliberately left as-is — renaming them breaks live installs
without commensurate benefit.
Verified: pnpm run build clean, 326 host tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renamed 13 skill folders to drop the -v2 suffix (the v2/v1 distinction
isn't load-bearing anymore — there is no v1 runtime). Deleted the four
v1 channel skills that occupied the rename target paths (add-discord,
add-slack, add-telegram, add-whatsapp); they targeted src/v1 which is
reference-only per CLAUDE.md.
Skill content still says "v2" in places — that's a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setup/groups.ts is whatsapp-only — its inline syncScript imports baileys
and pino to fetch group metadata via Baileys.groupFetchAllParticipating.
On trunk it was a no-op for non-whatsapp users (returned early without
auth) and the only thing keeping pino alive.
Removed:
- setup/groups.ts (lives on `channels` branch; restored by /add-whatsapp-v2)
- `groups` STEPS entry from setup/index.ts
- pino from package.json (no longer used outside the moved file)
/add-whatsapp-v2 skill updated to copy setup/groups.ts and register both
groups + whatsapp-auth in setup/index.ts STEPS, install pino@9.6.0 along
with baileys + qrcode.
Verified: build clean, 326 host tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The only use was channel-registry.ts checking `err instanceof NetworkError`
to retry transient setup failures. Switched to a duck-type predicate
(`err.name === 'NetworkError'`) so the dep is no longer needed at trunk
level. Channel skills bring it in transitively when they install their
Chat SDK adapter package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 ships with no channels baked in. All channel adapters (discord, slack,
telegram + helpers, whatsapp, whatsapp-cloud, gchat, github, imessage,
linear, matrix, resend, teams, webex) and their channel-specific setup
steps (pair-telegram, whatsapp-auth) now live on the `channels` branch
and get copied in via /add-*-v2 skills.
Removed:
- src/channels/{discord,slack,telegram*,whatsapp*,gchat,github,imessage,linear,matrix,resend,teams,webex}.ts
- setup/{pair-telegram,whatsapp-auth}.ts
- 14 channel-specific deps from package.json (@chat-adapter/*, @beeper/*,
@bitbasti/*, @resend/chat-sdk-adapter, @whiskeysockets/baileys,
chat-adapter-imessage, qrcode, @chat-adapter/state-memory unused)
- Their corresponding STEPS entries from setup/index.ts
- Channel imports from src/channels/index.ts
Kept:
- Channel infra: adapter.ts, channel-registry.ts (+ test), chat-sdk-bridge.ts,
ask-question.ts, an empty-imports index.ts
- Chat SDK runtime (`chat`) for channels that copy in via Chat SDK bridge
- @chat-adapter/shared promoted from transitive to direct dep
(channel-registry.ts uses NetworkError from it)
Verified: pnpm run build clean, 326 host tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 no longer ships opencode on trunk. The skill now:
- Fetches origin/providers
- Copies opencode source files to their target paths
- Appends self-registration imports to both provider barrels
- Adds @opencode-ai/sdk@1.4.3 as a pinned agent-runner dep
- Adds OPENCODE_VERSION ARG + opencode-ai pnpm global install to Dockerfile
- Rebuilds host + container
All steps idempotent. Credential/env/Zen docs unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 ships with only claude baked in. opencode now lives on the `providers`
branch and gets copied in via the /add-opencode skill.
Removed:
- src/providers/opencode.ts
- container/agent-runner/src/providers/{opencode,mcp-to-opencode}.ts + test
- @opencode-ai/sdk from agent-runner package.json + bun.lock
- opencode-ai global install + OPENCODE_VERSION ARG from Dockerfile
- opencode self-registration imports from both provider barrels
- opencode test case from factory.test.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Clarified the use of `x-api-key` for Zen's HTTP API, addressing common issues with Bearer tokens.
- Added configuration examples for `.env` and OneCLI registration for Zen keys.
- Provided guidance on naming conventions for OpenCode agent and provider settings.
- Included a note on the difference in authentication methods between OpenCode and OpenRouter.
Providers now mirror the channels pattern: each module calls
registerProvider() at top level, and providers/index.ts is a barrel of
side-effect imports. createProvider() becomes a thin registry lookup;
the closed ProviderName union is gone (now a string alias, since the
env var is a runtime string anyway).
Also adds a host-side provider-container-registry so providers can
declare their own mounts and env passthrough in src/providers/<name>.ts
instead of the container-runner having to know about each one. The
resolver runs once per spawn and threads provider + contribution
through buildMounts and buildContainerArgs so side effects (mkdir,
etc.) fire exactly once.
Both barrels are append-only — adding a new provider is a new file
+ one import line per barrel, no edits to existing files. The built-in
providers (claude, mock) don't need host-side config, so src/providers/
ships with an empty barrel; the container-side barrel imports both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/v2-build-and-runtime.md: new — runtime split rationale (Node host,
Bun container), lockfile topology, supply-chain trade-offs, image build
surface, two session-wake paths, CI shape, key invariants. Indexed from
CLAUDE.md v2 Docs Index.
- CLAUDE.md: Container Runtime (Bun) section with trigger/action gotchas
a contributor editing the container must know (named-param prefix rule,
bun:test vs vitest, bun.lock regeneration, no minimumReleaseAge for the
Bun tree, no tsc build step, DELETE pragma invariant). CJK font support
section for Claude sessions outside of /setup to proactively offer when
they detect CJK signals. Development section updated with Bun commands.
- .claude/skills/setup/SKILL.md: step 3b — auto-enable CJK fonts without
asking if the user is already writing in CJK; otherwise ask only on clear
signals (CJK timezone from step 2a). 3c renumbered from old 3b.
Container side:
- agent-runner switches to Bun. Drops better-sqlite3 (native compile gone),
drops tsc build step in-image AND the tsc-on-every-session-wake in the
entrypoint — bun runs src/index.ts directly. bun:sqlite replaces
better-sqlite3; cross-mount DB invariants (journal_mode=DELETE, busy_timeout)
preserved. Named params converted from @name to $name because bun:sqlite
does not auto-strip the prefix the way better-sqlite3 does.
- Tests ported from vitest to bun:test (only describe/it/expect/before/afterEach
used, API-compatible). vitest.config.ts excludes container/agent-runner/.
- bun.lock replaces pnpm-lock.yaml + pnpm-workspace.yaml under
container/agent-runner/. Host pnpm workspace does NOT include this tree.
Dockerfile improvements (independent of Bun but bundled while touching the file):
- tini as PID 1 for correct SIGTERM propagation (prevents half-written
outbound.db on shutdown).
- Extracted entrypoint.sh — readable and diffable vs the old inline printf.
- BuildKit cache mounts for apt + bun install + pnpm install.
- --no-install-recommends on apt, pinned CLAUDE_CODE_VERSION, AGENT_BROWSER,
VERCEL, BUN_VERSION.
- CJK fonts (~200MB) behind ARG INSTALL_CJK_FONTS=false; build.sh reads from
.env; setup/container.ts reads the same .env so /setup and manual rebuild
stay in sync.
- PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 in case any postinstall tries to pull a
redundant Chromium.
- /home/node 755 (was 777).
Host side:
- src/container-runner.ts dynamic spawn command collapses from
`pnpm exec tsc --outDir /tmp/dist … && node /tmp/dist/index.js` to
`exec bun run /app/src/index.ts` — cold start ~200-500ms faster per wake.
CI:
- oven-sh/setup-bun@v2 alongside Node/pnpm. Adds explicit container
typecheck (was documented in CLAUDE.md, not enforced) and `bun test` for
agent-runner tests.
- container/.dockerignore (new): exclude agent-runner/node_modules and
agent-runner/dist so COPY agent-runner/ ./ doesn't clobber the
pnpm-installed node_modules with host directories. Under npm's flat
layout this was forgiving; under pnpm's symlink layout it's a hard
conflict (overlay2 cannot copy onto a symlink target).
- setup/{groups,service}.ts: execSync('pnpm run build') not npm.
- setup/index.ts: usage string.
- scripts/*.ts: usage comments + seed-discord final log.
- .claude/settings.json: permission allowlist entries.
- .claude/skills/{add-whatsapp-v2,add-dashboard}/SKILL.md: docs.
- container/skills/{frontend-engineer,vercel-cli,self-customize}/SKILL.md:
agent-facing docs still told the container agent to run npm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Regenerate pnpm-lock.yaml to match v2 package.json (Baileys 6.17.16,
@chat-adapter/linear 4.26.0)
- src/container-runner.ts: when install_packages rebuilds a per-group
image, append each installed package to /root/.npmrc's
only-built-dependencies before pnpm install -g, so packages with
postinstall scripts (playwright, puppeteer, native addons) don't
install silently broken
- Fix stray 'ppnpm uninstall' in 13 skill files (REMOVE.md + SKILL.md)
left over from the npm→pnpm sed pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add agent-facing rules to CLAUDE.md covering minimumReleaseAgeExclude,
onlyBuiltDependencies, and frozen lockfile requirements — all require
human sign-off. Add comprehensive human-facing section to
docs/SECURITY.md with rationale, exclusion procedure (exact version
pin, approval, expiry), and build script allowlist documentation.
Add .pnpm-store/ to .gitignore — pnpm creates this when running in
sandbox mode with restricted network/filesystem access. Also commit
whatsapp.ts formatting change from prettier pre-commit hook.
Replace npx tsc with pnpm exec tsc in container entrypoint command,
and npm install -g with pnpm install -g in dynamic Dockerfile
generation. Variable names (npm, npmPackages) intentionally kept as
they refer to npm-registry packages, not the CLI.
Enable corepack and PNPM_HOME in container, switch all npm/npx
invocations to pnpm/pnpm exec. Use wildcard COPY for optional
pnpm-lock.yaml in agent-runner.
The specialist-subagent pattern forced every /add-vercel user through the
OneCLI credential-assignment plumbing (dynamically created Frontend Engineer
agents had no Vercel secret on first deploy). For a personal assistant the
isolation wasn't worth the complexity — the host agent can deploy to Vercel
directly using the same CLI.
- Remove the Phase 5 CLAUDE.md patch that forbade writing frontend code
- Drop the bundled frontend-engineer container skill
- Strip the HARD RULE + "Building Websites" delegation from vercel-cli
- Add a concise "Pre-Send Checks" section: local build, deployment READY,
live URL returns 2xx, optional agent-browser visual check
Net: -194 lines. /add-vercel now installs the CLI, registers the secret,
assigns it to existing agents, and teaches the agent to verify before
sharing the URL. No subagent plumbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the in-chat credential-collection flow introduced in e92b245. Agents
can no longer collect API keys via a secure modal — users must add secrets
through OneCLI directly. Keeps the OneCLI manual-approval handler and
threaded-routing work from the same commit intact.
Removed:
* container/agent-runner/src/mcp-tools/credentials.ts (MCP tool)
* src/credentials.ts (host-side modal/OneCLI pipeline)
* src/db/credentials.ts + migration 005 (pending_credentials table)
* src/onecli-secrets.ts (createSecret CLI facade, only caller was credentials.ts)
* findCredentialResponse from agent-runner DB layer
* PendingCredential types
* Four credential hooks from ChannelSetup (getCredentialForModal,
onCredentialReject, onCredentialSubmit, onCredentialChannelUnsupported)
* Credential card/modal handling in chat-sdk-bridge (nccr/nccm prefixes,
Modal/TextInput imports)
* credential_request text fallback in WhatsApp adapter
* request_credential system-action case in delivery.ts
Added:
* Migration 009 drops pending_credentials on existing installs.
Vercel skill now tells the agent to ask the user to register the token via
OneCLI instead of invoking the removed tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Baileys 6.7.21 silently failed the pairing handshake. Upgrade to 6.17.16
which fixes this. Three related issues:
1. proto is no longer a named ESM export in 6.17.x — use createRequire
to import via CJS (matching the proven v1 pattern).
2. Setup auth script didn't handle the 515 stream restart that WhatsApp
sends after successful pairing. Refactored to reconnect (matching v1's
connectSocket(isReconnect) pattern) instead of hanging until timeout.
3. Added succeeded guard and process.exit(0) to prevent timeout race
after successful auth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
update_task lets the agent adjust prompt/recurrence/processAfter/script
on a live scheduled task without losing the series id the user already
knows. Empty string clears recurrence/script.
list_tasks now groups by series_id so recurring tasks show as one row
(the live pending/paused occurrence) instead of one per firing — the
id displayed is the stable series handle that update/cancel/pause/resume
all match against.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recurring tasks spawn a new messages_in row per occurrence. Cancel
only matched the completed row the agent remembered, leaving the
live next occurrence running. Tag every row in a recurrence chain
with the originating task's id (series_id) so cancel/pause/resume
can reach any live row in the series. Cancel also clears recurrence
to prevent the sweep from cloning a cancelled task. Kind-aware id
prefix on recurrences (task- instead of msg-) keeps list_tasks output
consistent across occurrences.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
better-sqlite3 and @types/better-sqlite3 were declared in package.json
but missing from the lockfile. Ran `npm install` (needed to get tsc
working locally) and it patched the entries in. No code or behaviour
changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sweepSession called handleRecurrence without await, then synchronously
closed inDb in its finally block. handleRecurrence is async because it
does a dynamic `import('cron-parser')` before the first DB write; that
import resolved after the finally had already run, so insertRecurrence
hit a closed handle and threw "The database connection is not open".
Net effect: every recurring task was correctly marked completed by
syncProcessingAcks, but its next occurrence never got scheduled.
Single-word fix — `await`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Scheduled tasks stored process_after as ISO-8601 with `T` and `Z`
(e.g. `2026-04-16T14:37:00Z`) but the due-check queries compared it
via raw `<=` against `datetime('now')`, which returns space-separated
format (`2026-04-16 14:37:00`). Since `'T' (0x54) > ' ' (0x20)`,
every ISO-formatted process_after sorted greater than any SQLite-format
`now`, so tasks were never picked up by either the host sweep's
countDueMessages or the container's getPendingMessages.
Wrapping process_after in datetime() normalises both sides before
comparison. Recurrence rows (written by retryWithBackoff using
datetime('now', ...)) already had SQLite format and were unaffected,
which is why the bug only surfaced for agent-scheduled tasks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Scheduled tasks can now carry a bash script that runs inside the container
before the agent is invoked. The script prints `{wakeAgent, data?}` on its
last stdout line; if `wakeAgent: false` (or the script errors) the task
row is marked completed and the agent is never queried, saving API calls
on no-op checks. On wake, the script's `data` is injected into the task
prompt. Semantics mirror V1: 30s bash timeout, 1MB buffer, last-line JSON,
error == skip.
Also blocks the Claude SDK's built-in scheduling tools (CronCreate,
CronDelete, CronList, ScheduleWakeup) via `disallowedTools` so tasks
actually flow through `mcp__nanoclaw__schedule_task` and get the script
gate. CLAUDE.md gains a soft pointer explaining why `schedule_task` is
the right path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add Phase 5: patch agent CLAUDE.md with frontend delegation rule
so agents treat it as a hard constraint, not a suggestion
- Add Phase 6: sync container skills to existing agent sessions
(skills are copied once at group creation, not auto-updated)
- Add OneCLI secret assignment step in Phase 3 (selective mode
requires explicit assignment per agent)
- Add hard rule to vercel-cli container skill header
- Clean up Phase 4 (check Dockerfile before rebuilding)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Welcome skill now uses drip-feed approach instead of listing all
capabilities upfront. Agent asks user to explore or jump into building.
Init script delegates to /welcome skill instead of hardcoded prompt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Avoids running `ls -d ~/.openclaw` as a separate Bash command which
triggers permission prompts for reading outside the project directory.
The environment step now reports OPENCLAW_PATH in its status block.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- chat-sdk-bridge: forward thread.id to the router for DMs so sub-thread
context survives into delivery. Previously hardcoded to null, which
collapsed every reply to the DM top level.
- router: when a DM (is_group=0) is wired as `shared`, don't auto-escalate
to per-thread — keep one session for the whole DM and let thread_id
flow through to the adapter.
- agent-runner poll-loop: defer follow-up messages whose thread_id
differs from the active turn's routing. Mixing threads into one
streaming turn sent every reply to the first thread because routing
is captured at turn start.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enable Linear channel adapter. Fix setup permission rules: use specific
npm install entries per adapter package, replace cp -r with rsync -a to
avoid built-in cp safety prompt, add head to allow list for chained
commands. Update Linear API key URL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pairing codes no longer expire on a timer. They are consumed on match
or invalidated by wrong guesses. Removes ttlMs/expiresAt/deadline from
the pairing primitive, setup CLI, and tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add unregistered_senders table to capture dropped message origins
(one row per sender, upserted with message_count and last_seen)
- Add inbound DM logging to chat-sdk-bridge for debugging
- Add vercel CLI to base container image
- Install vercel-cli and frontend-engineer container skills
- Default requiresTrigger to false in register step
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Disable sandbox by default in project settings
- Setup: remove Apple Container option (Docker only), single channel selection
with plain text list, move fork to end, auto-set empty mounts, add command
pre-approval step, add UTC timezone confirmation, add wait-on-user guidance,
add 5m timeouts for long steps
- iMessage: improve Full Disk Access UX with Finder open + drag instructions
- Add /manage-mounts skill for post-setup mount configuration
- Enable iMessage channel import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The dev-agent-in-worktree approach for source self-modification is abandoned
in favor of a direct draft/activate flow with OS-level RO enforcement
(planned, not yet implemented). Strip the whole subgraph:
src/builder-agent/, pending-swaps DB module + migration 006, builder-agent
MCP tools, and all host wiring (startup sweep, approval routing, deadman,
worktree mount, freeze gate). Tool descriptions in self-mod.ts / agents.ts
no longer cross-reference create_dev_agent. CLAUDE.md + v2-checklist updated
to describe the new direction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Checkpoints the builder-agent dev-agent/worktree/swap flow (create_dev_agent,
request_swap, classifier, deadman, promote) before pivoting to a unified
draft-activate approach with OS-level RO enforcement. Lifts container_config
out of the agent_groups row into groups/<folder>/container.json so install_packages,
add_mcp_server, and rebuild flows can eventually route through the same draft
path as source edits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When /add-vercel is applied, agents that need to build websites spin up
a dedicated Frontend Engineer agent instead of building inline. The
frontend agent enforces build-test-verify discipline with visual browser
verification before deploying to Vercel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Self-contained skill: SKILL.md has instructions, resources/ holds
the dashboard-pusher.ts that gets copied to src/ at install time.
No src/ changes until the skill is applied.
npm package: @nanoco/nanoclaw-dashboard
Repo: https://github.com/qwibitai/nanoclaw-dashboard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Telegram pairing interceptor fired DB writes (createMessagingGroup,
upsertUser, grantRole) and the pairing-success confirmation inside an
unawaited `void (async () => {...})()`. Recent changes (0d3326a user
privilege model, c483860 pairing confirmation) widened the work done
inside this closure to include an extra two DB writes and a Telegram
API round-trip, making the race between match and commit reproducible
— a paired message could appear "lost" until a second send.
Change onInbound to optionally return a Promise, await it in the
chat-sdk-bridge dispatch callbacks, and make the pairing interceptor
async so its DB writes + confirmation send complete before the handler
resolves.
Note: the upstream @chat-adapter/telegram SDK itself does not await
processUpdate in its polling loop, so the adapter's getUpdates offset
still advances before our handler resolves. A true restart-safe fix
needs a corresponding change in chat-adapter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the "print the pairing code as plain text" directive from three
skill docs into the CLI output itself. Every caller of pair-telegram
(init-first-agent, manage-channels, add-telegram-v2, future callers)
now sees the reminder directly in the PAIR_TELEGRAM_ISSUED and
PAIR_TELEGRAM_NEW_CODE blocks. Skill docs shortened to point at it.
Also add a short pre-tool-call sentence in init-first-agent step 3b
instructing the assistant to extract the code and ask the user to send
it in Telegram.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reword pair-code instruction across add-telegram-v2, manage-channels,
and init-first-agent so the very last user-visible message after
generating the code MUST be a plain-text print of it.
- Replace init-first-agent's tail -f based verify step with a plain-text
prompt asking the user to confirm receipt of the welcome DM, falling
back to DB-based diagnostics only on non-arrival. Avoids harness
blocks on long leading sleeps and fragile log-string greps.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude Code's UI collapses bash tool output, so the user never sees the
pairing code emitted by pair-telegram. Reframe the skill instructions
to require the last user-visible message at this step to be a plain-text
print of the code.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude Code's UI folds bash tool results by default, hiding the 4-digit
pairing code from the user. Instruct the skill to echo the CODE as plain
text in the reply so it's always visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After a Telegram pair-code is successfully consumed, send a one-shot
"Pairing success! I'm spinning up the agent now, you'll get a message
from them shortly." reply to the same chat so the user knows the code
was accepted before the agent's own welcome DM arrives.
Best-effort: any sendMessage failure is logged but not rethrown, so a
Telegram outage can't undo a successful pairing or trigger the
interceptor's fail-open path.
Also includes a no-op prettier reformat in chat-sdk-bridge.ts that the
husky hook missed in the previous commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The chat-sdk bridge was emitting inbound messages with a nested
author.{userId,fullName,userName} shape, but router.ts:extractAndUpsertUser
reads flat content.senderId / sender / senderName. Result: every chat-sdk
adapter (telegram, discord, slack, teams, gchat, webex, matrix, resend,
imessage, whatsapp-cloud) hit the strict access gate with userId=null and
got dropped, even for the registered owner.
Project author into the flat fields inside messageToInbound so the bridge
matches the contract documented at router.ts:14-17. Native adapters
already set these directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- create_agent is not admin-gated (host has no role check on the system
action; agentTools unconditionally in the container MCP tool list).
- install_packages / add_mcp_server approval is owner/admin via
pickApprover, not "admin-only".
- Chat-first setup bootstrap + post-handoff welcome are partially done
via /setup + /init-first-agent (still TODO: single top-level entrypoint,
welcome prompt expansion).
- Add entries for cold-DM infrastructure (ChannelAdapter.openDM,
ensureUserDm, user_dms cache) and /init-first-agent skill under
Channel Adapters.
- Add entry for delivery ACL throw-on-unauthorized + implicit-origin
allow + auto-create agent_destinations on wire (the silent-drop bug
fix from the welcome-DM end-to-end test).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the agent-group-centric "main group" concept with user-level
privileges and adds the cold-DM infrastructure needed for proactive
outbound messaging (pairing, approvals, welcome flows).
Privilege model
- New tables: users, user_roles (owner global-only; admin global or
scoped to an agent_group), agent_group_members (explicit non-
privileged access; admin/owner imply membership), user_dms (cold-DM
resolution cache).
- Removed agent_groups.is_admin, messaging_groups.admin_user_id. Replaced
with messaging_groups.unknown_sender_policy (strict | request_approval
| public) for per-chat unknown-sender gating.
- src/access.ts: canAccessAgentGroup, pickApprover, pickApprovalDelivery.
- src/router.ts: access gate on every inbound, honoring
unknown_sender_policy for unknown senders.
- src/channels/telegram.ts: pairing interceptor upserts the paired user
and promotes them to owner if hasAnyOwner() is false (first-pair-wins).
Cold DM infrastructure
- ChannelAdapter.openDM?(handle) — optional method. Chat-SDK-bridge wires
it to chat.openDM() for resolution-required channels (Discord, Slack,
Teams, Webex, gChat); direct-addressable channels (Telegram, WhatsApp,
iMessage, Matrix, Resend) fall through to the handle directly.
- src/user-dm.ts: ensureUserDm(userId) — resolves + caches via user_dms.
Approval routing
- onecli-approvals + delivery use pickApprover + pickApprovalDelivery:
scoped admins → global admins → owners (dedup), first reachable via
ensureUserDm, same-channel-kind tie-break. Approvals land in the
approver's DM, not the origin chat.
Delivery fixes
- delivery.ts ACL rejection now throws instead of returning undefined —
the outer loop previously marked rejected messages as delivered.
- Implicit-origin allow: session.messaging_group_id === target skips the
destination check.
- createMessagingGroupAgent auto-creates the companion agent_destinations
row (normalized local_name from the messaging group's name, collision-
broken within the agent's namespace).
Container
- container-runner.ts: /workspace/global always read-only; drops
NANOCLAW_IS_ADMIN; adds NANOCLAW_ADMIN_USER_IDS (owners + global admins
+ scoped admins for this agent group). Agent-runner poll-loop gates
slash commands against that set.
New skill: /init-first-agent
- Walks the operator through standing up the first agent for a channel:
channel pick → identity lookup (reads each channel SKILL.md's
## Channel Info > how-to-find-id) → DM platform_id resolution (direct-
addressable, cold-DM via "user DMs bot first + sqlite lookup", or
Telegram pair-code fallback) → run scripts/init-first-agent.ts →
verify via tail of nanoclaw.log.
- scripts/init-first-agent.ts: parameterized helper that upserts the
user + grants owner (if none), creates dm-with-<display-name> agent
group + initGroupFilesystem, reuses/creates the DM messaging_group,
wires it (auto-creates destination), resolves the session, and writes
a kind:'chat' / sender:'system' welcome message into inbound.db. Host
sweep wakes the container and the agent DMs the operator via the
normal delivery path.
/manage-channels rewrite
- Drops --is-main / --jid / main-vs-non-main isolation references.
- First-channel flow delegates to /init-first-agent.
- Explains createMessagingGroupAgent auto-creates destinations.
- Adds a privileged-users show section.
setup/
- register.ts: drop --is-main, --jid, --local-name, --trigger
requiresTrigger defaults; call initGroupFilesystem; normalize to
v2 schema (no is_admin, no admin_user_id, sets unknown_sender_policy
'strict'); let createMessagingGroupAgent handle the destination row.
- pair-telegram.ts: emit PAIRED_USER_ID (namespaced "telegram:<id>")
instead of ADMIN_USER_ID; update header comment.
- register.test.ts deleted — was v1-only, tested a registered_groups
table that no longer exists.
Docs
- v2-architecture-diagram.{md,html}: ER diagram updated to drop
is_admin/admin_user_id, add unknown_sender_policy, and include
users/user_roles/agent_group_members/user_dms.
- v2-architecture-draft.md: approval-routing paragraph rewritten for
pickApprover/pickApprovalDelivery/ensureUserDm; SQL schema block
updated; admin-verification paragraph references
NANOCLAW_ADMIN_USER_IDS.
- v2-setup-wiring.md: entity-model sketch rewritten.
- v2-checklist.md: marked privilege refactor / container filtering /
approval routing / unknown-sender gating done; removed obsolete
admin_user_id and main-vs-non-main items.
Scripts
- scripts/init-first-agent.ts (new) replaces scripts/welcome-owner-dm.ts
(removed; welcome-owner was a Discord-specific one-off).
- test-v2-host.ts, test-v2-channel-e2e.ts, seed-discord.ts: drop
is_admin + admin_user_id, use unknown_sender_policy.
Tests
- src/access.test.ts (new): 14 tests for canAccessAgentGroup, role
helpers, pickApprover, ensureUserDm, pickApprovalDelivery.
- src/db/db-v2.test.ts: adds 3 tests for the auto-created
agent_destinations row (normalized name, no duplicates, collision
break within an agent group).
- host-core.test.ts, channel-registry.test.ts: updated fixtures to
use unknown_sender_policy: 'public' where the test exercises routing
rather than the access gate.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Approval follow-up prompts (e.g. the post-rebuild "Packages installed,
verify they work" note) are written with channel_type='agent' and
platform_id=<self agent_group_id>, and were dropped by the
agent-to-agent authorization check because no self-destination row
exists. Agents are always authorized to message themselves; skip the
hasDestination check when source == target.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Approval cards bypass the deliverMessage path that populates
pending_questions, so the post-click lookup found nothing and the
card edit fell back to "❓ Question" + the raw option value
("approve"/"reject"). Store title and normalized options on
pending_approvals as well, and look up either table via a shared
getAskQuestionRender helper so the chat-sdk post-click edit and the
Discord interaction callback render the per-card title and the
selectedLabel (e.g. "✅ Approved").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Approval cards now carry a required title (Add MCP Request, Install
Packages Request, Rebuild Request, Credentials Request) and structured
options with distinct pre-click label, post-click selectedLabel (e.g.
"✅ Approved" / "❌ Rejected"), and value used for click routing. The
title and normalized options are persisted in pending_questions so the
post-click card edit can render the correct per-type title and selected
label on both chat-sdk channels and Discord interactions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Setup skill that installs Vercel CLI in agent containers and configures
OneCLI credential injection for api.vercel.com. Container skill bundled
in .claude/skills/add-vercel/container-skills/ and copied to
container/skills/ during setup. Also adds dashboard & web apps prompt
to /setup flow (step 5b).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Install approval now auto-rebuilds the image and kills the container,
replacing the prior two-card flow where the agent had to call
request_rebuild separately after install_packages was approved.
Queues a processAfter=+5s synthetic prompt so the respawned container
verifies the new packages and reports back to the user.
Adds two v2-checklist gaps found along the way:
- /remote-control and /remote-control-end are v1 host-level commands
not ported to v2
- messaging_groups.admin_user_id is hardcoded null at registration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Separate from the v1 /add-whatsapp skill — v1 remains untouched.
Follows the v2 skill pattern (flat sections, defers to /manage-channels
for wiring). Covers Baileys auth, pairing code, QR code, and
documents the native adapter's features and limitations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Outbound files: images, videos, audio as native media messages;
other types as documents. First file gets text as caption.
- Reactions: send emoji reactions via Baileys react message type
- Inbound media: download images, video, audio, documents from
incoming messages and pass as attachments to the agent
- Edit operations silently skipped (WhatsApp linked device limitation)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Markdown→WhatsApp formatting: **bold**→*bold*, *italic*→_italic_,
headings→bold, links→plaintext, code blocks preserved
- ask_question support: renders as text with /approve, /reject slash
commands; matches replies and routes through onAction pipeline
- credential_request: text fallback (WhatsApp has no modal support)
- Bot echo filter: skip fromMe messages to prevent loops
- Formatting applied to all outbound text messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown (legacy)
but its converter emits CommonMark. Messages containing **bold** or list
bullets that round-trip to `*` produce "can't parse entities" errors and
get dropped after retries.
Add an opt-in transformOutboundText hook on the chat-sdk bridge and wire
a Telegram-specific sanitizer that downgrades **bold** to *bold*, rewrites
dash/plus list bullets to a Unicode bullet so the adapter's re-stringify
doesn't inject stray `*`, and strips unbalanced delimiters or brackets.
Only Telegram opts in; other channels are unaffected.
Workaround until upstream (vercel/chat) ships mode-aware conversion —
PR #367 adds a parseMode knob but not the converter fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Direct ChannelAdapter implementation — no Chat SDK bridge.
Ports v1 infrastructure: getMessage fallback, outgoing queue,
group metadata cache, LID-to-phone mapping, auto-reconnect.
Auth via pairing code (WHATSAPP_PHONE_NUMBER) or QR code.
Text messaging only (MVP). Not yet implemented:
- File/image attachments (send and receive)
- Edit message, delete message
- Reactions
- Bot echo filtering (own messages loop back as inbound)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude Code's @-import directive only follows paths inside the project
memory tree (cwd + ancestors). Both `@/workspace/global/CLAUDE.md` and
`@../global/CLAUDE.md` are silently ignored because `/workspace/global`
is outside `/workspace/agent` (the cwd). The import line is parsed but
the content is never loaded — validated with a sentinel passphrase test
against a live container.
Fix: drop a `.claude-global.md` symlink into each group's dir pointing
at `/workspace/global/CLAUDE.md`. The link path is absolute on container
terms (dangling on host, valid via the /workspace/global mount) and the
symlink file itself is inside cwd, so Claude's @-import is happy. The
group's CLAUDE.md imports via `@./.claude-global.md`.
- src/group-init.ts: initGroupFilesystem now drops the symlink (idempotent,
uses lstat so existsSync doesn't trip on the dangling target on the
host). Default CLAUDE.md body uses `@./.claude-global.md`.
- scripts/migrate-group-claude-md.ts: creates the symlink for existing
groups and rewrites any broken `@/workspace/global/CLAUDE.md` or
`@../global/CLAUDE.md` import line to `@./.claude-global.md`.
- groups/main/CLAUDE.md: migration rewrote the import.
Validated: live container with the symlinked import correctly surfaces
global CLAUDE.md content (passphrase `quinoa-submarine-42` added to
global, retrieved via claude -p, removed).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pairing-code registration applies to every Telegram group once the privileged
"main chat" identity goes away.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cold-start DNS/network hiccups can fail the adapter's first deleteWebhook or
getMe call, leaving the channel silently dead while the service stays up.
Wrap bridge.setup in an exponential-backoff retry (5 attempts) — if the
network is truly down we surface it instead of hanging forever.
Lives in telegram.ts so the chat-sdk bridge stays generic; other channels
can opt in by copying the small helper if they hit the same issue.
- createPairing now replaces any existing pending pairing for the same intent
(replace-by-default; no "two pending codes for one intent" state)
- tryConsume records each attempt on pending records (capped at 10); a
wrong code invalidates the pairing immediately (one attempt per code)
- waitForPairing gains onAttempt callback for misses and rejects with a
distinct "invalidated by wrong code" message so callers can distinguish
TTL expiry from user-error
- pair-telegram emits PAIR_TELEGRAM_ATTEMPT on misses and auto-regenerates
the pairing up to 5 times, emitting PAIR_TELEGRAM_NEW_CODE for each
- Skill docs updated so the host Claude knows to show new codes and
offer another batch on max-regenerations-exceeded
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Require the message to be exactly the 4 digits (optionally prefixed by
@botname). Loose matches like "my pin is 0349" are rejected to avoid false
positives from chat traffic that happens to contain a 4-digit number.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BotFather issues bot tokens with no user binding, so anyone who guesses the
bot's username can DM it and get registered as a channel. Pairing closes that
gap: setup issues a one-time 4-digit code, the operator echoes it back from
the chat they want to register, and the inbound interceptor binds
admin_user_id before the message reaches the router.
- src/channels/telegram-pairing.ts: JSON-backed store with createPairing,
tryConsume, getStatus, waitForPairing (fs.watch + poll fallback)
- src/channels/telegram.ts: wraps bridge.setup with an onInbound interceptor
that consumes pairing codes and upserts messaging_groups
- setup/pair-telegram.ts: CLI step issues a code and waits up to 5 min for
the operator to echo it back, emitting PLATFORM_ID/IS_GROUP/ADMIN_USER_ID
- Skill docs: /setup reorders mounts -> service -> wire (pairing needs a
live polling adapter); /manage-channels and /add-telegram-v2 use pairing
instead of asking the user to discover chat IDs
All other channels still bind admin via install-time identity (OAuth/QR/token);
pairing is Telegram-only. The bridge, router, and other adapters are untouched.
Each group's on-disk state (CLAUDE.md, .claude-shared/, agent-runner-src/)
is now initialized exactly once at group creation and owned by the group
forever after. Spawn does only mounts — no copies, no settings.json
overwrites, no skill clobbers, no source resyncs.
Global memory composition switches from "host reads /workspace/global/CLAUDE.md
at bootstrap and stuffs it into systemPrompt.append" to "group CLAUDE.md
imports it via @/workspace/global/CLAUDE.md at the top." Edits to global
propagate instantly through the existing read-only mount; no copy, no
restart.
- src/group-init.ts: new initGroupFilesystem(group, opts?) — idempotent,
populates groups/<folder>/, .claude-shared/, agent-runner-src/ only when
paths don't already exist.
- src/container-runner.ts: buildMounts() calls init defensively at the
top (catches existing groups on first spawn after this change), drops
the inline settings.json write, skills cpSync loop, and agent-runner-src
rm-then-copy. Just mounts now.
- src/delivery.ts: create_agent flow uses initGroupFilesystem with
optional instructions, replacing the inline mkdirSync + writeFileSync.
- container/agent-runner/src/index.ts: drops GLOBAL_CLAUDE_MD reading.
systemContext.instructions is now only the runtime-generated
destinations addendum.
- scripts/migrate-group-claude-md.ts: one-shot migration that prepends
the @-import to existing groups' CLAUDE.md. Skips if global doesn't
exist or if the @-import is already present (regex match on the @ form
to avoid false positives from prose mentions of the path).
- groups/main/CLAUDE.md: prepended by the migration.
Existing groups need a one-time wipe of their agent-runner-src/ dir so
init re-populates from current host source — done locally before this
commit. Future host-side updates to container/skills/ or
container/agent-runner/src/ won't auto-propagate; that's the trade-off
for unconditional persistence and will be covered by host-mediated
refresh tools in a follow-up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Channel adapter factories can now return a Promise, enabling adapters
that need async initialization like loading auth state from disk
(e.g. WhatsApp reading credentials via useMultiFileAuthState).
Existing sync factories are unaffected — await on a sync return is
a no-op. All current adapters remain synchronous.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrites the add-teams-v2 skill with step-by-step instructions
covering App Registration, client secret, Azure Bot creation (portal
and CLI), messaging endpoint, Teams channel, manifest template,
sideloading, and RSC permissions for receiving all messages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fs.cpSync never removes files that disappeared from the source, so
renamed or deleted files linger in data/v2-sessions/<group>/agent-runner-src/.
The container's entrypoint runs tsc over the whole mounted src via
tsconfig's `include: ["src/**/*"]`, so a single stale file fails the
compile and the container exits 2.
Latent since the dir was introduced — surfaced when the provider
interface refactor made a leftover index-v2.ts stop typechecking.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reshape AgentProvider so provider-specific assumptions stop leaking into
the generic layer. No change to what reaches sdkQuery() — same values,
different plumbing.
- QueryInput: opaque `continuation` replaces `sessionId` + `resumeAt`;
`systemContext.instructions` replaces ambiguous `systemPrompt`;
`mcpServers`, `env`, `additionalDirectories` move to `ProviderOptions`
at construction time.
- AgentProvider gains `isSessionInvalid(err)` and
`supportsNativeSlashCommands` so the poll-loop stops regex-matching
Claude error strings and gates passthrough slash commands per provider.
- ClaudeProvider owns `CLAUDE_CODE_AUTO_COMPACT_WINDOW` and the
stale-session regex internally.
- ProviderEvent.activity kept and documented as the liveness signal
(fires on every SDK message so the idle timer stays honest during
long tool runs); init carries `continuation` instead of `sessionId`.
- poll-loop drops mcpServers/env/systemPrompt from its config; admin
user id now passed explicitly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
send_file and send_message with an explicit `to` parameter were always
setting thread_id to null, causing files and messages to land in the
Discord channel root instead of the thread the session is bound to.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Aligns with upstream feat/chat-sdk-integration pattern: regex-based
routing (/webhook/{adapterName}), response streaming, cleanup function.
Updates Slack and Teams skill docs to match /webhook/{name} convention
used by all other v2 channel skills.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Route webhook requests through chat.webhooks[name]() instead of calling
adapter.handleWebhook() directly, getting proper auto-initialization and
signature verification. Extract Node↔Web Request/Response conversion
into reusable helpers, parse URL pathname properly for query string
safety, and support all HTTP methods (not just POST).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move all raw SQL out of session-manager, delivery, and host-sweep into
a dedicated DB module. Make session schemas idempotent (IF NOT EXISTS)
so initSessionFolder always applies them. Revert the markdown
plain-text retry from 4c477ac.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an agent has one configured destination (e.g. Discord) but
receives a message from a different channel (e.g. Slack), the
single-destination shortcut was routing replies to the destination
instead of the originating channel. Now uses the inbound message's
routing context (channel_type, platform_id) when available, falling
back to the destination table only when routing context is absent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Teams adapter now reads TEAMS_APP_TYPE and TEAMS_APP_TENANT_ID from
env, supporting both MultiTenant (default) and SingleTenant configs.
Updated add-teams-v2 skill docs with full Azure Bot setup flow,
webhook endpoint format, and app package sideloading instructions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Corrects webhook URL to /api/webhooks/slack, adds Enable DMs step
(App Home > Messages Tab), documents reinstall requirement after
adding event subscriptions, and adds webhook server section.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a shared HTTP server (port 3000, configurable via WEBHOOK_PORT)
that routes incoming webhooks to the correct Chat SDK adapter by path
(e.g. /api/webhooks/slack, /api/webhooks/teams). Required by Slack,
Teams, GitHub, Linear, and other non-gateway adapters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The forward-compat CREATE TABLE IF NOT EXISTS papered over a stale-DB
problem we don't need to support — the canonical INBOUND_SCHEMA in
src/db/schema.ts already creates session_routing for every fresh
session DB. Pre-existing local DBs that predate the schema entry are
treated as garbage and recreated, not migrated.
Schema is the single source of truth; write paths shouldn't carry
defensive table-creation logic.
A NetworkError during adapter.setup() (e.g. Telegram deleteWebhook hitting
a DNS hiccup at boot) would log the failure and immediately give up,
leaving the channel permanently dead until the host process was manually
restarted — even though the host kept running and other channels worked.
Wrap the setup call in a small retry loop with backoff (2s, 5s, 10s) that
fires only on NetworkError. Misconfigs (bad tokens, invalid options) still
fail fast since they don't surface as NetworkError.
Universal across channels — applies to any adapter that throws
NetworkError from setup(), not just Telegram.
A single message with markdown the adapter couldn't parse (e.g. Telegram
MarkdownV2 entity errors) would fail in deliverSessionMessages and be
retried forever, blocking every subsequent reply on that session.
Catch ValidationError from postMessage and retry once with the markdown
stripped to plain text via markdownToPlainText. Files re-attach in a
follow-up post since the plain-text retry drops the files payload shape.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- session-manager.ts: shrink the cross-mount invariant header from 31
lines to 12, keeping each invariant's cause and consequence inline.
- agent-runner/db/connection.ts: parallel cross-mount comment for the
container-side reader (inbound.db must be journal_mode=DELETE).
- agent-runner/db/messages-out.ts: document that even/odd seq parity
is load-bearing — seq is the agent-facing message ID returned by
send_message and consumed by edit_message / add_reaction, looked
up across both tables.
- v2-checklist.md: record the cross-mount invariants and seq parity
under Core Architecture so future "simplifications" don't regress
them.
- scripts/sanity-live-poll.ts: empirical validation harness for the
three cross-mount invariants — flips each one and observes silent
message loss / corruption.
- delivery.ts: inline routeAgentMessage at its single callsite (-17
net lines). The wrapper added more boilerplate than it factored.
- docs/v2-architecture-diagram.{md,html}: rendered Mermaid diagrams
of the v2 system, message flow, named destinations, entity model,
and the two-DB split.
- channels/adapter.ts, chat-sdk-bridge.ts, credentials.ts,
db/sessions.ts, db/db-v2.test.ts: prettier format pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mark OneCLI manual-approval integration and credential collection from chat
as partial with concrete sub-TODOs. Add upstream asks:
* Chat SDK input support beyond Slack — platforms support it natively
(Discord modals, Teams/GChat/Webex Adaptive Cards, WhatsApp Flows), Chat
SDK just doesn't expose the surfaces yet. Concrete per-platform mapping
captured.
* Built-in OneCLI apps shadow generic secrets on the same host; the
collection tool should check apps-list first and surface the connect URL
when an app exists.
* Tunneled OneCLI dashboard fallback for channels with no native form input.
* Per-agent-group secret scoping via OneCLI agentId.
* SDK-native secret management to replace the shell facade in onecli-secrets.
Also:
* Admin model refactor — instance-level default admin + per-group override
+ DM delivery when supported.
* Discord-specific Chat SDK quirks (first-message @mention requirement,
sub-thread materialization on subscribe).
* OneCLI migration check under Migration — flag whether existing installs
need OneCLI re-init (new SDK version, credentials re-scoped).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
session_state was added after the initial v2 schema with a lazy
`CREATE TABLE IF NOT EXISTS` in getOutboundDb(), so older session
outbound.db files have a session_state table from before updated_at
existed. The lazy create is a no-op when the table already exists,
leaving the column missing and causing:
Error: table session_state has no column named updated_at
on every `INSERT OR REPLACE INTO session_state` call.
Follow up the CREATE IF NOT EXISTS with a PRAGMA table_info check and
ALTER TABLE ADD COLUMN when updated_at is missing. Cheap on every open,
only runs DDL once per DB.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three features built on top of @onecli-sh/sdk 0.3.1, landed together because
they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK
bridge, channel adapter contract).
## OneCLI manual-approval handler
* `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's
`configureManualApproval`; on each request, delivers an `ask_question` card
to the admin agent group's first messaging group, persists a
`pending_approvals` row, and waits on an in-memory Promise resolved by the
admin's button click or an expiry timer. Expired cards are edited to
"Expired (...)" and a startup sweep flushes any rows left over from a
previous process.
* Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the
Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays
in the persisted payload for audit.
* Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware
columns from the start (`agent_group_id`, `channel_type`, `platform_id`,
`platform_message_id`, `expires_at`, `status`), `session_id` relaxed to
nullable so cross-session approvals fit.
* `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals
through `resolveOneCLIApproval` before falling back to the
session-bound approval path.
## Credential collection from chat
New `trigger_credential_collection` MCP tool — the agent researches a
third-party API, calls the tool with `{name, hostPattern, headerName,
valueFormat, description}`, and blocks until the host reports saved, rejected,
or failed. The credential value never enters the agent's context: the user
submits it into a Chat SDK Modal on the host side, the host writes it to
OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to
`onecli secrets create`, shape mirrors the SDK we expect upstream), and only
the status string flows back to the container via a system message.
* `src/credentials.ts` — host-side handler: delivers the card to the
conversation's own channel (not the admin channel — credential collection
is a user-facing flow, distinct from admin approval), persists a
`pending_credentials` row, drives the submit → `createSecret` → notify
pipeline. Falls back gracefully when the channel doesn't support modals.
* `src/db/credentials.ts` + migration 005: `pending_credentials` table.
* `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card,
handles the `nccr:` action prefix by opening a Modal with a TextInput,
registers an `onModalSubmit` handler for the `nccm:` callback prefix.
* `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP
tool, mirroring the `ask_user_question` polling pattern.
* `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse`
helper to pick up the system message the host writes back.
## Threaded adapter routing
The destination layer previously didn't carry thread context, so agent replies
to Discord always landed in the root channel regardless of which thread the
inbound came from.
* `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill
at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat,
Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix,
Resend, iMessage.
* `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads
collapse to channel-level sessions). Threaded adapters override the
wiring's `session_mode` to `'per-thread'` so each thread = a session
(except `agent-shared`, which is preserved as a cross-channel intent the
adapter can't know about).
* `session_routing` table in `inbound.db` — single-row default reply routing
written by the host on every container wake from
`session.messaging_group_id` + `session.thread_id`. Forward-compat
`CREATE TABLE IF NOT EXISTS` handles older session DBs lazily.
* `container/agent-runner/src/db/session-routing.ts` — container-side reader.
* `send_message` / `send_file` / `ask_user_question` / `send_card` /
scheduling tools all default their routing (channel, platform, **and**
thread) from the session when no explicit `to` is given. Explicit `to`
uses the destination's channel with `thread_id = null` (cross-destination
sends start a new conversation elsewhere).
* `poll-loop.ts::sendToDestination` (the final-text single-destination
shortcut) now inherits `thread_id` from `RoutingContext` too — this was
the root cause of Discord replies landing in the root channel even after
`send_message` was wired correctly.
## Related cleanups
* `src/container-runner.ts`: OneCLI agent identifier switched from the lossy
folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)`
a trivial reverse lookup for per-agent scoping.
* `wakeContainer` race fix via an in-flight promise map — concurrent wakes
during the async buildContainerArgs / OneCLI `applyContainerConfig` window
no longer double-spawn containers against the same session directory.
* `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema
version assertion — it had to be bumped on every migration addition.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Capture the product direction that's been landing in recent work:
everything configurable from chat once bootstrap is done, skills as
the primary extension mechanism, and mark named destinations / agent
self-modification / agent-to-agent comms as complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The /workspace/ipc/* tree is a v1 leftover — v2 routes everything
through inbound.db / outbound.db. Refresh the surrounding comment to
describe what the entrypoint actually does.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The v2 poll loop held the session ID in a local variable, so every
container restart started a fresh SDK session even though the .jsonl
transcript was still sitting in the shared .claude mount. Store it in
outbound.db (container-owned, already per channel/thread), seed the
loop on startup, clear on /clear, and recover from stale-session
errors the same way v1 did.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The per-session destination map was being written as a sidecar JSON file
(/workspace/.nanoclaw-destinations.json) — inconsistent with the rest of
v2, where all host↔container IO goes through inbound.db / outbound.db.
Move it into a `destinations` table in INBOUND_SCHEMA. The host writes
it before every container wake AND on demand (e.g. after create_agent)
so the creator sees the new child destination mid-session without a
restart. The container queries the table live on every lookup — no
cache, no staleness window.
- src/db/schema.ts: add `destinations` table to INBOUND_SCHEMA.
- src/session-manager.ts: writeDestinationsFile → writeDestinations,
writes via DELETE + INSERT inside a transaction.
- src/delivery.ts: create_agent handler calls writeDestinations on the
creator's session after inserting the new destination rows.
- container/agent-runner/src/destinations.ts: queries inbound.db
directly in every findByName/getAllDestinations/findByRouting call.
No more cache. No setDestinationsForTest (obsolete). No fs import.
- container/agent-runner/src/index.ts and mcp-tools/index.ts: remove
loadDestinations() calls — no longer needed.
- Test helper initTestSessionDb creates the destinations table.
Integration test inserts a row directly instead of mocking the cache.
No backwards compatibility: sessions predating the schema update must
be recreated. This is fine on the v2 branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an agent has exactly one configured destination, wrapping output in
<message to="..."> blocks is unnecessary. Plain text goes to the sole
destination automatically. This preserves the simple "just reply" flow
for the common case of one user on one channel.
Applies in three places:
- System prompt addendum: single-destination case gets a simplified
explanation ("your messages are delivered to X, just write directly").
Multi-destination case keeps the <message to="..."> syntax docs.
- Main output parser: if zero <message> blocks are found and there is
exactly one destination, the entire cleaned text (with <internal>
stripped) is sent to that destination.
- send_message / send_file MCP tools: `to` parameter is now optional.
With one destination, omitted defaults to it. With multiple, omitting
returns an error listing the options.
Multi-destination behavior is unchanged — explicit <message to="..."> is
still required, and untagged text is still scratchpad.
groups/global/CLAUDE.md updated to describe both cases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces implicit routing context (NANOCLAW_PLATFORM_ID env vars) with
per-agent named destination maps. Agents reference channels and peer
agents by local names; the host re-validates every outbound route against
a new agent_destinations table that is both the routing map and the ACL.
Model changes:
- New migration 004 adds agent_destinations (agent_group_id, local_name,
target_type, target_id). Backfills from existing messaging_group_agents.
- Host writes /workspace/.nanoclaw-destinations.json before every container
wake so admin changes take effect on next start.
- Container loads map at startup, appends system-prompt addendum listing
available destinations and the <message to="name">…</message> syntax.
- Agent main output is parsed for <message to="..."> blocks; each block
becomes a messages_out row with routing resolved via the local map.
Untagged text and <internal>…</internal> are scratchpad (logged only).
- send_message MCP tool now takes `to` (destination name) instead of raw
routing fields. send_to_agent deleted (redundant — agents are just
destinations). send_file/edit_message/add_reaction route via map too.
- Inbound formatter adds from="name" attribute via reverse-lookup so the
agent sees a consistent namespace in both directions.
Permission enforcement:
- Host checks hasDestination() before every channel delivery AND every
agent-to-agent route. Unauthorized messages dropped and logged.
- routeAgentMessage simplified: ~15 lines, no JSON parse, content copied
verbatim (target formatter resolves the sender via its own local map).
- create_agent is admin-only, checked at both the container (tool not
registered for non-admins) and the host (re-check on receive). Inserts
bidirectional destination rows so parent↔child comms work immediately.
Includes path-traversal guard on folder name.
Self-modification cleanup:
- add_mcp_server now requires admin approval (previously had none).
- install_packages validates package names on BOTH sides (container tool
+ host receiver) with strict regex. Max 20 packages per request.
- All three self-mod tools are fire-and-forget: write request, return
immediately with "submitted" message. Admin approval triggers a chat
notification to the requesting agent — no tool-call polling, no 5-min
holds. On rebuild/mcp_server approval, the container is killed so the
next wake picks up new config/image.
- Approval delivery extracted into requestApproval() helper (the one
place where three call sites were literally identical).
Also folded in the phase-1 dynamic import cleanup (create_agent no longer
does `await import('./db/agent-groups.js')`) and removes NANOCLAW_PLATFORM_ID
/ CHANNEL_TYPE / THREAD_ID env-var routing entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Self-customize skill lives in container/skills/ so it's loaded into the
agent container at runtime. Documents the builder-agent pattern with
diff size limits for safer self-modification.
CLAUDE.md communication section now has three tiers (short / longer /
long-running) instead of a single blanket rule — agents should
acknowledge upfront on longer work and update before slow operations,
but stay silent on quick tasks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Discord adapter fails to start without all three env vars. Also
fix platform ID format docs to show discord:{guildId}:{channelId}.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Pre-agent scripts: [~] → [ ] (formatter references scriptOutput but
no execution logic exists)
- Add typing indicator as completed (triggerTyping in router)
- Remove "stub exists" from register_group/reset_session (no stubs found)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After wiring a channel to an agent group, register.ts writes a task
message to the session that triggers the /welcome container skill.
The agent introduces itself immediately — the user sees typing and
then a greeting without having to send a message first.
Uses kind 'task' (not 'system') so the poll loop picks it up normally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Channel adapters prefix platform IDs with their channel type
(e.g. "telegram:123"). Normalize in register.ts so the DB always
stores the canonical format. Removes fallback lookup from router.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move prefix handling from register.ts to router.ts. Users register with
raw platform IDs (what they naturally have), adapters send prefixed IDs
(their internal format). Router now tries stripping the channel type
prefix when the exact lookup fails, matching either format.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Chat SDK adapters use prefixed platform IDs (e.g. "telegram:6037840640",
"discord:guildId:channelId") but users provide raw IDs during setup.
Without the prefix, the router can't match the registered messaging group
to incoming messages and silently drops them.
register.ts now auto-prefixes with the channel type if not already present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use platformId directly as thread ID in deliver() and setTyping()
instead of calling encodeThreadId with Discord-shaped args — platformId
is already in the adapter's encoded format (e.g. "telegram:6037840640")
- Add triggerTyping() in delivery.ts, call from router on message route
- Enable Telegram channel in barrel
- Verified E2E: Telegram message in → agent → typing indicator → response
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add three-level isolation model (shared session, same agent, separate agent)
with agent-shared session mode for cross-channel shared sessions
- Create /manage-channels skill for wiring channels to agent groups
- Refactor all 12 v2 channel skills: lean SKILL.md + VERIFY.md + REMOVE.md
with structured Channel Info section for platform-specific metadata
- Create /add-discord-v2 skill (was missing)
- Add step 5a to setup SKILL.md invoking /manage-channels after channel install
- Update setup/verify.ts to check all 12 channel token types
- Add docs/v2-isolation-model.md explaining the isolation model
- Update v2-checklist.md and v2-setup-wiring.md to reflect completed work
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Detailed status document for next session: what's done (two-DB split,
OneCLI, barrel, register.ts), what's not (channel skills don't call
register, no group creation step in setup, v1 add-discord incompatible).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Discord was directly imported in src/index.ts before the barrel wiring.
Moving to the barrel without uncommenting it broke Discord.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Import channel barrel from src/index.ts so channel skills that
uncomment lines in src/channels/index.ts actually execute
- Rewrite setup/register.ts to create v2 entities (agent_groups,
messaging_groups, messaging_group_agents) in data/v2.db instead
of v1's store/messages.db
- Fix setup/verify.ts to check v2 central DB for registered groups
- Add prominent "MESSAGE DROPPED" warnings in router when no agent
groups are wired, with actionable guidance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eliminates SQLite write contention across the host-container mount
boundary by splitting the single session.db into two files, each with
exactly one writer:
inbound.db — host writes (messages_in, delivered tracking)
outbound.db — container writes (messages_out, processing_ack)
Key changes:
- Host uses even seq numbers, container uses odd (collision-free)
- Container heartbeat via file touch instead of DB UPDATE
- Scheduling MCP tools now emit system actions via messages_out
(host applies them to inbound.db during delivery)
- Host sweep reads processing_ack + heartbeat file for stale detection
- OneCLI ensureAgent() call added (was missing from v2, caused
applyContainerConfig to reject unknown agent identifiers)
Verified: tsc clean, 327 tests pass, real e2e through Docker works.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace `as never` cast with proper polyfill for channelIdFromThreadId.
Narrow GatewayAdapter cast to only the gateway code path in bridge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move all v1 files (index, router, container-runner, db, ipc, types,
logger, channels/registry, and all utilities) to src/v1/ as a
fully self-contained archive with no shared dependencies
- Rename v2 files to remove -v2 suffix (index-v2.ts → index.ts, etc.)
- Update all imports across v2 source, tests, and setup files
- Migrate shared utilities (config, env, container-runtime, mount-security,
timezone, group-folder) from pino logger to v2 log module
- Migrate setup/ files from logger to log with argument order swap
- Container agent-runner: move v1 entry to v1/, rename v2 to index.ts
- Update setup skill to offer all 13 v2 channels
- Install all Chat SDK adapter packages
- dist/index.js now runs v2; dist/v1/index.js runs v1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace in-memory Chat SDK state with SqliteStateAdapter — thread
subscriptions now persist across restarts
- Add migration 002 for chat_sdk_kv, subscriptions, locks, lists tables
- Handle /clear in agent-runner (reset sessionId) — SDK has
supportsNonInteractive:false for this command
- Pass /compact, /context, /cost, /files through to SDK as admin commands
- Skip admin commands in follow-up poll so they start fresh queries
- Emit compact_boundary events as user-visible feedback messages
- Pass NANOCLAW_ADMIN_USER_ID and NANOCLAW_ASSISTANT_NAME to containers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
End-to-end ask_user_question flow:
- Agent MCP tool writes question card to messages_out
- Host delivery creates pending_questions row, delivers as Discord Card with buttons
- Local webhook server receives Gateway INTERACTION_CREATE events
- Acknowledges interaction + updates card to show selected answer
- Routes response back to session DB as system message
- MCP tool poll picks up response and returns to agent
Key fixes:
- Poll loop now skips system messages (reserved for MCP tool responses)
- Gateway listener uses webhookUrl forwarding mode for interaction support
- Button custom_id encodes questionId + option text for self-contained routing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Host sweep: fix DELETE journal mode, busy_timeout, seq in recurrence INSERT
- Outbound files: delivery reads from outbox dir, passes buffers to adapter,
cleans up after delivery. Chat SDK bridge sends files via postMessage.
- Inbound attachments: formatter includes attachment info in prompts
- Commands: categorize /commands as admin, filtered, or passthrough.
Admin commands check sender against NANOCLAW_ADMIN_USER_ID.
Filtered commands silently dropped. Passthrough sent raw to agent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use DELETE journal mode for session DBs instead of WAL. WAL doesn't
sync reliably across Docker volume mounts (VirtioFS), causing dropped
writes and duplicate deliveries.
- Add 20s idle detection to end the query stream. The concurrent poll
tracks SDK activity via a new 'activity' provider event. When no SDK
events arrive for 20s and no messages are pending, the stream ends
and the poll loop continues.
- Add touchProcessing heartbeat so the host can distinguish active
agents from idle ones by checking status_changed recency.
- Catch query errors in the poll loop and write error responses to
messages_out instead of crashing the process.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChannelAdapter interface with setup/deliver/teardown/setTyping lifecycle.
Self-registration pattern via channel-registry. Host wiring in index-v2
bridges inbound messages to routeInbound and outbound delivery to adapters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Override entrypoint to compile and run index-v2.js (no stdin)
- Add better-sqlite3 + @types to agent-runner dependencies
- Exclude test files from agent-runner tsconfig (Docker build)
- Add real e2e test script (host → container → Claude → session DB)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Host orchestrator connecting channel events to session DBs and
delivering responses back through channel adapters.
- session-manager.ts: session folder/DB lifecycle, message writing
- container-runner-v2.ts: Docker spawn with session + agent group
mounts, OneCLI, idle timeout, agent-runner recompilation
- router-v2.ts: inbound routing (channel → messaging group → agent
group → session → messages_in → wake container)
- delivery.ts: two-tier polling (1s active, 60s sweep) for
messages_out, channel adapter delivery
- host-sweep.ts: stale detection with backoff, recurrence, wake
containers for due messages
- index-v2.ts: thin entry point wiring everything together
- scripts/test-v2-agent.ts: real Claude provider integration test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Poll loop end-to-end with mock provider: message pickup, batch
processing, concurrent polling for late arrivals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AgentProvider abstraction with Claude and Mock implementations.
Poll loop reads messages_in, formats by kind, queries provider,
writes results to messages_out. Concurrent polling pushes follow-up
messages into active queries.
- providers/types.ts: AgentProvider, AgentQuery, ProviderEvent
- providers/claude.ts: wraps Agent SDK with MessageStream, hooks,
transcript archiving
- providers/mock.ts: canned responses with push() support
- providers/factory.ts: createProvider()
- formatter.ts: format by kind (chat/task/webhook/system), XML
escaping, routing extraction
- poll-loop.ts: poll → format → query → write, concurrent polling
- mcp-tools.ts: MCP server with send_message tool
- index-v2.ts: new entry point (config from env, enters poll loop)
- 11 new tests, all 288 tests pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add the v2 data layer: typed interfaces, central DB with migration
runner, per-entity CRUD, and agent-runner session DB operations.
- src/log.ts: concise message-first logging API
- src/types-v2.ts: AgentGroup, MessagingGroup, Session, MessageIn/Out
- src/db/: connection (WAL), migration runner, 001-initial schema,
CRUD for agent_groups, messaging_groups, sessions, pending_questions
- container/agent-runner/src/db/: session DB connection, messages_in
reads + status transitions, messages_out writes
- 31 new tests, all 277 tests pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Skills document env vars in SKILL.md instead of patching config.ts.
Prettier printWidth 120 to keep log calls and signatures on one line.
Thin logging wrapper for one-line structured log calls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Based on analysis of 33 skill branches. Maps each conflict hotspot
(index.ts, config.ts, container-runner.ts, db.ts) to its v2 solution.
Adds mount registration pattern so channel skills don't edit
container-runner. Config stays in the module that uses it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Split DB by entity (agent-groups.ts, messaging-groups.ts, sessions.ts)
instead of one monolith. Numbered migration files replace inline
ALTER TABLE blocks. Channels use barrel pattern for self-registration.
Session DB split into messages-in.ts and messages-out.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Channels, MCP tools, and providers use registration patterns so
skill branches can add capabilities without conflicting. Index
stays thin. File map updated with channels/ and mcp-tools/
directories.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Supply chain protection — npm will not install package versions
published less than 7 days ago.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Lint schedule now uses NanoClaw scheduled_tasks table instead of
Claude Code cron — runs in the group's agent container
2. CLAUDE.md must enforce one-at-a-time file ingestion — never batch
3. Expanded CLAUDE.md guidance: explain system, index files, point to
container skill, enforce ingest discipline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove hardcoded file path checks. Step 4 now discusses source types
with the user and helps install needed skills dynamically. Fix "use use"
typo and change curl example to file download.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove pre-written container skill. Instead, include llm-wiki.md
(Karpathy's gist) as the reference material and have the setup skill
guide the user through collaboratively building their own wiki schema,
container skill, and directory structure based on the pattern.
Add NanoClaw-specific notes: image vision, PDF reader, voice
transcription, curl for full document fetch, file attachment handling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Container skill teaches the agent to maintain a structured, interlinked
wiki from ingested sources. Feature skill bootstraps the setup — directory
structure, group CLAUDE.md, optional scheduled lint.
Based on Karpathy's LLM Wiki pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Main group had no mount for the global memory directory
(/workspace/global), so it could only reach it through the read-only
project root. This meant the main agent couldn't write to global
memory despite groups/main/CLAUDE.md instructing it to do so.
Add a writable mount at /workspace/global for the isMain branch,
matching the read-only mount that non-main groups already have.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The documented path /workspace/project/groups/global/CLAUDE.md doesn't
match the actual mount point /workspace/global. This caused agents to
look for global memory at a nonexistent path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace macOS-only `stat -f%z` with portable `wc -c` for Linux compat
- Replace `find | while` pipes with process substitution so TOTAL_FREED
counter survives the loop (pipe runs in subshell, losing mutations)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Session files (JSONLs, debug logs, todos, telemetry, group logs) accumulate
unboundedly — especially from daily cron tasks. This adds a cleanup script
that prunes old artifacts while protecting active sessions (read from DB),
and wires it into the main process on a 24h interval.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use sonnet[1m] for full 1M context window and set auto-compact at 200k
tokens to keep costs down while preserving access to extended context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Matches the pattern used by /setup and /update-nanoclaw. Captures
migration-specific properties (tier, phase, customization count,
skill interactions). Opt-out permanently disables across all skills.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Based on analysis of a live migration (v1.2.42 -> v1.2.47):
1. Absolute worktree paths: Bash tool resets cwd between calls,
so relative cd .upgrade-worktree fails. Store PROJECT_ROOT and
WORKTREE as absolute paths, use them throughout.
2. Smarter tier assessment: discount files from skill merges when
counting — a fork with 3 skills and no other changes is Tier 2,
not Tier 3 just because 24 files changed.
3. Inter-skill conflict analysis: new "Skill Interactions" section
in the migration guide captures conflicts between applied skills
(duplicate declarations, conflicting env var handling).
4. Cleaner swap recipe: use git reset --hard to the upgrade commit
instead of git checkout -B intermediate branch. Backup tag
preserves rollback. Copy guide to /tmp before worktree removal.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces merge-based upgrades with a two-phase approach:
1. Extract: analyzes user's fork, captures customizations as a
migration guide (intent + implementation details in markdown)
2. Upgrade: checks out clean upstream in a worktree, reapplies
customizations from the guide, validates, and swaps in
Key features:
- Tiered complexity (lightweight/standard/complex)
- Sub-agent exploration with haiku for efficient analysis
- Incremental guide updates instead of full re-extraction
- Live e2e testing via worktree symlinks before swapping
- New-changes guard prevents losing unrecorded work
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mount store/ separately as read-write so the main agent can access
the SQLite database directly.
- Add requiresTrigger parameter to the register_group MCP tool
(host IPC already supported it, but the tool never exposed it).
Defaults to false (no trigger).
- Update group registration instructions to ask user about trigger.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously required `[BREAKING]` at the start of the line, missing
entries formatted as `- [BREAKING] ...` in changelogs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add generic reply context fields to NewMessage (reply_to_message_id,
reply_to_message_content, reply_to_sender_name) so any channel can
pass quoted message context to the agent.
- Add thread_id and reply_to_* fields to NewMessage interface
- Add DB migration for reply context columns on messages table
- Update storeMessage/getMessagesSince/getNewMessages to persist and
retrieve reply fields
- Render reply context as <quoted_message> XML in formatMessages
- Add DB and formatting tests
Co-Authored-By: Alfred-the-buttler <leon.alfred.bot@gmail.com>
Co-Authored-By: moktamd <moktamd@users.noreply.github.com>
Co-Authored-By: gurixs-carson <gurixs-carson@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The original regex didn't match the actual error ("No conversation
found with session ID: ..."). Added `no conversation found` pattern.
Removed the inline retry — clearing the session and returning 'error'
lets the existing group-queue.ts backoff loop retry with a fresh
session naturally. Simpler, no duplicate error paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When Claude Code exits with code 1 during a session resume because the
session transcript file no longer exists (ENOENT on .jsonl), clear the
stale session from SQLite and retry once with a fresh session.
Detection is targeted: only triggers on ENOENT referencing a .jsonl
file or explicit "session not found" errors. Transient failures
(network, API) fall through to the normal backoff retry path.
Also removes unrelated ollama files that were mixed in during rebase.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When Claude Code exits with code 1 during a session resume, the group's
session ID is now cleared from the database and the query is retried with
a fresh session. This prevents the infinite retry loop that occurred when
a stale/corrupt session ID was stored in SQLite.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The apple-container branch already includes the credential proxy code.
Applying /use-native-credential-proxy on top would conflict. Setup now
inlines the credential collection steps instead of delegating.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OneCLI is incompatible with Apple Container. Setup now picks the
credential system after the container runtime: Docker → OneCLI,
Apple Container → native credential proxy. Also marks Apple Container
as experimental, pauses after claude setup-token, limits AskUserQuestion
to multiple-choice, and removes telegram swarm upsell.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix container-runner bug: stopContainer() returns void but was
passed to exec() as a command string. Replace with direct call
and try/catch.
- Mock container-runtime in tests so they don't need Docker running.
- Increase claw-skill test timeout to handle slower python startup.
- Clear .env.example (telegram token was added by mistake).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When lastAgentTimestamp was missing (new group, corrupted state, or
startup recovery), the empty-string fallback caused getMessagesSince to
return up to 200 messages — the entire group history. This sent a
massive prompt to the container agent instead of just recent messages.
Fix: recover the cursor from the last bot reply timestamp in the DB
(proof of what we already processed), and cap all prompt queries to a
configurable MAX_MESSAGES_PER_PROMPT (default 10). Covers all three
call sites: processGroupMessages, the piping path, and
recoverPendingMessages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Kubernetes image garbage collection silently deletes the nanoclaw-agent
image when disk usage is high because ephemeral containers don't
protect the image from GC. Documents symptoms, cause, fix, and diagnosis.
Drop 23 transitive dependencies by replacing pino + pino-pretty with a
~70-line logger that matches the same output format and API. All 80+
call sites work unchanged. Production deps now: @onecli-sh/sdk,
better-sqlite3, cron-parser.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update SKILL.md to ask users during setup whether they want model
management tools (pull, delete, show, list-running) and set
OLLAMA_ADMIN_TOOLS=true in .env accordingly. Core inference tools
remain always available.
Incorporates #1456 by @bitcryptic-gw. Closes#1331.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
None of these are imported or referenced by the main codebase.
yaml had zero imports; zod is only used in container/agent-runner
(which has its own package.json); coverage-v8 was never configured.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A value like `X=a` would pass the startsWith/endsWith quote check
(both `"` and `'` are single chars), then slice(1, -1) would produce
an empty string, silently dropping the value. Add length >= 2 guard
before checking for surrounding quotes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
**stopContainer (container-runtime.ts):**
- Validate container name against `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$` before
passing to shell command. Rejects names with shell metacharacters
(`;`, `$()`, backticks, etc.) that could execute arbitrary commands.
- Changed return type from string to void — callers no longer build
shell commands from the return value.
**mount-security.ts:**
- Reject container paths containing `:` to prevent Docker `-v` option
injection (e.g., `repo:rw` could override readonly flags).
- Don't permanently cache "file not found" for mount allowlist — the
file may be created later without requiring a service restart. Only
parse/structural errors are permanently cached.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use third-person voice and clearer terminology for the task scripts
intro paragraph.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The task snapshot mappings in index.ts were omitting the script field,
making it appear that scheduled tasks had no script even when one was
stored in the database.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Broadens the trigger from "check or monitor" to "any recurring task",
adds context about API credit usage and account risk for frequent tasks,
and prompts the agent to clarify ambiguous requests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reword Task Scripts opening in main template to guide agents toward
schedule_task instead of inline bash loops. Add missing Task Scripts
section to global template — non-main groups have unrestricted access
to schedule_task with script parameter, so omitting instructions just
leads to worse patterns.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clarify that only long-lived OAuth tokens (claude setup-token) or API keys
should be used — short-lived tokens from the keychain expire within hours
and cause recurring 401s. Also update native credential proxy skill to
swap the OneCLI reference when applied.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Channel tokens (Telegram, Slack, Discord) are used by the host
process, not by containers via the gateway. Only offer to migrate
credentials that containers use for outbound API calls (OpenAI,
Parallel, etc.).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After migrating Anthropic credentials, the skill now scans .env for
other service tokens (Telegram, Slack, Discord, OpenAI, etc.) and
offers to move them into OneCLI Agent Vault as well.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Operational skill that installs OneCLI, configures the Agent Vault
gateway, and migrates existing .env credentials into the vault.
Designed to run after /update-nanoclaw introduces OneCLI as a
breaking change. Added [BREAKING] changelog entry so update-nanoclaw
automatically offers to run /init-onecli.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace references to the old built-in credential proxy with OneCLI's
Agent Vault across README (feature list, FAQ) and docs/SECURITY.md
(credential isolation section, architecture diagram).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds four new MCP tools to the existing ollama integration, consolidating
model management (from #1331) into the single add-ollama-tool skill as
requested by @gavrielc:
- ollama_pull_model — pull a model from the Ollama registry
- ollama_delete_model — delete a local model to free disk space
- ollama_show_model — inspect modelfile, parameters, and architecture
- ollama_list_running — list models loaded in memory with VRAM/processor info
All four tools follow the existing patterns in this file: OLLAMA_HOST env
var, ollamaFetch() with host.docker.internal fallback, log() and
writeStatus() helpers. No changes to index.ts or container-runner.ts
needed — OLLAMA_HOST is already forwarded via sdkEnv.
Also updates SKILL.md description, tool list, verify steps, and adds a
troubleshooting entry for large-model pull timeouts.
Closes#1331.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SKILL.md for the Emacs channel — an HTTP bridge that lets Emacs
send messages to NanoClaw and poll for responses. Source code lives on
the skill/emacs branch.
Co-Authored-By: Ken Bolton <ken@bscientific.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Telegram Markdown v1 renders [text](url) links natively — they are now
preserved rather than flattened to "text (url)". Update the skill table
to reflect the actual post-fix behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SKILL.md for channel-aware text formatting. When applied, converts
Claude's Markdown output to each channel's native syntax (WhatsApp,
Telegram, Slack) before delivery. Source code lives on the
skill/channel-formatting branch.
Co-Authored-By: Ken Bolton <ken@bscientific.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Main groups (e.g. telegram_main) should get the full main template
with Admin Context section, not the minimal global template.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous wording ("Send diagnostics data by following ...") was too
passive — Claude treated the backtick-quoted path as informational rather
than an action, so the diagnostics file was never actually read and the
PostHog prompt was silently skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These workflows auto-resolved package.json conflicts with --theirs,
silently stripping fork-specific dependencies during upstream syncs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a script returns wakeAgent=false, set result to null so the host
doesn't forward an internal status string to the user's chat.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The registerGroup() function in index.ts creates the group folder and
logs subdirectory but never copies the global CLAUDE.md template.
Agents in newly registered groups start without identity or
instructions until the container is manually fixed.
Copy groups/global/CLAUDE.md into the new group folder on registration,
substituting the assistant name if it differs from the default.
Closes#1391
The example "1234567890" was ambiguous — users couldn't tell where the
country code ended and the number began, and some included a leading "+"
which caused pairing to fail. Use a realistic US example (14155551234)
and explicit formatting rules in both the prompt and troubleshooting.
Closes#447
Without linger enabled, systemd terminates all user-level processes
(including the NanoClaw service) when the last SSH session closes.
This adds `loginctl enable-linger` during setup for non-root users.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
POSIX-style TZ strings like IST-2 cause a hard RangeError crash in
formatMessages because Intl.DateTimeFormat only accepts IANA identifiers.
- Add isValidTimezone/resolveTimezone helpers to src/timezone.ts
- Make formatLocalTime fall back to UTC on invalid timezone
- Validate TZ candidates in config.ts before accepting
- Add timezone setup step to detect and prompt when autodetection fails
- Use node:22-slim in Dockerfile (node:24-slim Trixie package renames)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Auto-resolve .env.example (keep fork's channel-specific vars) and
.github/workflows/* (always take upstream) during fork sync
- Add docker-sandbox and docker-sandbox-windows to dispatch list
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The fork-sync and merge-forward workflows were failing on every run
because package-lock.json, package.json (version), and badge.svg
always conflict between upstream and forks. These are always safe to
take from upstream/main. Now auto-resolves these trivial conflicts
and only fails on real code conflicts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gives Claude context on how credentials/API keys/OAuth tokens are managed via the OneCLI gateway, so it doesn't suggest storing secrets in .env or passing them to containers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add SKILL.md for the native credential proxy feature skill.
Delete src/credential-proxy.ts and src/credential-proxy.test.ts
which became dead code after PR #1237 (OneCLI integration).
These files live on the skill/native-credential-proxy branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The promotion logic (overwriting CLAUDE.md when a group becomes main)
is unsafe. Real-world setups use is_main for groups that intentionally
lack admin context — e.g. a family chat (whatsapp_casa) with 144 lines
of custom persona, PARA workspace, task management, and family context.
Overwriting based on missing "## Admin Context" would destroy user work.
register.ts now follows a simple rule: create template for new folders,
never touch existing files. Tests updated to verify preservation across
re-registration and main promotion scenarios.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a non-main group is re-registered with --is-main, the existing
CLAUDE.md (copied from global template) lacked admin context. Now
register.ts detects this promotion case and replaces it with the main
template. Files that already contain "## Admin Context" are preserved.
Adds tests for:
- promoting non-main to main upgrades the template
- cross-channel promotion (e.g. Telegram non-main → main)
- promotion with custom assistant name
- re-registration preserves user-modified main CLAUDE.md
- re-registration preserves user-modified non-main CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces single-channel tests with multi-channel scenarios:
- each channel can have its own main with admin context
- non-main groups across channels get global template
- custom name propagates to all channels and groups
- user-modified CLAUDE.md preserved on re-registration
- missing templates handled gracefully
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds 5 tests verifying the template copy and glob-based name update
logic introduced in the parent commit:
- copies global template for non-main groups
- copies main template for main groups
- does not overwrite existing CLAUDE.md
- updates name across all groups/*/CLAUDE.md files
- handles missing template gracefully (no crash)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When registering a new group, create CLAUDE.md in the group folder from
the appropriate template (groups/main/ for main groups, groups/global/
for others). Without this, the container agent runs with no CLAUDE.md
since its CWD is /workspace/group (the group folder).
Also update the name-replacement glob to cover all groups/*/CLAUDE.md
files rather than only two hardcoded paths, so newly created files and
any future group folders are updated correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README.md: add docs.nanoclaw.dev link, point architecture and security
references to documentation site
- CHANGELOG.md: add all releases from v1.1.0 through v1.2.21 (was only v1.2.0),
link to full changelog on docs site
- docs/REQUIREMENTS.md: update multi-channel references (NanoClaw now supports
WhatsApp, Telegram, Discord, Slack, Gmail), update RFS to reflect existing
skills, fix deployment info (macOS + Linux)
- docs/SECURITY.md: generalize WhatsApp-specific language to channel-neutral
- docs/DEBUG_CHECKLIST.md: use Docker commands (default runtime) instead of
Apple Container syntax, generalize WhatsApp references
- docs/README.md: new file pointing to docs.nanoclaw.dev as the authoritative
source, with mapping table from local files to docs site pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claw was running containers with no volume mounts, so the agent
always saw an empty /workspace/group. Add build_mounts() to
replicate the same bind-mounts that container-runner.ts sets up
(group folder, .claude sessions, IPC dir, agent-runner source,
and project root for main).
Also includes upstream fix from qwibitai/nanoclaw#1368:
graceful terminate() before kill() on output sentinel, and early
return after a successful structured response so exit code stays 0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The skip message mentioned --force but parseArgs didn't handle it,
making it a false promise. Now --force is parsed and passed through,
allowing users to regenerate the mount allowlist when needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/setup overwrote ~/.config/nanoclaw/mount-allowlist.json unconditionally,
clobbering any user customizations made after initial setup. Now checks for
the file first and skips with a 'skipped' status if it exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The diagnostics section used a markdown link that Claude never resolved,
so the prompt was silently skipped. Replace with a numbered step (setup)
and mandatory final step (update-nanoclaw) that instructs Claude to use
the Read tool on the full file path. Update opt-out instructions to
match the renamed section headings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The OneCLI integration removed the upstream subscription/API key question
and only offered dashboard vs CLI. This restores the choice so users with
a Claude Pro/Max subscription can use `claude setup-token` to get their
OAuth token, while API key users get the existing flow.
Both paths converge to the same `onecli secrets create --type anthropic`
command — OneCLI handles both token types transparently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These workflows use APP_ID/APP_PRIVATE_KEY secrets that only exist on
the upstream repo. Without a fork guard they fail on every push for
every fork. merge-forward-skills already has the correct guard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Configure CLI api-host to local instance (defaults to cloud otherwise)
- Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues
- Present dashboard and CLI as two options with platform guidance
- Accept ONECLI_URL as valid credentials in verify step
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- "Never ask again" now removes diagnostics from both skills
- Added shell commands to gather version, platform, arch, node version
- Show only properties object to user, not api_key/distinct_id
- Write full PostHog payload to temp file, send with curl -d @file
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove send-diagnostics.ts entirely. Claude writes the JSON, shows
it to the user, and sends via curl. Opt-out is permanent: Claude
replaces diagnostics.md contents and removes the section from SKILL.md.
No dependencies, no state files, no .nanoclaw/ directory.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Show example commands with placeholder values. Claude fills in the
actual values from the session in one shot — no multi-step build process.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace shared _shared/diagnostics.md with dedicated diagnostics.md
files in setup/ and update-nanoclaw/. Each contains only the event
types relevant to that skill. References updated to local links.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove diagnostics appendage from all other skills. Only /setup and
/update-nanoclaw need telemetry — these are the two points where we
can detect regressions and track improvements across the user base.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Demote Docker Sandboxes from a prominent hero banner to inline
mentions in the features list and FAQ. New users now land on
Quick Start first.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the Python CLI script from inline markdown into scripts/claw,
aligning with the Claude Code skills standard (code in files, not md).
Remove non-standard `author` frontmatter field. SKILL.md now uses
${CLAUDE_SKILL_DIR} substitution to copy the script during install.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add ESLint v9.35+ with typescript-eslint recommended config and
error-handling rules: preserve-caught-error (enforces { cause } when
re-throwing), no-unused-vars with caughtErrors:all, and
eslint-plugin-no-catch-all (warns on catch blocks that don't rethrow).
Fix existing violations: add error cause to container-runtime rethrow,
prefix unused vars with underscore, remove unused imports.
https://claude.ai/code/session_01JPjzhBp9PR5LtfLWVDrYrH
Add a new skill that teaches agents how to format messages using Slack's
mrkdwn syntax. Updates agent CLAUDE.md files to detect channel type from
folder name prefix and use appropriate formatting.
- container/skills/slack-formatting/SKILL.md: comprehensive mrkdwn reference
- groups/global/CLAUDE.md: channel-aware formatting instructions
- groups/main/CLAUDE.md: same, plus emoji shortcode examples
https://claude.ai/code/session_01W44WtL2gRETr9YBB6h62YM
Previously, sub-skills (e.g. /add-telegram) skipped diagnostics when
called from a parent skill like /setup. This lost channel-level events.
Now all events are collected and shown to the user in a single prompt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ephemeral UUID is harmless but showing it to users creates unnecessary
doubt about whether they're being tracked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per-event consent diagnostics that sends anonymous install/update/skill data
to PostHog. Conflict filenames are gated against upstream. Supports --dry-run
to show exact payload before sending, and "never ask again" opt-out via state.yaml.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass -t 1 to docker stop, reducing SIGTERM-to-SIGKILL grace period from
10s to 1s. NanoClaw containers are stateless (--rm, mounted filesystems)
so they don't need a long grace period. Makes restarts ~10x faster.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Container error logs wrote the full ContainerInput (including user
prompt) to disk on every non-zero exit. The structured log stream
also included the first 200 chars of agent output.
- container-runner: only include full input at verbose level; error
path now logs prompt length and session ID instead
- index: log output length instead of content snippet
Fixes#1150
Thread the optional `script` field through the IPC layer so it is
persisted when an agent calls schedule_task, and updated when an agent
calls update_task (empty string clears the script).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add optional `script` field to schedule_task and update_task MCP tools,
allowing agents to attach a pre-flight bash script that controls whether
the task agent is woken up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds optional `script` field to the ScheduledTask interface, with a
migration for existing DBs and full support in createTask/updateTask.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
systemd's default KillMode=control-group kills all processes in the
cgroup on service restart, including the detached claude remote-control
process. KillMode=process only kills the main Node.js process, letting
detached children survive. restoreRemoteControl() already handles
reattaching on startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`claude remote-control` prompts "Enable Remote Control? (y/n)" on every
launch. With stdin set to 'ignore', the process exits immediately because
it cannot read the response. Pipe 'y\n' to stdin instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously all messages starting with / were silently dropped. This
prevented NanoClaw-level commands like /remote-control from reaching
the onMessage callback. Now only Telegram bot commands (/chatid, /ping)
are skipped; everything else flows through as a regular message.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Users can send /remote-control from the main group in any channel to
spawn a detached `claude remote-control` process on the host. The
session URL is sent back through the channel. /remote-control-end
kills the session.
Key design decisions:
- One global session at a time, restricted to main group only
- Process is fully detached (stdout/stderr to files, not pipes) so it
survives NanoClaw restarts
- PID + URL persisted to data/remote-control.json; restored on startup
- Commands intercepted in onMessage before DB storage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step-by-step guide for running NanoClaw in Docker Sandboxes from
scratch without the install script. Covers proxy patches, DinD
mount fixes, channel setup, networking details, and troubleshooting.
Validated on macOS (Apple Silicon) with WhatsApp — other channels
and environments may need additional proxy patches.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keep heading and description centered, but left-align the install
blocks and labels so they don't clash with the code block layout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the Agent Swarms / Claude Code lines at the top with a
prominent Docker Sandboxes announcement section including install
commands and a link to the blog post.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
grammY creates its own https.Agent internally, bypassing any global
proxy. In Docker Sandbox, NanoClaw sets https.globalAgent to a proxy
agent at startup. This tells grammY to use it instead. On non-sandbox
setups it's a no-op.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of failing on package-lock.json merge conflicts, take the
fork's version and continue. Applied to all channel skill merge
instructions and CLAUDE.md troubleshooting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The sendTelegramMessage helper now passes { parse_mode: 'Markdown' }
to bot.api.sendMessage, but three tests still expected only two args.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
setup.sh ran npm install which modified package-lock.json, causing
git merge to refuse during channel skill installation. Switch to
npm ci (deterministic, doesn't modify lockfile) and clean up stale
peer flags in the lockfile.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wrap outbound sendMessage calls with parse_mode: 'Markdown' so that
Claude's natural formatting (*bold*, _italic_, `code`, etc.) renders
correctly in Telegram instead of showing raw asterisks and underscores.
Falls back to plain text if Telegram rejects the Markdown formatting.
Previously, current_tasks.json was only written at container-start time,
so tasks created (or paused/cancelled/updated) during a session were
invisible to list_tasks until the next invocation.
Add an onTasksChanged callback to IpcDeps, called after every successful
mutation in processTaskIpc (schedule_task, pause_task, resume_task,
cancel_task, update_task). index.ts wires it up to write fresh snapshots
for all registered groups immediately, keeping no new coupling between
ipc.ts and the container layer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move skill definitions from the nanoclaw-skills marketplace plugin
into .claude/skills/ so they're available as unprefixed slash commands
(e.g. /add-whatsapp instead of /nanoclaw-skills:add-whatsapp).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the custom skills engine with standard git operations.
Feature skills are now git branches (on upstream or channel forks)
applied via `git merge`. Channels are separate fork repos.
- Remove skills-engine/ (6,300+ lines), apply/uninstall/rebase scripts
- Remove old skill format (add/, modify/, manifest.yaml) from all skills
- Remove old CI (skill-drift.yml, skill-pr.yml)
- Add merge-forward CI for upstream skill branches
- Add fork notification (repository_dispatch to channel forks)
- Add marketplace config (.claude/settings.json)
- Add /update-skills operational skill
- Update /setup and /customize for marketplace plugin install
- Add docs/skills-as-branches.md architecture doc
Channel forks created: nanoclaw-whatsapp (with 5 skill branches),
nanoclaw-telegram, nanoclaw-discord, nanoclaw-slack, nanoclaw-gmail.
Upstream retains: skill/ollama-tool, skill/apple-container, skill/compact.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: implement credential proxy for enhanced container environment isolation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address PR review — bind proxy to loopback, scope OAuth injection, add tests
- Bind credential proxy to 127.0.0.1 instead of 0.0.0.0 (security)
- OAuth mode: only inject Authorization on token exchange endpoint
- Add 5 integration tests for credential-proxy.ts
- Remove dangling comment
- Extract host gateway into container-runtime.ts abstraction
- Update Apple Container skill for credential proxy compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: scope OAuth token injection by header presence instead of path
Path-based matching missed auth probe requests the CLI sends before
the token exchange. Now the proxy replaces Authorization only when
the container actually sends one, leaving x-api-key-only requests
(post-exchange) untouched.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: bind credential proxy to docker0 bridge IP on Linux
On bare-metal Linux Docker, containers reach the host via the bridge IP
(e.g. 172.17.0.1), not loopback. Detect the docker0 interface address
via os.networkInterfaces() and bind there instead of 0.0.0.0, so the
proxy is reachable by containers but not exposed to the LAN.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: bind credential proxy to loopback on WSL
WSL uses Docker Desktop with the same VM routing as macOS, so
127.0.0.1 is correct and secure. Without this, the fallback to
0.0.0.0 was triggered because WSL has no docker0 interface.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: detect WSL via /proc instead of env var
WSL_DISTRO_NAME isn't set under systemd. Use
/proc/sys/fs/binfmt_misc/WSLInterop which is always present on WSL.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Scheduled tasks that send messages via send_message (IPC) instead of
returning text as result left the container idle for ~30 minutes until
the hard timeout killed it (exit 137). This blocked new messages for
the group during that window.
Root cause: scheduleClose() was only called inside the
`if (streamedOutput.result)` branch. Tasks that communicate solely
through IPC (e.g. heartbeat check-ins) complete with result=null,
so the 10s close timer was never set.
Fix: also call scheduleClose() on status==='success', covering both
result-based and IPC-only task completions.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(skill): add reactions skill (emoji reactions + status tracker)
* refactor(reactions): minimize overlays per upstream review
Address gavrielc's review on qwibitai/nanoclaw#509:
- SKILL.md: remove all inline code, follow add-telegram/add-whatsapp pattern (465→79 lines)
- Rebuild overlays as minimal deltas against upstream/main base
- ipc-mcp-stdio.ts: upstream base + only react_to_message tool (8% delta)
- ipc.ts: upstream base + only reactions delta (14% delta)
- group-queue.test.ts: upstream base + isActive tests only (5% delta)
- Remove group-queue.ts overlay (isActive provided by container-hardening)
- Remove group-queue.ts from manifest modifies list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs in the DM with dedicated bot number setup:
1. The skill asked for the bot's own phone number to use as the JID.
But from the bot's perspective, incoming DMs appear with the SENDER's
JID (the user's personal number), not the bot's own number. The
registration must use the user's personal number as the JID.
2. DM with bot (1:1 conversation) should use --no-trigger-required, same
as self-chat. A trigger prefix is unnecessary in a private DM.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The pairing code was only emitted to stdout, which is buffered by the
calling process and not visible until the auth command exits (~120s).
By also writing to store/pairing-code.txt the moment the code is ready,
callers can poll that file and display the code to the user within seconds
instead of after the 60s expiry window.
Update the add-whatsapp skill instructions to use the background +
file-poll pattern instead of waiting on buffered stdout.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Thanks @glifocat! Clean skill package — good docs, solid tests, nice intent files. Pushed a small fix for path traversal on the PDF filename before merging.
getNewMessages() and getMessagesSince() loaded all rows after a
checkpoint with no cap, causing growing memory and token costs.
Both queries now use a DESC LIMIT subquery to return only the
most recent N messages, re-sorted chronologically.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: per-group timezone architecture with context injection (#483)
Implement a comprehensive timezone consistency layer so the AI agent always
receives timestamps in the user's local timezone. The framework handles all
UTC↔local conversion transparently — the agent never performs manual timezone
math.
Key changes:
- Per-group timezone stored in containerConfig (no DB migration needed)
- Context injection: <context timezone="..." current_time="..." /> header
prepended to every agent prompt with local time and IANA timezone
- Message timestamps converted from UTC to local display in formatMessages()
- schedule_task translation layer: agent writes local times, framework
converts to UTC using per-group timezone for cron, once, and interval types
- Container TZ env var now uses per-group timezone instead of global constant
- New set_timezone MCP tool for users to update their timezone dynamically
- NANOCLAW_TIMEZONE passed to MCP server environment for tool confirmations
Architecture: Store UTC everywhere, convert at boundaries (display to agent,
parse from agent). Groups without timezone configured fall back to the server
TIMEZONE constant for full backward compatibility.
Closes#483Closes#526
Co-authored-by: shawnYJ <shawny011717@users.noreply.github.com>
Co-authored-by: Adrian <Lafunamor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* style: apply prettier formatting
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: strip to minimalist context injection — global TIMEZONE only
Remove per-group timezone support, set_timezone MCP tool, and all
related IPC handlers. The implementation now uses the global system
TIMEZONE for all groups, keeping the diff focused on the message
formatting layer: mandatory timezone param in formatMessages(),
<context> header injection, and formatLocalTime/formatCurrentTime
helpers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: drop formatCurrentTime and simplify context header
Address PR review: remove redundant formatCurrentTime() since message
timestamps already carry localized times. Simplify <context> header to
only include timezone name.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: shawnYJ <shawny011717@users.noreply.github.com>
Co-authored-by: Adrian <Lafunamor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The modify/src/channels/whatsapp.ts patch in the add-voice-transcription
skill was missing the registerChannel() call and its registry import.
When the three-way merge ran, this caused the WhatsApp channel to silently
skip registration on every service restart — messages were never received.
Added the missing import and registerChannel factory with a creds.json
guard, matching the pattern used by the add-whatsapp skill template.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The send_message tool description incorrectly stated that a scheduled
task's final output is not delivered to the user, instructing agents to
use the MCP tool for any communication. In reality, task-scheduler.ts
unconditionally forwards the agent's result to the user via a streaming
output callback (deps.sendMessage), which is a direct call to the
channel layer — entirely separate from the MCP tool path.
This caused agents following the description to call send_message
explicitly, resulting in duplicate messages: once via MCP and once via
the native streaming callback.
- Remove the incorrect note from the send_message tool description
- Fix the misleading comment at task-scheduler.ts which attributed
result delivery to the MCP tool rather than the streaming callback
schedule_task was creating duplicate tasks when users asked to modify
a schedule, because the agent had no way to update an existing task
and didn't know the ID of the task it created. Now schedule_task
generates and returns the task ID, and a new update_task tool allows
modifying prompt, schedule_type, and schedule_value in place.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add /add-ollama skill for local model inference
Adds a skill that integrates Ollama as an MCP server, allowing the
container agent to offload tasks to local models (summarization,
translation, general queries) while keeping Claude as orchestrator.
Skill contents:
- ollama-mcp-stdio.ts: stdio MCP server with ollama_list_models and
ollama_generate tools
- ollama-watch.sh: macOS notification watcher for Ollama activity
- Modifications to index.ts (MCP config) and container-runner.ts
(log surfacing)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: rename skill from /add-ollama to /add-ollama-tool
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: gavrielc <gabicohen22@yahoo.com>
The underscore prefix convention signals an unused parameter, but it's
now actively used by the sender allowlist logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: atomic claim prevents scheduled tasks from executing twice (#138)
Replace the two-phase getDueTasks() + deferred updateTaskAfterRun() with
an atomic SQLite transaction (claimDueTasks) that advances next_run
BEFORE dispatching tasks to the queue. This eliminates the race window
where subsequent scheduler polls re-discover in-progress tasks.
Key changes:
- claimDueTasks(): SELECT + UPDATE in a single db.transaction(), so no
poll can read stale next_run values. Once-tasks get next_run=NULL;
recurring tasks get next_run advanced to the future.
- computeNextRun(): anchors interval tasks to the scheduled time (not
Date.now()) to prevent cumulative drift. Includes a while-loop to
skip missed intervals and a guard against invalid interval values.
- updateTaskAfterRun(): simplified to only record last_run/last_result
since next_run is already handled by the claim.
Closes#138, #211, #300, #578
Co-authored-by: @taslim (PR #601)
Co-authored-by: @baijunjie (Issue #138)
Co-authored-by: @Michaelliv (Issue #300)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* style: apply prettier formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: track running task ID in GroupQueue to prevent duplicate execution (#138)
Previous commits implemented an "atomic claim" approach (claimDueTasks)
that advanced next_run before execution. Per Gavriel's review, this
solved the symptom at the wrong layer and introduced crash-recovery
risks for once-tasks.
This commit reverts claimDueTasks and instead fixes the actual bug:
GroupQueue.enqueueTask() only checked pendingTasks for duplicates, but
running tasks had already been shifted out. Adding runningTaskId to
GroupState closes that gap with a 3-line fix at the correct layer.
The computeNextRun() drift fix is retained, applied post-execution
where it belongs.
Closes#138, #211, #300, #578
Co-authored-by: @taslim (PR #601)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add changelog entry for scheduler duplicate fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add contributors for scheduler race condition fix
Co-Authored-By: Taslim <9999802+taslim@users.noreply.github.com>
Co-Authored-By: BaiJunjie <7956480+baijunjie@users.noreply.github.com>
Co-Authored-By: Michael <13676242+Michaelliv@users.noreply.github.com>
Co-Authored-By: Kyle Zhike Chen <3477852+kk17@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: gavrielc <gabicohen22@yahoo.com>
Co-authored-by: Taslim <9999802+taslim@users.noreply.github.com>
Co-authored-by: BaiJunjie <7956480+baijunjie@users.noreply.github.com>
Co-authored-by: Michael <13676242+Michaelliv@users.noreply.github.com>
Co-authored-by: Kyle Zhike Chen <3477852+kk17@users.noreply.github.com>
Thanks for the great contribution @glifocat! This is a really well-structured skill — clean package, thorough docs, and solid test coverage. Hope to see more skills like this from you!
Wrap the inner message processing loop in a try-catch to prevent a
single malformed or edge-case message from crashing the entire handler.
Logs the error with remoteJid for debugging while continuing to process
remaining messages in the batch.
Co-authored-by: Ethan M <ethan@nanoclaw.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
---
# Add Atomic Chat Integration
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
Tools exposed:
-`atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
-`atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
## Phase 1: Pre-flight
### Check if already applied
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
### Check prerequisites
Verify Atomic Chat is installed and its local API server is running. On the host:
```bash
curl -s http://127.0.0.1:1337/v1/models | head
```
If the request fails:
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
2. Open the app.
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
```ts
'mcp__nanoclaw__*',
'mcp__atomic_chat__*',
];
```
### Forward host env vars into the container
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
```ts
args.push('-e',`TZ=${TIMEZONE}`);
```
Add ATOMIC_CHAT forwarding right after it:
```ts
args.push('-e',`TZ=${TIMEZONE}`);
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
> Send a message like: "use atomic chat to tell me the capital of France"
>
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i atomic
```
Look for:
-`[ATOMIC] Listing models...` — list request started
-`[ATOMIC] Found N models` — models discovered
-`[ATOMIC] >>> Generating with <model>` — generation started
-`[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
## Troubleshooting
### Agent says "Atomic Chat is not installed" or tries to run a CLI
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
4. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Atomic Chat"
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
2. Confirm the Local API Server is enabled in Atomic Chat's settings
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
### `model not found` / 404 on generate
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
### Slow first response
Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
### Agent doesn't use Atomic Chat tools
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
### Context window or output size issues
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
text:`Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${errinstanceofError?err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'atomic_chat_generate',
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
),
prompt: z.string().describe('The prompt to send to the model'),
system: z
.string()
.optional()
.describe('Optional system prompt to set model behavior'),
temperature: z
.number()
.optional()
.describe('Sampling temperature (0.0–2.0). Defaults to model default.'),
max_tokens: z
.number()
.optional()
.describe('Maximum number of tokens to generate in the response.'),
},
async(args)=>{
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating',`Generating with ${args.model}`);
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
---
# Codex agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
## Install
### Pre-flight
If all of the following are already present, skip to **Configuration**:
-`import './codex.js';` line in `src/providers/index.ts`
-`import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
-`ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch the providers branch
```bash
git fetch origin providers
```
### 2. Copy the Codex source files
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line — alphabetical placement keeps diffs small.
`src/providers/index.ts`:
```typescript
import'./codex.js';
```
`container/agent-runner/src/providers/index.ts`:
```typescript
import'./codex.js';
```
### 4. Add the Codex CLI to the container Dockerfile
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
```dockerfile
ARGCODEX_VERSION=0.124.0
```
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
### Option A — ChatGPT subscription (recommended for individuals)
On the host (not inside the container), run Codex's OAuth login:
```bash
codex login
```
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
### Per group / per session
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
## Operational notes
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
cd container/agent-runner && bun test src/providers/codex.factory.test.ts &&cd -
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
-`init` event with a stable thread ID as continuation
- One or more `activity` / `progress` events during the turn
-`result` event with the model's reply
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
- Message splitting for responses over 2000 characters
- Typing indicators while the agent processes
- **type**: `discord`
- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages.
- **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.
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Local HTTP bridge — no bot token or external service needed.
---
# Add Emacs Channel
Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
## What you can do with this
- **Ask while coding** — open the chat buffer (`C-c n c` / `SPC N c`), ask about a function or error without leaving Emacs
- **Code review** — select a region and send it with `nanoclaw-org-send`; the response appears as a child heading inline in your org file
- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node
- **Draft writing** — send org prose; receive revisions or continuations in place
- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and the Lisp client in from the `channels` branch. Native HTTP bridge — no Chat SDK, no adapter package.
### Pre-flight (idempotent)
Skip to **Enable** if all of these are already in place:
Emacs is a single-user, single-chat channel. One host = one messaging group with `platform_id = "default"`.
### If this is your first agent group
Run `/init-first-agent` — pick **Emacs** as the channel, use any short handle as the "user id" (e.g. your OS username), and the skill will create the agent group, wire the channel, and write a welcome message that the agent delivers back to your Emacs buffer.
### Otherwise — wire to an existing agent group
Run the `register` step directly. The `EMACS_PLATFORM_ID` (default `default`) becomes the messaging group's platform id:
```bash
pnpm exec tsx setup/index.ts --step register -- \
--platform-id "default" --name "Emacs"\
--folder "<existing-folder>" --channel "emacs"\
--session-mode "agent-shared"\
--assistant-name "<existing-assistant-name>"
```
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
## Configure Emacs
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
AskUserQuestion: Which Emacs distribution are you using?
- **Doom Emacs** — `config.el` with `map!` keybindings
- **Spacemacs** — `dotspacemacs/user-config` in `~/.spacemacs`
- **Vanilla Emacs / other** — `init.el` with `global-set-key`
**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`):
> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`)
> 2. Type a message and press `C-c C-c` to send (RET inserts newlines)
> 3. A response should appear within a few seconds
>
> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o`
### Log line
`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup.
## Channel Info
- **type**: `emacs`
- **terminology**: Single local buffer. There are no "groups" or separate chats — one host = one chat, addressed by a `platform_id` string (default `default`).
- **how-to-find-id**: The platform id is whatever you set in `EMACS_PLATFORM_ID` (default `default`). User handles are arbitrary; your OS username or first name is fine (e.g. `emacs:<username>`).
- **supports-threads**: no
- **typical-use**: Single developer talking to the assistant from within Emacs, alongside whatever other channel they use (Slack, Telegram, Discord).
- **default-isolation**: Same agent group as the primary DM, with `session-mode = agent-shared` so a conversation started elsewhere continues in Emacs. Pick a separate folder only if you specifically want an Emacs-only persona.
### Features
- Interactive chat buffer (`nanoclaw-chat`) with markdown → org-mode rendering
- Org integration (`nanoclaw-org-send`) — sends the current subtree or region; reply lands as a child heading
- Optional bearer-token auth for the local endpoint
- Single-user: the adapter exposes exactly one messaging group per host
Error: listen EADDRINUSE: address already in use :::8766
```
Either a stale NanoClaw is running or another app has the port. Kill stale process or change port:
```bash
lsof -ti :8766 | xargs kill -9
# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port)
```
### Adapter not starting
If `grep "Emacs channel listening" logs/nanoclaw.log` returns nothing, check that `EMACS_ENABLED=true` is in `.env` and that the adapter import is present:
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
If no messaging group row exists, run the `register` command above.
### Auth token mismatch (401 Unauthorized)
```elisp
M-xdescribe-variableRETnanoclaw-auth-tokenRET
```
Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too:
```elisp
(setqnanoclaw-auth-tokennil)
```
### nanoclaw.el not loading
```bash
ls ~/src/nanoclaw/emacs/nanoclaw.el
```
If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config.
## Agent Formatting
The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax:
| Markdown | Org-mode |
|----------|----------|
| `**bold**` | `*bold*` |
| `*italic*` | `/italic/` |
| `~~text~~` | `+text+` |
| `` `code` `` | `~code~` |
| ` ```lang ` | `#+begin_src lang` |
If an agent outputs org-mode directly, markers get double-converted and render incorrectly.
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
---
# Add Google Calendar Tool (OneCLI-native)
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
**Why this package (and not gongrzhe's):**`@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
## Phase 1: Pre-flight
### Verify OneCLI has Google Calendar connected
```bash
onecli apps get --provider google-calendar
```
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
### Verify stub credentials exist
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
```bash
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
```dockerfile
ARGCALENDAR_MCP_VERSION=2.6.1
```
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
### Rebuild the container image
```bash
./container/build.sh
```
## Phase 3: Wire Per-Agent-Group
For each agent group, merge into `groups/<folder>/container.json`:
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `gchat`
- **terminology**: Google Chat has "spaces." A space can be a group conversation or a direct message with the bot.
- **how-to-find-id**: Open the space in Google Chat, look at the URL — the space ID is the segment after `/space/` (e.g. `spaces/AAAA...`). Or use the Google Chat API to list spaces.
- **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.
description: Add GitHub channel integration via Chat SDK. PR and issue comment threads as conversations.
---
# Add GitHub Channel
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
## Prerequisites
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
1. Go to **Settings** > **Webhooks** > **Add webhook**
2. Payload URL: `https://your-domain/webhook/github` (the shared webhook server, default port 3000)
3. Content type: `application/json`
4. Secret: generate a random string (e.g. `openssl rand -hex 20`)
5. Events: select **Issue comments** and **Pull request review comments**
### 3. Configure environment
Add to `.env`:
```bash
GITHUB_TOKEN=github_pat_...
GITHUB_WEBHOOK_SECRET=your-webhook-secret
GITHUB_BOT_USERNAME=your-bot-username
```
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Wiring
Ask the user: **Is this a private or public repo?**
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
Use `per-thread` session mode so each PR/issue gets its own agent session.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
## Channel Info
- **type**: `github`
- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation.
- **how-to-find-id**: The platform ID is `github:owner/repo` (e.g. `github:acme/backend`). Each PR/issue becomes its own thread automatically.
- **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.
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
---
# Add Gmail Tool (OneCLI-native)
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
## Phase 1: Pre-flight
### Verify OneCLI has Gmail connected
```bash
onecli apps get --provider gmail
```
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
### Verify stub credentials exist
```bash
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
```bash
onecli agents list
```
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
```bash
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
Edit `container/Dockerfile`. Find the pinned-version ARG block:
```dockerfile
ARGCLAUDE_CODE_VERSION=2.1.116
ARGAGENT_BROWSER_VERSION=latest
ARGVERCEL_VERSION=latest
ARGBUN_VERSION=1.3.12
```
Add a new line:
```dockerfile
ARGGMAIL_MCP_VERSION=1.1.11
```
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
**Why the `zod-to-json-schema` pin:**`@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
### Rebuild the container image
```bash
./container/build.sh
```
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
## Phase 3: Wire Per-Agent-Group
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
**Why the container path is relative:**`mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
>
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
-`command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
-`ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
-`401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
## Removal
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
4.`pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
## Credits & references
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
---
# Add Gmail Integration
This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox.
## Phase 1: Pre-flight
### Check if already applied
Read `.nanoclaw/state.yaml`. If `gmail` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
### Ask the user
Use `AskUserQuestion`:
AskUserQuestion: Should incoming emails be able to trigger the agent?
- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically
- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added.
## Phase 2: Apply Code Changes
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
```bash
npx tsx scripts/apply-skill.ts --init
```
### Path A: Tool-only (user chose "No")
Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, index.ts channel logic, routing tests, `googleapis` dependency).
#### 1. Mount Gmail credentials in container
Apply the changes described in `modify/src/container-runner.ts.intent.md` to `src/container-runner.ts`: import `os`, add a conditional read-write mount of `~/.gmail-mcp` to `/home/node/.gmail-mcp` in `buildVolumeMounts()` after the session mounts.
#### 2. Add Gmail MCP server to agent runner
Apply the changes described in `modify/container/agent-runner/src/index.ts.intent.md` to `container/agent-runner/src/index.ts`: add `gmail` MCP server (`npx -y @gongrzhe/server-gmail-autoauth-mcp`) and `'mcp__gmail__*'` to `allowedTools`.
#### 3. Record in state
Add `gmail` to `.nanoclaw/state.yaml` under `applied_skills` with `mode: tool-only`.
#### 4. Validate
```bash
npm run build
```
Build must be clean before proceeding. Skip to Phase 3.
### Path B: Channel mode (user chose "Yes")
Run the full skills engine to apply all code changes:
- Adds `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
- Adds `src/channels/gmail.test.ts` (unit tests)
- Appends `import './gmail.js'` to the channel barrel file `src/channels/index.ts`
- Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp)
- Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp)
- Installs the `googleapis` npm dependency
- Records the application in `.nanoclaw/state.yaml`
If the apply reports merge conflicts, read the intent files:
-`modify/src/channels/index.ts.intent.md` — what changed for the barrel file
-`modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
-`modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner
#### Add email handling instructions
Append the following to `groups/main/CLAUDE.md` (before the formatting section):
```markdown
## Email Notifications
When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
```
#### Validate
```bash
npm test
npm run build
```
All tests must pass (including the new gmail tests) and build must be clean before proceeding.
## Phase 3: Setup
### Check existing Gmail credentials
```bash
ls -la ~/.gmail-mcp/ 2>/dev/null ||echo"No Gmail config found"
```
If `credentials.json` already exists, skip to "Build and restart" below.
### GCP Project Setup
Tell the user:
> I need you to set up Google Cloud OAuth credentials:
>
> 1. Open https://console.cloud.google.com — create a new project or select existing
> 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable**
> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID**
> - If prompted for consent screen: choose "External", fill in app name and email, save
If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`.
### OAuth Authorization
Tell the user:
> I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps.
Run the authorization:
```bash
npx -y @gongrzhe/server-gmail-autoauth-mcp auth
```
If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`.
### Build and restart
Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server):
> Gmail is connected! Send this in your main channel:
>
> `@Andy check my recent emails` or `@Andy list my Gmail labels`
### Test channel mode (Channel mode only)
Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`.
Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
## Troubleshooting
### Gmail connection not responding
Test directly:
```bash
npx -y @gongrzhe/server-gmail-autoauth-mcp
```
### OAuth token expired
Re-authorize:
```bash
rm ~/.gmail-mcp/credentials.json
npx -y @gongrzhe/server-gmail-autoauth-mcp
```
### Container can't access Gmail
- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount
-`chat-adapter-imessage` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import'./imessage.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install chat-adapter-imessage@0.1.1
```
### 5. Build
```bash
pnpm run build
```
## Credentials
### Local Mode (macOS)
Requirements: macOS with Full Disk Access granted to the Node.js binary.
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:
```bash
open "$(dirname "$(which node)")"
```
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
Stop and wait for the user to confirm before continuing.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `imessage`
- **terminology**: iMessage has "conversations." Each conversation is with a contact identified by phone number or email address. Group chats are also supported.
- **how-to-find-id**: The platform ID is the contact's phone number (e.g. `+15551234567`) or email address. For group chats, the ID is assigned by iMessage internally.
- **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.
description: Add a persistent wiki knowledge base to a NanoClaw group. Based on Karpathy's LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki", "karpathy wiki".
---
# Add Karpathy LLM Wiki
Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern.
## Step 1: Read the pattern
Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build.
## Step 2: Choose a group
AskUserQuestion: "Which group should have the wiki?"
1.**Main group** — add to your existing main chat
2.**Dedicated group** — create a new group just for the wiki
3.**Other** — pick an existing group
If dedicated: ask which channel and chat, then register with `pnpm exec tsx setup/index.ts --step register`.
## Step 3: Design collaboratively
Discuss with the user based on the pattern:
- What's the wiki's domain or topic?
- What kinds of sources will they add? (URLs, PDFs, images, voice notes, books, transcripts)
- Do they want the full three-layer architecture or a lighter version?
- Any specific conventions they care about? (The pattern intentionally leaves this open.)
Based on this discussion, create three things:
### 3a. Directory structure
Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain.
### 3b. Container skill
Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
### 3c. Group CLAUDE.md
Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should:
- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint)
- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`)
- Point to the container skill for detailed workflow
- **Ingest discipline:** Be very explicit that when the user provides multiple files or points at a folder with many files, the agent MUST process them one at a time. For each file: read it, discuss takeaways, create/update all wiki pages (summary, entities, concepts, cross-references, index, log), and completely finish with that file before moving to the next. Never batch-read all files and then process them together — this produces shallow, generic pages instead of the deep integration the pattern requires.
## Step 4: Source handling capabilities
Based on the source types the user plans to ingest (discussed in Step 3), check whether the agent can already handle those formats — some are supported natively, others need a skill (e.g. `/add-image-vision`, `/add-pdf-reader`, `/add-voice-transcription`). If a needed capability isn't installed, check if there's an available skill for it and help the user get it set up.
### URL handling note
claude has built-in `WebFetch`, but it returns a summary, not the full document. For wiki ingestion of a URL where the full text matters, the container skill and CLAUDE.md should instruct claude to use bash commands to download full files instead. For example:
```bash
curl -sLo sources/filename.pdf "<url>"
```
If the document is a webpage, then claude can use fetch or `agent-browser` to open the page and extract full text if available. The container skill and CLAUDE.md should note this so claude gets full content for sources rather than summaries.
## Step 5: Optional lint schedule
AskUserQuestion: "Want periodic wiki health checks?"
1.**Weekly**
2.**Monthly**
3.**Skip** — lint manually
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
'cron',
'<cron-expr>',
'group',
nextRun,
'active',
new Date().toISOString()
);
db.close();
"
```
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
A pattern for building personal knowledge bases using LLMs.
This is an idea file, designed to be copied to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, etc.). Its goal is to communicate the high-level idea, with your agent building out specifics through collaboration with you.
## The Core Idea
Most interactions with LLMs and documents follow RAG patterns: upload files, retrieve relevant chunks at query time, generate answers. The knowledge is re-derived on each question with no accumulation.
The concept here differs fundamentally. Rather than just retrieving from raw documents, the LLM incrementally builds and maintains a persistent wiki — a structured, interlinked markdown collection sitting between you and raw sources. When adding new material, the LLM reads it, extracts key information, and integrates it into existing wiki pages—updating entities, revising summaries, flagging contradictions, strengthening synthesis. Knowledge compiles once and stays current rather than re-deriving on every query.
The wiki becomes a persistent, compounding artifact. Cross-references already exist. Contradictions are flagged. Synthesis reflects everything read. The wiki enriches with every source added and question asked.
You source material and ask questions; the LLM maintains everything—summarizing, cross-referencing, filing, and organizing. The LLM acts as programmer; Obsidian serves as IDE; the wiki functions as codebase.
- Reading: building companion wikis while progressing through books
- Business/teams: internal wikis fed by Slack, transcripts, documents
- Analysis: competitive research, due diligence, trip planning, hobby deep-dives
## Architecture
Three layers comprise the system:
**Raw sources** — immutable curated documents (articles, papers, images, data). The LLM reads but never modifies these.
**The wiki** — LLM-generated markdown directories containing summaries, entity pages, concept pages, comparisons, syntheses. The LLM owns this entirely, creating and updating pages while maintaining cross-references and consistency.
**The schema** — configuration document (e.g., CLAUDE.md) explaining wiki structure, conventions, and workflows for ingestion, querying, and maintenance. This key file transforms the LLM into disciplined wiki maintainer rather than generic chatbot.
## Operations
**Ingest:** Drop new sources into the raw collection; the LLM processes them. The agent reads sources, discusses takeaways, writes summaries, updates indexes, refreshes entity and concept pages, logs entries. Single sources might touch 10-15 wiki pages. Prefer ingesting individually while staying involved, though batch ingestion with less oversight is possible.
**Query:** Ask questions against the wiki. The LLM searches relevant pages, synthesizes answers with citations. Answers take various forms—markdown pages, comparison tables, slide decks, charts, canvas. Good answers can be filed back into the wiki as new pages—explorations compound in the knowledge base rather than disappearing into chat history.
**Lint:** Periodically health-check the wiki. Look for contradictions, stale claims superseded by newer sources, orphan pages lacking inbound links, important concepts lacking dedicated pages, missing cross-references, data gaps. The LLM suggests investigations and sources to pursue, keeping the wiki healthy as it grows.
## Indexing and Logging
Two special files help navigate the growing wiki:
**index.md** — content-oriented catalog of everything (each page with link, one-line summary, optional metadata like dates or source counts), organized by category. The LLM updates it on every ingest. When answering queries, read the index first to locate relevant pages before drilling deeper. This approach works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) while avoiding embedding-based RAG infrastructure needs.
**log.md** — append-only chronological record of what happened and when (ingests, queries, lint passes). Each entry beginning with consistent prefix (e.g., `## [2026-04-02] ingest | Article Title`) becomes parseable with simple tools—`grep "^## \[" log.md | tail -5` yields last 5 entries. The log shows wiki evolution timeline and helps the LLM understand recent activity.
## Optional: CLI Tools
At scale, small tools help the LLM operate more efficiently. Search engine over wiki pages is most obvious—at small scale the index suffices, but as the wiki grows, proper search becomes necessary. qmd (https://github.com/tobi/qmd) offers local search with hybrid BM25/vector search and LLM re-ranking, entirely on-device. It includes both CLI (so LLMs can shell out) and MCP server (native tool integration). Build simpler custom search scripts as needs arise.
## Tips and Tricks
- **Obsidian Web Clipper** converts web articles to markdown for quick source collection
- **Download images locally:** Set attachment folder in Obsidian Settings, bind download hotkey. All images store locally; LLM views and references directly instead of relying on potentially broken URLs
- **Obsidian's graph view** visualizes wiki connectivity—what connects to what, hub pages, orphans
- **Marp** provides markdown-based slide deck format with Obsidian plugin integration
- The wiki is simply a git-backed markdown directory—version history, branching, collaboration included
## Why This Works
Knowledge base maintenance's tedious part is bookkeeping, not reading/thinking: updating cross-references, keeping summaries current, noting data contradictions, maintaining consistency across pages. Humans abandon wikis as maintenance burden outpaces value. LLMs don't bore, don't forget updates, can touch 15 files in one pass. Wiki maintenance becomes nearly free.
Humans curate sources, direct analysis, ask good questions, think about meaning. LLMs handle everything else.
This relates in spirit to Vannevar Bush's 1945 Memex—personal curated knowledge stores with associative document trails. Bush's vision resembled this more than what the web became: private, actively curated, with connections between documents as valuable as documents themselves. Bush couldn't solve maintenance; LLMs handle that.
## Note
This document intentionally remains abstract, describing the idea rather than specific implementation. Directory structure, schema conventions, page formats, tooling—all depend on domain, preferences, and LLM choice. Everything is optional and modular. Pick what's useful; ignore what isn't. Your sources might be text-only (no image handling needed). Your wiki might stay small enough that index files suffice (no search engine required). You might want different output formats entirely. Share this with your LLM agent and work collaboratively to instantiate a version fitting your needs. This document's sole purpose is communicating the pattern; your LLM figures out the rest.
description: Add Linear channel integration via Chat SDK. Issue comment threads as conversations.
---
# 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.
## Prerequisites
**Recommended:** Create a Linear **OAuth application** so the agent posts as an app identity, not as you. This prevents the adapter from filtering your own comments as self-messages.
1. Go to [Linear Settings > API > OAuth Applications](https://linear.app/settings/api/applications/new)
2. Create an app (e.g. "NanoClaw Bot")
- Developer URL: your repo URL (e.g. `https://github.com/your-org/nanoclaw`)
- Callback URL: `http://localhost`
3. After creating, click the app and enable **Client credentials** under grant types
4. Copy the **Client ID** and **Client Secret**
**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
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import'./linear.js';
```
### 4. Patch the Chat SDK bridge for catch-all message forwarding
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
```typescript
/**
* Forward ALL messages in unsubscribed threads, not just @-mentions.
* Use for platforms where the bot identity can't be @-mentioned (e.g.
* Linear OAuth apps). The thread is auto-subscribed on first message.
*/
catchAll?: boolean;
```
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
```typescript
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
2. Label: `NanoClaw`
3. URL: `https://your-domain/webhook/linear` (the shared webhook server, default port 3000)
4. Team: select the team you want to monitor
5. Events: check **Comment**
6. Save — copy the **signing secret**
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
### 2. Configure environment
Add to `.env`:
```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_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?**
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
## Channel Info
- **type**: `linear`
- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation.
- **how-to-find-id**: The platform ID is `linear:<TEAM_KEY>` (e.g. `linear:ENG`). Find your team key in Linear under Settings > Teams. Each issue becomes its own thread automatically.
- **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.
description: Add a macOS menu bar status indicator for NanoClaw. Shows a bolt icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only.
---
# Add macOS Menu Bar Status Indicator
Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user
start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar.
**macOS only.** Requires Xcode Command Line Tools (`swiftc`).
## Phase 1: Pre-flight
### Check platform
If not on macOS, stop and tell the user:
> This skill is macOS only. The menu bar status indicator uses AppKit and requires `swiftc` (Xcode Command Line Tools).
### Check for swiftc
```bash
which swiftc
```
If not found, tell the user:
> Xcode Command Line Tools are required. Install them by running:
>
> ```bash
> xcode-select --install
> ```
>
> Then re-run `/add-macos-statusbar`.
### Check if already installed
```bash
launchctl list | grep com.nanoclaw.statusbar
```
If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 3 (Verify).
## Phase 2: Compile and Install
### Compile the Swift binary
The source lives in the skill directory. Compile it into `dist/`:
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
```
### Configure environment
Add the chosen env vars to `.env`, then sync:
```bash
mkdir -p data/env && cp .env data/env/env
```
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `matrix`
- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`).
- **how-to-find-id**: For DMs, use the bot's `openDM` to resolve the room automatically. For group rooms, in Element click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`.
- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability)
- **typical-use**: Interactive chat — rooms or direct messages. 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.
description: Route a NanoClaw agent group to a local Ollama model instead of the Anthropic API. Ollama speaks the Anthropic API natively (v1/messages), so no provider code changes are needed — just env var overrides and a model setting. Use when the user wants to run their agent locally, cut API costs, or experiment with open-weight models. See docs/ollama.md for background.
---
# Add Ollama Provider
Routes an agent group to a local Ollama instance instead of the Anthropic API.
See `docs/ollama.md` for how this works and the tradeoffs involved.
## Prerequisites
1.**Ollama is installed and running** on the host — verify: `curl -s http://localhost:11434/api/tags`
2.**A model is pulled** — e.g. `ollama pull gemma4` or `ollama pull qwen3-coder`
3.**The agent group already exists** — run `/init-first-agent` first if needed
## 1. Check source support
The feature requires two fields in `ContainerConfig` (`env` and `blockedHosts`) and their
corresponding wiring in `container-runner.ts`. Check if already present:
description: Add Ollama MCP server so the container agent can call local models and optionally manage the Ollama model library.
---
# Add Ollama Integration
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
Core tools (always available):
-`ollama_list_models` — list installed Ollama models with name, size, and family
-`ollama_generate` — send a prompt to a specified model and return the response
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
-`ollama_pull_model` — pull (download) a model from the Ollama registry
-`ollama_delete_model` — delete a locally installed model to free disk space
-`ollama_show_model` — show model details: modelfile, parameters, and architecture info
-`ollama_list_running` — list models currently loaded in memory with memory usage and processor type
## Phase 1: Pre-flight
### Check if already applied
Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
### Check prerequisites
Verify Ollama is installed and running on the host:
```bash
ollama list
```
If Ollama is not installed, direct the user to https://ollama.com/download.
If no models are installed, suggest pulling one:
> You need at least one model. I recommend:
>
> ```bash
> ollama pull gemma3:1b # Small, fast (1GB)
> ollama pull llama3.2 # Good general purpose (2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
> Would you like the agent to be able to **manage Ollama models** (pull, delete, inspect, list running)?
>
> - **Yes** — adds tools to pull new models, delete old ones, show model info, and check what's loaded in memory
> - **No** — the agent can only list installed models and generate responses (you manage models yourself on the host)
If the user wants management tools, add to `.env`:
```bash
OLLAMA_ADMIN_TOOLS=true
```
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
### Set Ollama host (optional)
By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`:
> Send a message like: "use ollama to tell me the capital of France"
>
> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response.
### Test model management (if enabled)
If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
> Send a message like: "pull the gemma3:1b model" or "which ollama models are currently loaded in memory?"
>
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
### Monitor activity (optional)
Run the watcher script for macOS notifications when Ollama is used:
```bash
./scripts/ollama-watch.sh
```
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i ollama
```
Look for:
-`[OLLAMA] >>> Generating` — generation started
-`[OLLAMA] <<< Done` — generation completed
-`[OLLAMA] Pulling model:` — pull in progress (management tools)
-`[OLLAMA] Deleted:` — model removed (management tools)
## Troubleshooting
### Agent says "Ollama is not installed"
The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
2. The per-group source wasn't updated — re-copy files (see Phase 2)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Ollama"
1. Verify Ollama is running: `ollama list`
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
3. If using a custom host, check `OLLAMA_HOST` in `.env`
### Agent doesn't use Ollama tools
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
---
# OpenCode agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
## Install
### Pre-flight
If all of the following are already present, skip to **Configuration**:
-`import './opencode.js';` line in `src/providers/index.ts`
-`import './opencode.js';` line in `container/agent-runner/src/providers/index.ts`
-`@opencode-ai/sdk` in `container/agent-runner/package.json`
-`opencode-ai@${OPENCODE_VERSION}` in the pnpm global-install block in `container/Dockerfile`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch the providers branch
```bash
git fetch origin providers
```
### 2. Copy the OpenCode source files
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts
git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts
git show origin/providers:container/agent-runner/src/providers/opencode.factory.test.ts > container/agent-runner/src/providers/opencode.factory.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line appended at the end — skip if the line is already present.
`src/providers/index.ts`:
```typescript
import'./opencode.js';
```
`container/agent-runner/src/providers/index.ts`:
```typescript
import'./opencode.js';
```
### 4. Add the agent-runner dependency
Pinned. Bump deliberately, not with `bun update`. Use `1.4.17` — must match the `opencode-ai` CLI version pinned in step 5. The 1.14.x SDK has a completely different API and is **incompatible** with the current provider code.
```bash
cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 &&cd -
```
### 5. Add `opencode-ai` to the container Dockerfile
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`:
```dockerfile
ARGOPENCODE_VERSION=1.4.17
```
> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x.
**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list:
> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild:
> ```bash
> docker builder prune -f && ./container/build.sh
> ```
### 7. Propagate to existing per-group overlays
Each agent group has a live source overlay at `data/v2-sessions/<group-id>/agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually.
```bash
for overlay in data/v2-sessions/*/agent-runner-src/providers/;do
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
-`OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
-`OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
-`OPENCODE_SMALL_MODEL` — optional second model for lighter tasks; defaults to `OPENCODE_MODEL` if unset.
-`ANTHROPIC_BASE_URL` — **required for non-`anthropic` providers.** The opencode container provider passes this as the `baseURL` for the upstream provider config so requests route through OneCLI's credential proxy or directly to the provider's API. Set it to the provider's API base URL (e.g. `https://api.deepseek.com/v1`, `https://openrouter.ai/api/v1`).
Credentials: register provider API keys in OneCLI with the matching `--host-pattern` (e.g. `api.deepseek.com`, `openrouter.ai`). OneCLI injects them via `HTTPS_PROXY` in the container — the key never lives in `.env` or the container environment.
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
#### Example: Anthropic (no ANTHROPIC_BASE_URL needed)
When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container — the proxy + placeholder key pattern is unchanged and `ANTHROPIC_BASE_URL` is not required.
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
**Host `.env` (typical Zen shape):**
```env
OPENCODE_PROVIDER=opencode
OPENCODE_MODEL=opencode/big-pickle
OPENCODE_SMALL_MODEL=opencode/big-pickle
ANTHROPIC_BASE_URL=https://opencode.ai/zen/v1
```
Use a real Zen model id from the docs; `big-pickle` is one example.
**OneCLI:** register the Zen key with **`x-api-key`**, not Bearer:
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
## Operational notes
- OpenCode keeps a local **`opencode serve`** process and SSE subscription; the provider tears down with **`stream.return`** and **SIGKILL** on the server process on **`abort()`** / shared runtime reset to avoid MCP/zombie hangs.
- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder.
- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI).
-`@resend/chat-sdk-adapter` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/resend.ts > src/channels/resend.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import'./resend.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install @resend/chat-sdk-adapter@0.1.1
```
### 5. Build
```bash
pnpm run build
```
## Credentials
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.
4. Set up a webhook:
- Go to **Webhooks** > **Add webhook**.
- URL: `https://your-domain/webhook/resend`.
- Events: select **email.received**.
- Copy the signing secret.
### Configure environment
Add to `.env`:
```bash
RESEND_API_KEY=re_...
RESEND_FROM_ADDRESS=bot@yourdomain.com
RESEND_FROM_NAME=NanoClaw
RESEND_WEBHOOK_SECRET=your-webhook-secret
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## 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)
- **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.
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
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.
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
## Registration
Two paths. The new-number path is recommended and battle-tested.
### Path A: Register a new number (recommended)
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
**Step 1: Solve the CAPTCHA**
Signal requires a CAPTCHA on first registration:
1. Open `https://signalcaptchas.org/registration/generate.html` in a browser
2. Solve the captcha
3. Right-click the **"Open Signal"** button → **Copy Link**
4. The link starts with `signalcaptcha://` — the token is everything after that prefix
**Step 2: Request SMS verification**
```bash
signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE"
```
**Step 3: Voice call fallback (if your number can't receive SMS)**
Wait ~60 seconds after the SMS request, then:
```bash
signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN"
```
Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one.
> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…`
**Step 4: Verify**
```bash
signal-cli -a +1YOURNUMBER verify CODE
```
No output = success.
**Step 5: Set profile name (optional)**
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start nanoclaw
```
### Path B: Link as secondary device
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
```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` and `src/channels/signal.test.ts` both exist
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter and tests
```bash
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import'./signal.js';
```
### 4. Build
```bash
pnpm run build
```
No npm packages to install — the adapter uses only Node.js builtins.
## Credentials
Add to `.env`:
```bash
SIGNAL_ACCOUNT=+1YOURNUMBER
```
### Optional settings
```bash
# TCP daemon host and port (default: 127.0.0.1:7583)
SIGNAL_TCP_HOST=127.0.0.1
SIGNAL_TCP_PORT=7583
# Path to the signal-cli binary (default: resolved on PATH)
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# Whether NanoClaw manages the daemon lifecycle (default: true).
# Set to false if you run signal-cli daemon externally.
SIGNAL_MANAGE_DAEMON=true
# signal-cli data directory (default: ~/.local/share/signal-cli)
SIGNAL_DATA_DIR=~/.local/share/signal-cli
```
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
```bash
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux
systemctl --user restart nanoclaw
```
## 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
sqlite3 data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
### Groups
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
sqlite3 data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
Find the UUID from `messaging_groups.platform_id` or the `users` table.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
- **supports-threads**: no
- **platform-id-format**:
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
- 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
```bash
grep "Signal" logs/nanoclaw.log | tail
```
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
### Lost connection mid-session
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish.
### 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.
### Captcha required
Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`.
### `Invalid verification method: Before requesting voice verification…`
You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token.
### Config file in use / daemon lock
signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward.
### Group replies going to DM instead of group
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
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.
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
```
This deterministically:
- Adds `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`)
- Adds `src/channels/slack.test.ts` (46 unit tests)
- Appends `import './slack.js'` to the channel barrel file `src/channels/index.ts`
- Installs the `@slack/bolt` npm dependency
- Records the application in `.nanoclaw/state.yaml`
### 3. Append the self-registration import
If the apply reports merge conflicts, read the intent file:
-`modify/src/channels/index.ts.intent.md` — what changed and invariants
Append to `src/channels/index.ts` (skip if the line is already present):
### Validate code changes
```typescript
import'./slack.js';
```
### 4. Install the adapter package (pinned)
```bash
npm test
npm run build
pnpm install @chat-adapter/slack@4.26.0
```
All tests must pass (including the new slack tests) and build must be clean before proceeding.
### 5. Build
## Phase 3: Setup
```bash
pnpm run build
```
### Create Slack App (if needed)
## Credentials
If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table.
### Create Slack App
Quick summary of what's needed:
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
2. Enable Socket Mode and generate an App-Level Token (`xapp-...`)
3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im`
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
@@ -77,139 +90,29 @@ Add to `.env`:
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_APP_TOKEN=xapp-your-app-token
SLACK_SIGNING_SECRET=your-signing-secret
```
Channels auto-enable when their credentials are present — no extra configuration needed.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
Sync to container environment:
### Webhook server
```bash
mkdir -p data/env && cp .env data/env/env
```
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.
The container reads environment from `data/env/env`, not `.env` directly.
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`.
### Build and restart
## Next Steps
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
If you're in the middle of `/setup`, return to the setup flow now.
## Phase 4: Registration
Otherwise, run `/manage-channels` to wire this channel to an agent group.
### Get Channel ID
## Channel Info
Tell the user:
> 1. Add the bot to a Slackchannel (right-click channel → **View channel details** → **Integrations** → **Add apps**)
> 2. In that channel, the channel ID is in the URL when you open it in a browser: `https://app.slack.com/client/T.../C0123456789` — the `C...` part is the channel ID
> 3. Alternatively, right-click the channel name → **Copy link** — the channel ID is the last path segment
>
> The JID format for NanoClaw is: `slack:C0123456789`
Wait for the user to provide the channel ID.
### Register the channel
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
For a main channel (responds to all messages):
```typescript
registerGroup("slack:<channel-id>",{
name:"<channel-name>",
folder:"slack_main",
trigger:`@${ASSISTANT_NAME}`,
added_at: newDate().toISOString(),
requiresTrigger: false,
isMain: true,
});
```
For additional channels (trigger-only):
```typescript
registerGroup("slack:<channel-id>",{
name:"<channel-name>",
folder:"slack_<channel-name>",
trigger:`@${ASSISTANT_NAME}`,
added_at: newDate().toISOString(),
requiresTrigger: true,
});
```
## Phase 5: Verify
### Test the connection
Tell the user:
> Send a message in your registered Slack channel:
> - For main channel: Any message works
> - For non-main: `@<assistant-name> hello` (using the configured trigger word)
>
> The bot should respond within a few seconds.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
## Troubleshooting
### Bot not responding
1. Check `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are set in `.env` AND synced to `data/env/env`
2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'slack:%'"`
3. For non-main channels: message must include trigger pattern
4. Service is running: `launchctl list | grep nanoclaw`
### Bot connected but not receiving messages
1. Verify Socket Mode is enabled in the Slack app settings
2. Verify the bot is subscribed to the correct events (`message.channels`, `message.groups`, `message.im`)
3. Verify the bot has been added to the channel
4. Check that the bot has the required OAuth scopes
### Bot not seeing messages in channels
By default, bots only see messages in channels they've been explicitly added to. Make sure to:
1. Add the bot to each channel you want it to monitor
2. Check the bot has `channels:history` and/or `groups:history` scopes
### "missing_scope" errors
If the bot logs `missing_scope` errors:
1. Go to **OAuth & Permissions** in your Slack app settings
2. Add the missing scope listed in the error message
3.**Reinstall the app** to your workspace — scope changes require reinstallation
4. Copy the new Bot Token (it changes on reinstall) and update `.env`
- **Public channels** — Bot must be added to the channel
- **Private channels** — Bot must be invited to the channel
- **Direct messages** — Users can DM the bot directly
- **Multi-channel** — Can run alongside WhatsApp or other channels (auto-enabled by credentials)
## Known Limitations
- **Threads are flattened** — Threaded replies are delivered to the agent as regular channel messages. The agent sees them but has no awareness they originated in a thread. Responses always go to the channel, not back into the thread. Users in a thread will need to check the main channel for the bot's reply. Full thread-aware routing (respond in-thread) requires pipeline-wide changes: database schema, `NewMessage` type, `Channel.sendMessage` interface, and routing logic.
- **No typing indicator** — Slack's Bot API does not expose a typing indicator endpoint. The `setTyping()` method is a no-op. Users won't see "bot is typing..." while the agent works.
- **Message splitting is naive** — Long messages are split at a fixed 4000-character boundary, which may break mid-word or mid-sentence. A smarter split (on paragraph or sentence boundaries) would improve readability.
- **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent.
- **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup.
- **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section.
- **type**: `slack`
- **terminology**: Slack has "workspaces" containing "channels." Channels can be public (#general) or private. The bot can also receive direct messages.
- **platform-id-format**: `slack:{channelId}` for channels (e.g., `slack:C0123ABC`), `slack:{dmId}` for DMs (e.g., `slack:D0ARWEBLV63`)
- **how-to-find-id**: Right-click a channel name > "View channel details" — the Channel ID is at the bottom (starts with C). For DMs, the ID starts with D. Or copy the channel link — the ID is the last segment of the URL.
- **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.
Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together.
**Sideload in Teams:**
1. Open Teams > **Apps** > **Manage your apps**
2. Click **Upload an app** > **Upload a custom app**
3. Select the zip file
Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant.
### Step 7: Receive All Messages (Optional)
By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`:
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
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.
For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration.
## 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.
## Channel Info
- **type**: `teams`
- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies.
- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring.
- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring.
- **supports-threads**: yes (channels only; DMs and group chats are flat)
- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs
- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory.
description: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot identity in the group. Requires Telegram channel to be set up first (use /add-telegram). Triggers on "agent swarm", "agent teams telegram", "telegram swarm", "bot pool".
---
# Add Agent Swarm to Telegram
This skill adds Agent Teams (Swarm) support to an existing Telegram channel. Each subagent in a team gets its own bot identity in the Telegram group, so users can visually distinguish which agent is speaking.
**Prerequisite**: Telegram must already be set up via the `/add-telegram` skill. If `src/telegram.ts` does not exist or `TELEGRAM_BOT_TOKEN` is not configured, tell the user to run `/add-telegram` first.
## How It Works
- The **main bot** receives messages and sends lead agent responses (already set up by `/add-telegram`)
- **Pool bots** are send-only — each gets a Grammy `Api` instance (no polling)
- When a subagent calls `send_message` with a `sender` parameter, the host assigns a pool bot and renames it to match the sender's role
- Messages appear in Telegram from different bot identities
1.**Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config.
2.**Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool:
```typescript
if(data.sender&&data.chatJid.startsWith('tg:')){
awaitsendPoolMessage(
data.chatJid,
data.text,
data.sender,
sourceGroup,
);
}else{
awaitdeps.sendMessage(data.chatJid,data.text);
}
```
Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs.
3.**Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add:
```typescript
if(TELEGRAM_BOT_POOL.length>0){
awaitinitBotPool(TELEGRAM_BOT_POOL);
}
```
### Step 5: Update CLAUDE.md Files
#### 5a. Add global message formatting rules
Read `groups/global/CLAUDE.md` and add a Message Formatting section:
```markdown
## Message Formatting
NEVER use markdown. Only use WhatsApp/Telegram formatting:
- *single asterisks* for bold (NEVER **double asterisks**)
- _underscores_ for italic
- • bullet points
- ```triple backticks``` for code
No ## headings. No [links](url). No **double stars**.
```
#### 5b. Update existing group CLAUDE.md headings
In any group CLAUDE.md that has a "WhatsApp Formatting" section (e.g. `groups/main/CLAUDE.md`), rename the heading to reflect multi-channel support:
```
## WhatsApp Formatting (and other messaging apps)
```
#### 5c. Add Agent Teams instructions to Telegram groups
For each Telegram group that will use agent teams, create or update its `groups/{folder}/CLAUDE.md` with these instructions. Read the existing CLAUDE.md first (or `groups/global/CLAUDE.md` as a base) and add the Agent Teams section:
```markdown
## Agent Teams
When creating a team to tackle a complex task, follow these rules:
### CRITICAL: Follow the user's prompt exactly
Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names.
### Team member instructions
Each team member MUST be instructed to:
1.*Share progress in the group* via `mcp__nanoclaw__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group.
2.*Also communicate with teammates* via `SendMessage` as normal for coordination.
3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text.
4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable.
5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**.
### Example team creation prompt
When creating a teammate, include instructions like:
\```
You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__nanoclaw__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage.
\```
### Lead agent behavior
As the lead agent who created the team:
- You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots.
- Send your own messages only to comment, share thoughts, synthesize, or direct the team.
- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `<internal>` tags.
- Focus on high-level coordination and the final synthesis.
```
### Step 6: Update Environment
Add pool tokens to `.env`:
```bash
TELEGRAM_BOT_POOL=TOKEN1,TOKEN2,TOKEN3,...
```
**Important**: Sync to all required locations:
```bash
cp .env data/env/env
```
Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.nanoclaw.plist`) in the `EnvironmentVariables` dict if using launchd.
2. Check pool initialized: `grep "Pool bot" logs/nanoclaw.log`
3. Ensure all pool bots are members of the Telegram group
4. Check Group Privacy is disabled for each pool bot
### Bot names not updating
Telegram caches bot names client-side. The 2-second delay after `setMyName` helps, but users may need to restart their Telegram client to see updated names immediately.
### Subagents not using send_message
Check the group's `CLAUDE.md` has the Agent Teams instructions. The lead agent reads this when creating teammates and must include the `send_message` + `sender` instructions in each teammate's prompt.
## Removal
To remove Agent Swarm support while keeping basic Telegram:
1. Remove `TELEGRAM_BOT_POOL` from `src/config.ts`
2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`)
3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`)
4. Remove `initBotPool` call from `main()`
5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts`
6. Remove Agent Teams section from group CLAUDE.md files
7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit
8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)
description: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only).
description: Add Telegram channel integration via Chat SDK.
---
# Add Telegram Channel
This skill adds Telegram support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
Adds Telegram bot support via the Chat SDK bridge.
## Phase 1: Pre-flight
## Install
### Check if already applied
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.
Read `.nanoclaw/state.yaml`. If `telegram` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
### Pre-flight (idempotent)
### Ask the user
Skip to **Credentials** if all of these are already in place:
Use `AskUserQuestion` to collect configuration:
-`src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist
After completing the Telegram setup, use `AskUserQuestion`:
AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the `/add-telegram-swarm` skill.
## Removal
To remove Telegram integration:
1. Delete `src/channels/telegram.ts` and `src/channels/telegram.test.ts`
2. Remove `import './telegram.js'` from `src/channels/index.ts`
3. Remove `TELEGRAM_BOT_TOKEN` from `.env`
4. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"`
5. Uninstall: `npm uninstall grammy`
6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)
- **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).
- **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.
Send a message to your bot in Telegram (search for its username), or add the bot to a group and send a message there. The bot should respond within a few seconds.
description: Add Vercel deployment capability to NanoClaw agents. Installs the Vercel CLI in agent containers and sets up OneCLI credential injection for api.vercel.com. Use when the user wants agents to deploy web applications to Vercel.
---
# Add Vercel
This skill gives NanoClaw agents the ability to deploy web applications to Vercel. It installs the Vercel CLI in agent containers and configures OneCLI to inject Vercel credentials automatically.
**Principle:** Do the work — don't tell the user to do it. Only ask for their input when it genuinely requires manual action (pasting a token).
## Phase 1: Pre-flight
### Check if already applied
Check if the container skill exists:
```bash
test -d container/skills/vercel-cli &&echo"INSTALLED"||echo"NOT_INSTALLED"
```
If `INSTALLED`, skip to Phase 3 (Configure Credentials).
### Check prerequisites
Verify OneCLI is working (required for credential injection):
```bash
onecli version 2>/dev/null &&echo"ONECLI_OK"||echo"ONECLI_MISSING"
```
If `ONECLI_MISSING`, tell the user to run `/init-onecli` first, then retry `/add-vercel`. Stop here.
## Phase 2: Install Container Skill
Copy the bundled container skill into the container skills directory:
```bash
rsync -a .claude/skills/add-vercel/container-skills/ container/skills/
```
Verify:
```bash
head -5 container/skills/vercel-cli/SKILL.md
```
## Phase 3: Configure Credentials
### Check if Vercel credential already exists
```bash
onecli secrets list 2>/dev/null | grep -i vercel
```
If a Vercel credential already exists, skip to Phase 4.
### Set up Vercel API credential
The agent needs a Vercel personal access token. Tell the user:
> I need your Vercel personal access token. Go to https://vercel.com/account/tokens and create one with these settings:
>
> - **Token name:** `nanoclaw` (or any name you'll recognize)
> - **Scope:** "Full Account" — the agent needs to create projects, deploy, and manage domains
> - **Expiration:** "No expiration" recommended (avoids credential rotation), or pick a date if your security policy requires it
>
> After creating the token, copy it — you'll only see it once.
Once the user provides the token, add it to OneCLI:
```bash
onecli secrets create \
--name "Vercel API Token"\
--type generic \
--value "<TOKEN>"\
--host-pattern "api.vercel.com"\
--header-name "Authorization"\
--value-format "Bearer {value}"
```
Verify:
```bash
onecli secrets list | grep -i vercel
```
### Assign the secret to all agents
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
```bash
# For each agent, add the Vercel secret to its assigned secrets list.
# First get current assignments, then set them with the new secret appended.
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel"| grep '"id"'| head -1 | sed 's/.*"id": "//;s/".*//')
for agent in $(onecli agents list 2>/dev/null | grep '"id"'| sed 's/.*"id": "//;s/".*//');do
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"'| grep -v hint | grep -v data | sed 's/.*"//;s/".*//'| tr '\n'','| sed 's/,$//')
If `MISSING`, add `vercel` to the global npm install line in `container/Dockerfile`, then rebuild:
```bash
./container/build.sh
```
If `PRESENT`, skip — no rebuild needed.
## Phase 5: Sync Skills to Running Agent Groups
Container skills are copied once at group creation and not auto-synced. After installing or updating a container skill, sync it to all existing agent groups:
```bash
for session_dir in data/v2-sessions/ag-*;do
if[ -d "$session_dir/.claude-shared/skills"];then
rsync -a container/skills/ "$session_dir/.claude-shared/skills/"
echo"Synced skills to: $session_dir"
fi
done
```
## Phase 6: Restart Running Containers
Stop all running agent containers so they pick up the new skills on next wake:
description: Deploy apps to Vercel. Use when asked to deploy, ship, or publish a web application, or manage Vercel projects, domains, and environment variables.
---
# Vercel CLI
You can deploy web applications to Vercel using the `vercel` CLI.
## Auth
Auth is handled by OneCLI — the HTTPS_PROXY injects the real token into API requests automatically. The Vercel CLI requires a token to be present to skip its local credential check, so **always pass `--token placeholder`** on every command. OneCLI replaces this with the real token at the proxy level.
Before any Vercel operation, verify auth:
```bash
vercel whoami --token placeholder
```
If this fails with an auth error, ask the user to add a Vercel token to OneCLI. They can create one at https://vercel.com/account/tokens and register it via `onecli secrets create` on the host. Once added, retry `vercel whoami`.
## Deploying
Always use `--yes` to skip interactive prompts and `--token placeholder` for auth (OneCLI replaces with real token).
## Pre-Send Checks (do this before sharing the URL)
Don't send the deployment URL to the user until you've confirmed it's actually working. At minimum:
1. **Local build passes** — run `npm run build` (or the project's build command) before `vercel deploy`. If the build fails locally, fix it first; don't deploy broken code.
2. **Deployment succeeded** — the `vercel deploy` output shows a "Production: https://..." URL and the status is READY (confirm with `vercel inspect`).
3. **Live URL responds** — `curl -sI <url> | head -1` should return `HTTP/2 200` (or another 2xx/3xx). A 404/500 means something's broken even though Vercel reported success.
4. **Optional visual check** — if `agent-browser` is loaded, open the URL and eyeball it. Helpful for catching broken layouts that a 200 response wouldn't reveal.
If any check fails, fix the issue and redeploy before reporting to the user.
## Project Management
```bash
# Link to an existing Vercel project (non-interactive)
description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them.
---
# Add Voice Transcription
This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: <transcript>]`.
## Phase 1: Pre-flight
### Check if already applied
Read `.nanoclaw/state.yaml`. If `voice-transcription` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
### Ask the user
Use `AskUserQuestion` to collect information:
AskUserQuestion: Do you have an OpenAI API key for Whisper transcription?
If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys.
## Phase 2: Apply Code Changes
Run the skills engine to apply this skill's code package.
Added voice message transcription support. When a WhatsApp voice note (PTT audio) arrives, it is downloaded and transcribed via OpenAI Whisper before being stored as message content.
## Key sections
### Imports (top of file)
- Added: `isVoiceMessage`, `transcribeAudioMessage` from `../transcription.js`
- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/webex.ts > src/channels/webex.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './webex.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install @bitbasti/chat-adapter-webex@0.1.0
```
### 5. Build
```bash
pnpm run build
```
## 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**
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
### Configure environment
Add to `.env`:
```bash
WEBEX_BOT_TOKEN=your-bot-token
WEBEX_WEBHOOK_SECRET=your-webhook-secret
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `webex`
- **terminology**: Webex has "spaces." A space can be a group conversation or a 1:1 direct message with the bot.
- **how-to-find-id**: Open the space in Webex, click the space name > Settings — the Space ID is listed there. Or use the Webex API (`GET /rooms`) to list spaces and their IDs.
- **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.
description: Add WeChat (personal) channel integration via Tencent's official iLink Bot API. Uses long-polling and QR scan — no webhook, no ToS risk, no paid token.
---
# Add WeChat Channel
Adds WeChat support via **iLink Bot API** — the first-party Tencent API for personal WeChat bots (different from WeCom / Official Account).
**Why this is different from wechaty/PadLocal:**
- Official Tencent API — no ToS violation, no ban risk
- Free — no PadLocal token required
- No public webhook URL needed — uses long-poll
- Works with any personal WeChat account
## Prerequisites
- A **personal WeChat account** with the mobile app installed
- A phone to scan the QR code for login
- Node.js >= 20 (already required by NanoClaw)
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in from the `channels` branch.
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
Have the sender message the bot again — the agent should respond.
## Operational notes
- **Only one instance can use a given token at a time.** Don't run multiple NanoClaw instances pointing to the same `data/wechat/auth.json`.
- **Re-login on session expiry:** if you see `WeChat: session expired` in logs, delete `data/wechat/auth.json` and restart — you'll be asked to re-scan.
- **Sync cursor persistence:**`data/wechat/sync-buf.txt` holds the long-poll cursor. Deleting it replays recent history on next start; don't delete it in normal operation.
- **Account safety:** this uses the official Tencent API, so account bans for bot automation aren't a risk. That said, don't spam — normal rate limits still apply.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service to pick up the new channel and wiring.
## Channel Info
- **type**: `wechat`
- **terminology**: WeChat has "contacts" (DMs) and "group chats" (rooms). Each DM or group is a separate messaging group.
- **how-to-find-id**: Send a message to the bot from the target account; the adapter auto-creates a messaging group and logs `WeChat inbound platformId=wechat:<id>`. Use `wechat:<user_id>` for DMs, `wechat:<group_id>` for rooms.
- **admin-user-id**: The operator's WeChat user_id (for `init-first-agent.ts --admin-user-id`) is saved to `data/wechat/auth.json` as `operatorUserId` after the QR scan. Read it with `cat data/wechat/auth.json | jq -r .operatorUserId` and prefix with `wechat:` (i.e. `wechat:<operatorUserId>`).
- **supports-threads**: no (WeChat has no reply threads)
- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed.
- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot.
- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running as part of `bash nanoclaw.sh`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/manage-channels` to wire this channel to an agent group.
## Channel Info
- **type**: `whatsapp-cloud`
- **terminology**: WhatsApp Cloud API supports 1:1 conversations only (no group chats). Each conversation is with a phone number.
- **how-to-find-id**: The platform ID is the Phone Number ID from the Meta Business dashboard (not the phone number itself). Find it under WhatsApp > API Setup.
- **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.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.