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>
14 KiB
NanoClaw
Personal Claude assistant. See README.md for philosophy and setup. Architecture lives in docs/.
Quick Context
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
Everything is a message. There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
A src/v1/ tree exists for historical reference and is not part of the runtime — ignore it unless you're explicitly working on a migration.
Entity Model
users (id "<channel>:<handle>", kind, display_name)
user_roles (user_id, role, agent_group_id) — owner | admin (global or scoped)
agent_group_members (user_id, agent_group_id) — unprivileged access gate
user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
messaging_groups (one chat/channel on one platform; unknown_sender_policy)
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
Privilege is user-level (owner/admin), not agent-group-level. See docs/isolation-model.md for the three isolation levels (agent-shared, shared, separate agents).
Two-DB Session Split
Each session has two SQLite files under data/v2-sessions/<session_id>/:
inbound.db— host writes, container reads.messages_in, routing, destinations, pending_questions, processing_ack.outbound.db— container writes, host reads.messages_out, session_state.
Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at /workspace/.heartbeat, not a DB update. Host uses even seq numbers, container uses odd.
Central DB
data/v2.db holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at src/db/migrations/.
Key Files
| File | Purpose |
|---|---|
src/index.ts |
Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
src/router.ts |
Inbound routing: messaging group → agent group → session → inbound.db → wake |
src/delivery.ts |
Polls outbound.db, delivers via adapter, handles system actions (schedule, approvals, etc.) |
src/host-sweep.ts |
60s sweep: processing_ack sync, stale detection, due-message wake, recurrence |
src/session-manager.ts |
Resolves sessions; opens inbound.db / outbound.db; manages heartbeat path |
src/container-runner.ts |
Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI ensureAgent |
src/container-runtime.ts |
Runtime selection (Docker vs Apple containers), orphan cleanup |
src/access.ts |
pickApprover, pickApprovalDelivery, admin resolution for NANOCLAW_ADMIN_USER_IDS |
src/onecli-approvals.ts |
OneCLI credentialed-action approval bridge |
src/user-dm.ts |
Cold-DM resolution + user_dms cache |
src/group-init.ts |
Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
src/db/ |
DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
src/channels/ |
Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the channels branch |
src/providers/ |
Host-side provider container-config (claude baked in; opencode etc. installed from the providers branch) |
container/agent-runner/src/ |
Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
container/skills/ |
Container skills mounted into every agent session |
groups/<folder>/ |
Per-agent-group filesystem (CLAUDE.md, skills, per-group agent-runner-src/ overlay) |
scripts/init-first-agent.ts |
Bootstrap the first DM-wired agent (used by /init-first-agent skill) |
Channels and Providers (skill-installed)
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
channelsbranch — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via/add-<channel>skills.providersbranch — OpenCode (and any future non-default agent providers). Installed via/add-opencode.
Each /add-<name> skill is idempotent: git fetch origin <branch> → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → pnpm install <pkg>@<pinned-version> → build.
Self-Modification
One tier of agent self-modification today:
install_packages/add_mcp_server/request_rebuild— changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Admin approval, rebuild, container restart.container/agent-runner/src/mcp-tools/self-mod.ts.
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
Secrets / Credentials / OneCLI
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. src/onecli-approvals.ts, ensureAgent() in container-runner.ts. Run onecli --help.
Skills
Four types of skills. See CONTRIBUTING.md for the full taxonomy.
- Channel/provider install skills — copy the relevant module(s) in from the
channelsorprovidersbranch, wire imports, install pinned deps (e.g./add-discord,/add-slack,/add-whatsapp,/add-opencode). - Utility skills — ship code files alongside
SKILL.md(e.g./claw). - Operational skills — instruction-only workflows (
/setup,/debug,/customize,/init-first-agent,/manage-channels,/init-onecli,/update-nanoclaw). - Container skills — loaded inside agent containers at runtime (
container/skills/:welcome,self-customize,agent-browser,slack-formatting).
| Skill | When to Use |
|---|---|
/setup |
First-time install, auth, service config |
/init-first-agent |
Bootstrap the first DM-wired agent (channel pick → identity → wire → welcome DM) |
/manage-channels |
Wire channels to agent groups with isolation level decisions |
/customize |
Adding channels, integrations, behavior changes |
/debug |
Container issues, logs, troubleshooting |
/update-nanoclaw |
Bring upstream updates into a customized install |
/init-onecli |
Install OneCLI Agent Vault and migrate .env credentials |
Contributing
Before creating a PR, adding a skill, or preparing any contribution, you MUST read CONTRIBUTING.md. It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, and the pre-submission checklist.
Development
Run commands directly — don't tell the user to run them.
# Host (Node + pnpm)
pnpm run dev # Host with hot reload
pnpm run build # Compile host TypeScript (src/)
./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest)
pnpm test # Host tests (vitest)
# Agent-runner (Bun — separate package tree under container/agent-runner/)
cd container/agent-runner && bun install # After editing agent-runner deps
cd container/agent-runner && bun test # Container tests (bun:test)
Container typecheck is a separate tsconfig — if you edit container/agent-runner/src/, run pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit from root (or bun run typecheck from container/agent-runner/).
Service management:
# macOS (launchd)
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
# Linux (systemd)
systemctl --user start|stop|restart nanoclaw
Host logs: logs/nanoclaw.log (normal) and logs/nanoclaw.error.log (errors only — some delivery/approval failures only show up here).
Supply Chain Security (pnpm)
This project uses pnpm with minimumReleaseAge: 4320 (3 days) in pnpm-workspace.yaml. New package versions must exist on the npm registry for 3 days before pnpm will resolve them.
Rules — do not bypass without explicit human approval:
minimumReleaseAgeExclude: Never add entries without human sign-off. If a package must bypass the release age gate, the human must approve and the entry must pin the exact version being excluded (e.g.package@1.2.3), never a range.onlyBuiltDependencies: Never add packages to this list without human approval — build scripts execute arbitrary code during install.pnpm install --frozen-lockfileshould be used in CI, automation, and container builds. Never run barepnpm installin those contexts.
Docs Index
| Doc | Purpose |
|---|---|
| docs/architecture.md | Full architecture writeup |
| docs/api-details.md | Host API + DB schema details |
| docs/db.md | DB architecture overview: three-DB model, cross-mount rules, readers/writers map |
| docs/db-central.md | Central DB (data/v2.db) — every table + migration system |
| docs/db-session.md | Per-session inbound.db + outbound.db schemas + seq parity |
| docs/agent-runner-details.md | Agent-runner internals + MCP tool interface |
| docs/isolation-model.md | Three-level channel isolation model |
| docs/setup-wiring.md | What's wired, what's open in the setup flow |
| docs/checklist.md | Rolling status checklist across all subsystems |
| docs/architecture-diagram.md | Diagram version of the architecture |
| docs/build-and-runtime.md | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
Container Build Cache
The container buildkit caches the build context aggressively. --no-cache alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild, prune the builder then re-run ./container/build.sh.
Container Runtime (Bun)
The agent container runs on Bun; the host runs on Node (pnpm). They communicate only via session DBs — no shared modules. Details and rationale: docs/build-and-runtime.md.
Gotchas — trigger + action:
- Adding or bumping a runtime dep in
container/agent-runner/→ editpackage.json, thencd container/agent-runner && bun installand commit the updatedbun.lock. Do not runpnpm installthere — agent-runner is not a pnpm workspace. - Bumping
@anthropic-ai/claude-agent-sdk,@modelcontextprotocol/sdk, or any agent-runner runtime dep → nominimumReleaseAgepolicy applies to this tree. Check the release date on npm, pin deliberately, neverbun updateblindly. - Writing a new named-param SQL insert/update in the container → use
$namein both SQL and JS keys:.run({ $id: msg.id }).bun:sqlitedoes not auto-strip the prefix the waybetter-sqlite3does on the host. Positional?params work normally. - Adding a test in
container/agent-runner/src/→ import frombun:test, notvitest. Vitest runs on Node and can't loadbun:sqlite.vitest.config.tsexcludes this tree. - Adding a Node CLI the agent invokes at runtime (like
agent-browser,claude-code,vercel) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a newARG. Don't usebun install -g— that bypasses the pnpm supply-chain policy. - Changing the Dockerfile entrypoint or the dynamic-spawn command (
src/container-runner.tsline ~301) → keepexec bun ...so signals forward cleanly. The image has no/app/dist; don't reintroduce a tsc build step. - Changing session-DB pragmas (
container/agent-runner/src/db/connection.ts) →journal_mode=DELETEis load-bearing for cross-mount visibility. Read the comment block at the top of the file first.
CJK font support
Agent containers ship without CJK fonts by default (~200MB saved). If you notice signals the user works with Chinese/Japanese/Korean content — conversing in CJK, CJK timezone (e.g., Asia/Tokyo, Asia/Shanghai, Asia/Seoul, Asia/Taipei, Asia/Hong_Kong), system locale hint, or mentions of needing to render CJK in screenshots/PDFs/scraped pages — offer to enable it:
# Ensure .env has INSTALL_CJK_FONTS=true (overwrite or append)
grep -q '^INSTALL_CJK_FONTS=' .env && sed -i.bak 's/^INSTALL_CJK_FONTS=.*/INSTALL_CJK_FONTS=true/' .env && rm -f .env.bak || echo 'INSTALL_CJK_FONTS=true' >> .env
# Rebuild and restart so new sessions pick up the new image
./container/build.sh
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# systemctl --user restart nanoclaw # Linux
container/build.sh reads INSTALL_CJK_FONTS from .env and passes it through as a Docker build-arg. Without CJK fonts, Chromium-rendered screenshots and PDFs containing CJK text show tofu (empty rectangles) instead of characters.