Core-only (apiOnly) API Control
Embed botmux as a controlled HTTP service inside your own system: one process, a fixed port, bound to
127.0.0.1, with no Feishu credentials / bots.json / pm2 / dashboard. Your orchestrator (task runner, CI, backend, sandbox) drives it over HTTP to run a CLI (codex / claude-code / …), poll results, and get a writable web terminal on demand.
This doc is for integrators embedding botmux into their own product (e.g. an in-sandbox botmux running codex). If you just want an external system to trigger a normal bot already running in a Feishu group, see Triggering Tasks via API — that covers the trigger/poll/cancel four-state contract. The two are complementary: the same trigger/poll contract applies under core-only; this doc only covers the core-only-specific startup, auth, route boundary, writable terminal, and security model.
1. core-only vs the normal fleet
In one line: core-only is a headless, single-tenant, loopback-only botmux. It keeps the full daemon IPC contract but drops the Feishu side.
2. Startup
Two equivalent ways to start:
Optional env vars / flags (--flag wins over the env var):
Visible TUI vs structured return:
--cli codex-appuses the app-server runner — structured return, no visible terminal;--cli codex(orclaude-code) runs a visible TUI in a tmux pane you can watch/operate via the web terminal (see §6).
Readiness contract
The daemon binds the port first, then completes durable restore. So "port accepts a connection" ≠ "ready to trigger" — wait for readiness:
- stdout ready line (locked contract, regex
^\[core-only\] listening on):This line is printed only after restore completes. GET /healthz(public, no auth):- not ready →
503 {"ok":false,"status":"starting"} - ready →
200 {"ok":true}
- not ready →
The readiness barrier also gates the public control routes: while restore is incomplete, /api/trigger, /api/sessions/:id/trigger-result, and /api/sessions/:id/insight also return 503 {status:'starting'} — so even a client that skips the /healthz probe cannot trigger into a racing restore. Recommended: poll /healthz until 200 before the first trigger.
3. Route boundary: three auth layers
core-only's IPC routes are not a "public vs everything-HMAC" split — there are three layers. Integrators only need the first; understanding the other two helps you reason about the threat surface correctly.
Layer 1 · no-credential integrator surface (core-only-specific, no HMAC) — this is all you need to integrate botmux:
(/__health is also permanently public in every mode, but it is a legacy liveness probe that always returns 200 — it is not equivalent to /healthz: /healthz returns 503 {status:'starting'} until restore/attach/scheduler finish, i.e. it is the core-only readiness barrier. To decide "may I start triggering", you must use /healthz; using /__health would read as ready-when-not and trigger too early into a racing restore.)
The three control routes' no-auth is a tight, core-only-specific allowlist — deliberately narrow: an earlier "auth off for all routes" would let a co-resident model turn read/perturb sessions, the scheduler, and mutations. /api/asks/answer is deliberately excluded (askId-keyed with no session binding — exposing it would let a co-resident turn hijack another pending ask).
Layer 2 · internal capability / signature routes (bypass the outer trusted-host HMAC, but each self-authenticates in its handler) — these are not public, yet they do not require the §4 trusted-host HMAC either. They are verified by a per-session rotating per-turn capability (bound to the sessionId in the URL) or by an independent strong-signature protocol inside the handler. Examples: POST /api/session-ready, POST /api/asks, POST /api/sessions/:id/{slash,cd,close,chat-rename}, POST /api/hooks/emit, POST /api/attention, POST /api/vc-meetings/action-request, the workflow-v3 mutation prefix. The legitimate caller is the in-session CLI itself (which, under sandbox/read-isolation, cannot read the host secret); the capability only proves "I am this session's current-turn CLI" and cannot select another session. Integrators normally don't call this layer.
Layer 3 · host / operator routes (require the §4 route+port-bound HMAC) — everything else, including the writable terminal GET /api/sessions/:id/write-link and GET /api/sessions/:id (metadata). The next section shows how to sign correctly.
Note on
POST /api/sessions/:id/close: it can be called by an external host caller with the §4 HMAC, and also has a Layer-2 per-session capability channel (the in-session CLI closing itself) — so it isn't part of a blanket "everything else is HMAC-only".
4. HMAC signing (the easy trap: bind)
Signed routes use a loopback HMAC. The request carries three headers:
The secret is the raw 43-char base64url string in ~/.botmux/.dashboard-secret — use it directly as the HMAC key; do NOT base64-decode it to bytes.
⚠️ Key point: msg must include the bind
For Layer-3 host/operator routes (the third layer in §3, e.g. write-link), core-only runs a bind-carrying check at the server layer before the route handler. (Layer-1 public routes verify no signature; Layer-2 internal routes bypass this check and are verified by their handler's own capability / independent signature — neither uses the bind HMAC described here.) The bind ties the signature to "method + path + port", so a captured signature can't be replayed to a different route/port.
Three things people get wrong:
portis the port the service actually bound (e.g.8930), not the value in the Host header — the verifier rebuilds the bind with the port it bound.pathnameexcludes the query string (useURL().pathname).secretis used as a raw string key; don't decode it.
Symptom: if you sign bare
ts:nonce(missing the bind), you get HTTP 401{reason:"sig_mismatch"}. Because public routes (trigger-result etc.) verify no signature at all, the bind gap often surfaces for the first time on write-link — easy to misdiagnose as a secret/time-window issue.
Reference implementation
src/core/daemon-ipc-auth.ts daemonIpcAuthHeaders() in the repo is the authoritative implementation; mirror its logic:
5. Trigger & poll
The trigger/poll/cancel four-state contract is identical to a normal bot — see Triggering Tasks via API. The core-only-specific differences:
- An HTTP response mode is mandatory: an apiOnly bot's trigger must set
options.waitForFinalOutput(sync) oroptions.asyncReturnSessionId(async), else400 bad_request(apiOnly has no Feishu group to reply into, so the result must come back over HTTP). - An async trigger returns a synthetic
http_async_*chatId plus a realsessionId; polltrigger-resultwith thatsessionId. - The user instruction goes in the top-level
instructionfield (rendered as a trusted<botmux_task>) — notprompt. options.model/options.reasoningEffortapply to codex-family and grok fresh triggers.- Poll
trigger-resultwith the full sessionId (UUID), not the[worker:xxxxxxxx]short tag from logs — the short tag ≠ sessionId and yieldssession_not_found.
Completion mechanism:
trigger-resultflips tocompletedwhen botmux extracts final_output from the CLI transcript. core-only claude-code once had a bug where, after the first-turn ready-gate timed out and fell back, the persisted user line was head-truncated → the completion signal never bound → permanentrunning. Fixed in v3.9.0 (suffix-anchored content proof to bind the durable mark). Use v3.9.0 or later.
6. Writable web terminal
core-only (tmux backend + a visible CLI) can expose an operable web terminal. Get the link via GET /api/sessions/:id/write-link (requires HMAC signing — see §4):
- The returned URL carries a write token; opening it gives an editable xterm (
readonly=false). - Read-only variant: the read link (
readableTerminalUrlFor/ the card link) carries aviewToken(view, no input). - Backend
zmxhas no web terminal (409 terminal_unsupported); tmux/pty support it.
core-only also ships readOnlyUrl / viewToken in trigger-result
In core-only, whenever the session has a live worker terminal (workerPort bound + a view capability minted), the public GET /api/sessions/:id/trigger-result response also carries readOnlyUrl + viewToken (a read-only entry, so riff's in-sandbox runner can open the visible TUI directly). This is core-only-specific: a normal/mixed fleet's trigger-result does not emit these fields (there trigger-result is HMAC-gated, and we must not push a terminal read-capability into a poll response); closed / restored sessions with no live worker don't emit them either, so no stale URL is ever advertised. The write token is only ever obtained via the §4 HMAC write-link — it never appears in trigger-result.
⚠️ Prefer "fetch on open" over caching the URL
The token itself is stable: production view/write tokens are a domain-separated HMAC of the host dashboard secret + sessionId (deriveTerminalViewToken / deriveTerminalWriteToken, each with a distinct domain prefix), recomputed by refreshTerminal*Token() at worker init — so the same session derives the same token across worker / daemon restart (randomBytes is only the standalone/test fallback when the secret is unavailable). A cached token does not break just because the worker cycled.
Still, fetch write-link fresh on each "open terminal" — not because the token changes, but because the rest of the URL does: proxy port / advertised host / deployment topology, plus whether the worker is currently available (the terminal page must connect to a live worker). Fetching fresh is cheap (loopback + HMAC) and yields a URL that matches the current topology and a worker that is actually online.
- ✅ Recommended: persist only the stable sessionId, fetch the URL on "open".
- ⚠️ If you must cache: the token part is reusable, but port/host/worker-availability changes will make a stale URL fail to connect — fetching fresh is simpler.
A single core-only session is not idle-reaped (live-worker cap defaults to 30, with no idle timeout; one session is always ≤ cap), so normally the worker stays resident and the terminal remains reachable.
7. Security boundary (no-transport)
core-only is "no Feishu outbound + single-tenant loopback", with a ring of deliberate hardening around that. Integrators should know:
- No Feishu Client: an apiOnly bot never constructs a
Lark.Client;larkAppSecretis withheld from workers (not injected into the child env). - Config authority: ignores
~/.botmux/bots.jsonandBOTS_CONFIG, and the entrypoint deletesprocess.env.BOTS_CONFIG— so a forked worker can'tcat $BOTS_CONFIGto read a real fleet's sibling credentials. - State isolation: the entrypoint freezes
SESSION_DATA_DIRto a dedicated~/.botmux/core-only/<botId>/data— a managed turn carrying the host'sSESSION_DATA_DIRcan't point core-only at the real fleet's sessions/pid/descriptor. - Loopback freeze:
BOTMUX_WORKER_HTTP_HOSTandWEB_EXTERNAL_HOSTare both frozen to127.0.0.1(bind and advertised host agree), so the worker web server is never exposed on all interfaces. - Skips host maintenance: core-only does not run fleet-level auto-restart /
botmux restart/ shared-HOME breadcrumb writes; it never touches the global botmux install on the same machine. - Auth is still the hard gate: loopback is connectivity, not identity — same-machine processes (incl. bwrap sandboxes, which normally share the network namespace) can also dial
127.0.0.1. So beyond §3's Layer-1 three control routes +/healthz//__health, every route is authenticated: Layer 2 by a per-session capability / independent strong signature verified in the handler, and Layer-3 host/operator routes by the §4 route+port-bound HMAC. No layer is "bare loopback is enough".
Appendix: core-only endpoint cheat-sheet
- Three-layer auth model: see §3; integrators only use layer 1.
- Trigger request shape,
errorCodes, four-state semantics, restart-survival guarantee: see Triggering Tasks via API. - Reference signing implementation:
src/core/daemon-ipc-auth.ts(daemonIpcAuthHeaders).
