- 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.
6.1 KiB
Build & Runtime
NanoClaw runs a split stack: the host is Node + pnpm, the agent container is Bun. They communicate exclusively through two SQLite files per session — there are no shared modules between them, which is what lets them use different runtimes cleanly.
Why the split
- Host stays on Node because Baileys (WhatsApp) depends on
libsignal-nodenative bindings and a long-tested WebSocket/HTTP stack. Bun's Node-API compat has improved, but this isn't where we want risk. - Container runs Bun because
bun:sqliteis built-in (no native compile ofbetter-sqlite3per image rebuild), source runs directly (no tsc build step at image build or session wake), andbun installis ~5-10× faster thannpm install.
Host and container each have their own package tree:
/ pnpm + Node 22
pnpm-lock.yaml host deps (channels, Chat SDK, Baileys, better-sqlite3, etc.)
pnpm-workspace.yaml minimumReleaseAge + onlyBuiltDependencies policy
/container/agent-runner/ Bun 1.3+
bun.lock agent-runner runtime deps (Claude Agent SDK, MCP SDK, zod, etc.)
package.json @types/bun, typescript devDeps for type-checking
The container image also has pnpm + Node inside for global CLIs (@anthropic-ai/claude-code, agent-browser, vercel). Those are Node binaries the agent invokes at runtime, not library deps. Keeping them on pnpm preserves the supply-chain policy for CLI versions.
Lockfiles
| Tree | Lockfile | Manager | Regenerate after dep change |
|---|---|---|---|
| Host | pnpm-lock.yaml |
pnpm 10 | pnpm install |
| Agent-runner | container/agent-runner/bun.lock |
Bun 1.3+ | cd container/agent-runner && bun install |
Both are committed. CI and the Dockerfile run --frozen-lockfile variants — any drift between package.json and lockfile fails the build.
Supply chain
- Host + global CLIs (pnpm):
minimumReleaseAge: 4320(3-day hold on new versions),onlyBuiltDependenciesallowlist for postinstall scripts. Seepnpm-workspace.yamlanddocs/SECURITY.md. - Agent-runner (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are
bun.lockpinning plus version-pinned CLIs/Bun itself via Dockerfile ARGs. When bumping@anthropic-ai/claude-agent-sdkor any runtime dep, review the release date on npm and bump deliberately, not viabun update.
Image build surface
container/Dockerfile is a single-stage build on node:22-slim:
- Pinned ARGs —
BUN_VERSION,CLAUDE_CODE_VERSION,AGENT_BROWSER_VERSION,VERCEL_VERSION. Bump deliberately in PRs. - CJK fonts —
ARG INSTALL_CJK_FONTS=false.container/build.shreadsINSTALL_CJK_FONTSfrom.envand passes it through. Default build saves ~200MB; opt in when the user works with Chinese/Japanese/Korean content. - BuildKit cache mounts —
/var/cache/apt,/var/lib/apt,/root/.bun/install/cache,/root/.cache/pnpm. Rebuilds wherepackage.json/bun.lockhaven't changed are fast. Requires BuildKit (default on Docker 23+, Apple Container-compat). tinias init — reaps Chromium zombies, forwards signals so in-flightoutbound.dbwrites finalize on SIGTERM.entrypoint.sh(extracted) —exec bun run /app/src/index.tsunder tini. Readable and diffable.- No compiled
/app/dist— Bun runs TS directly. The host also mounts fresh source over/app/srcat session start, so host edits take effect without rebuilding the image.
Session wake (two paths)
- Base image ENTRYPOINT — used for stdin-piped test invocations like the sample in
container/build.sh:tini --> entrypoint.shcaptures stdin to/tmp/input.json, thenexec bun run src/index.ts. - Host-spawned session —
src/container-runner.tsat line ~301 uses--entrypoint bashwith-c 'exec bun run /app/src/index.ts'. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
Both paths end with Bun running the same source file from /app/src/index.ts.
CI shape
.github/workflows/ci.yml installs both Node (with pnpm cache) and Bun, then runs in order:
pnpm install --frozen-lockfile(host)bun install --frozen-lockfileincontainer/agent-runner/(container)pnpm run format:checkpnpm exec tsc --noEmit(host typecheck)pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit(container typecheck)pnpm exec vitest run(host tests)bun testincontainer/agent-runner/(container tests)
Any failure fails the PR.
Key invariants
- Session DBs must use
journal_mode=DELETE. WAL's-shmmemory-map doesn't cross VirtioFS between host and guest. See the doc comment at the top ofcontainer/agent-runner/src/db/connection.tsandsrc/session-manager.ts. - Named SQL parameters in the container require the prefix in JS object keys.
bun:sqlitedoes not auto-strip@/$/:the waybetter-sqlite3does on the host. Use$namein both SQL and keys:.run({ $id: msg.id }). Positional?params work normally. - Agent-runner tests run under
bun:test, not vitest.vitest.config.tsexcludes thecontainer/agent-runner/tree because vitest runs on Node and can't loadbun:sqlite. - No tsc build step in the container image. Re-adding one would reintroduce the ~200-500ms per-session-wake cost we removed.
- Global container CLIs stay on pnpm, not Bun.
agent-browser,@anthropic-ai/claude-code,verceland any future Node CLIs the agent invokes should be pinned versions under the Dockerfile's pnpm global-install block.bun install -gwould bypass the pnpm supply-chain policy.
Migration history
This structure replaced a uniform npm-on-Node stack across both host and container. The pnpm migration landed first (PR #1771) to bring the host under supply-chain policy, then the container moved to Bun to eliminate native-module compilation and the per-wake tsc step. The split was chosen over going full-Bun because Baileys' native deps are the main risk surface on the host — the container has no such deps, so it benefits from Bun without taking the risk.