Workflow orchestration for Connectome residents, as an MCPL server. A resident submits a JavaScript script that
orchestrates subagents (agent() / parallel() / pipeline()). SwarmBase runs it in the background, fans out the
subagents, and pushes the result back to the resident as a push/event when the run ends. A dashboard on the tailnet
shows every run live.
resident (MCPL host) ──MCPL over WS ?token=──▶ swarmbase daemon (127.0.0.1:7360)
├─ script runner per run (node --experimental-permission + vm)
├─ agent() → one headless `claude -p` (or `codex exec`) process per call
├─ run store ~/.swarmbase/runs/<stamp>-<runId>/
└─ dashboard (tailscale serve → tailnet only)
Why it's a daemon and not a host module:
- Runs outlive the caller. A resident can restart mid-run; its completion notice is queued and delivered when it reconnects.
- One dashboard covers every caller.
- Subagent transcripts stay out of the resident's own memory store.
| tool | does |
|---|---|
run |
start a run: {script, args?, label?, model?, effort?, profile?, budgetUsd?, budgetTokens?, keepWork?} → runId, immediately |
status |
phases, each agent's state and cost, recent log lines |
result |
the return value, paged |
cancel |
stop a run (finished agent results stay cached) |
list |
the caller's recent runs |
resume |
re-run a script; identical agent() calls that already succeeded replay from the journal for free |
guide |
the authoring reference (src/guide.ts) |
There are two feature sets. swarmbase uses tools. swarmbase.notify uses pushEvents and carries run-completion
notices: eventId <runId>:end, origin {source: "swarmbase", kind: "run-end", runId}, tags surface:done,
swarmbase:run-end and swarmbase:<status> (completed, failed, cancelled or interrupted). The feature set
declares these tags in an RFC-001 tagOntology that suggests waking immediately on swarmbase:run-end. A host offers
that suggestion to its agent or operator and applies it only on acceptance, so a host whose wake policy skips
unrecognized push events still needs a rule placed before the skips, for example on agent-framework:
{ "name": "swarmbase-wake", "match": { "scope": ["mcpl:push-event"], "source": "swarmbase" }, "behavior": "always" }Notices for a caller that is offline wait in ~/.swarmbase/outbox.json and go out when it reconnects. Failed pushes
retry with backoff.
A script is plain JavaScript that runs as the body of an async function: top-level await and return work. Hooks:
agent(prompt, opts), parallel(thunks), pipeline(items, ...stages), phase(title), log(message), args,
budget. An optional export const meta = {name, description} labels the run. agent() resolves to text, or to an
object when opts.schema is given. It resolves to null when the agent fails or is skipped, and throws when the run
budget is spent. The full reference is what the guide tool returns.
Date.now(), argless new Date() and Math.random() throw, because they would break resume.
Each run's script executes in a child process under Node's permission model (no filesystem writes, no child
processes) inside a vm context. That context reaches the daemon only through a bridge of primitives and callbacks,
so no host-realm object is reachable from the script. A watchdog fails a run whose script busy-loops.
To add a caller:
- Generate a token:
openssl rand -hex 24 > ~/.swarmbase/mcpl-<name>.token. - Put the token's sha256 in the config as
tokenSha256. - Give the caller its own API key file, mode 0600.
- Restart the daemon.
The host connects to ws://127.0.0.1:7360/mcpl with that token.
apiBaseUrl and claudeBin are still read as aliases of engines.claude.baseUrl / .bin, and defaultModel of
defaultModels.claude.
When an engine's baseUrl points at an inference gateway, every agent request carries x-gate-origin: swarmbase,
x-gate-channel: <runId> and x-gate-counterparty: <n>:<label>, so the gateway ledger attributes spend per run and
per agent. One key file per caller serves both engines when one gateway token is valid on both legs; give the swarm
its own gateway identity rather than the resident's, so a burst never moves the resident's pins or limits.
| engine | subagent | models | budget |
|---|---|---|---|
claude (default) |
claude -p |
Claude ids/aliases | --max-budget-usd from the CLI's own cost report |
codex |
codex exec --json |
whatever the codex endpoint serves (gpt-…) |
token ceiling (opts.maxTokens) reserved from the run's token budget; USD too when prices has a row for the model |
Scripts pick with agent(prompt, { engine: 'codex' }); a caller's engines list says which it may use. The run-level
model default applies to claude agents; codex agents fall back to the caller's defaultModels.codex.
The Codex CLI reports token usage only when its (single) turn ends, so the daemon runs a loopback usage meter that
each codex subagent's requests pass through on their way to engines.codex.baseUrl. The meter holds the caller's
gateway token (the CLI never sees it — it authenticates to the meter with a per-agent bearer), adds the x-gate-*
attribution, and reads the usage out of every response as it streams past (one SSE event in memory at a time, so a
long response counts like a short one); an agent is stopped when it crosses its token ceiling or, for priced models,
its USD cap. The meter relays exactly one route, POST /responses, so a meter bearer is worth nothing on any other
path of the gateway; and killing an agent (cancel, skip, timeout, ceiling) destroys its in-flight upstream request,
so it stops costing when it stops. Without a price row a codex agent counts as $0 against the run's USD budget — add
rows under prices to shadow-price it — which is why every run also carries a token budget (budgetTokens,
caller defaults runTokensDefault / runTokensMax): a codex agent reserves its ceiling from it while it runs, the
way maxUsd is reserved from the USD budget, and agent() throws once it is spent. opts.maxTokens is clamped to
engines.codex.maxTokensMax. When the meter saw no usage for a response it could not parse, the CLI's own
turn.completed count is used instead.
Codex isolation differs from Claude Code's, and the engine compensates:
- The CLI's own sandbox (bubblewrap on Linux) confines writes to the workspace but reads the whole disk; a named
permission profile with
filesystemdeny entries blocks reads, but only as a deny-list, and the-sflag replaces it. The engine writes a throwawayCODEX_HOMEper agent whose profile denies the daemon's secrets (key and token files, the config file, every run's records and agent files, the other agents'config.tomlwith their meter bearers),.env/token/credential files under the workspace, and the usual credential dirs; selects it withdefault_permissions; and never passes-s. Deny globs are expanded when the session starts and their accepted shapes differ between CLI versions (0.158 expands a*directory component, 0.162 does not), so the run records are enumerated as exact paths at agent start; only*in a last component and**(withglob_scan_max_depth) are relied on. - By default (
engines.codex.sandbox: "bwrap") the whole CLI runs inside an outer bubblewrap allow-list:/usr(+/lib,/bin…), a few/etcfiles, the agent's cwd (read-only unless the profile iswrite) and its throwawayCODEX_HOME; nothing else exists in its namespace, and the CLI's inner sandbox nests inside. The directories ofengines.codex.binand of the daemon's ownnodeare mounted read-only too, and for an npm-installed CLI (bin/codex→lib/node_modules/@openai/codex/bin/codex.js) the wholenode_modulestree, since the wrapper loads its platform package from a sibling directory. A barebinname is resolved onPATHwhen the config loads, and the daemon refuses to start when it isn't found. Installbubblewrap, or set"sandbox": "none"to rely on the deny-list alone: an unwrapped shell reads everything the deny-list misses (other runs' work dirs, most of$HOME), so codexread/writeare then offered only to callers whosemaxProfileiswrite— the same trust the claude engine's unconfined Bash takes — and everyone else getsthink. - Tools per profile:
think= none;read= a read-only shell with no network plus the CLI's web search;write= a shell that may write inside the cwd and has network (permissions.swarm-write.network.enabled; without it the CLI's sandbox has none at all, not even DNS). The outer wrapper shares the host's network namespace (the CLI must reach the meter), so a shell with network reaches every loopback service on the host — this daemon's own dashboard included — which is whyreadhas none andwriteis for trusted callers only, like the claude engine's Bash. Sub-agents, goals, image viewing and user-input requests are off in every profile. schemais strict on this engine: the engine addsadditionalProperties: falseto every object, andagent()throws up front when a property is missing fromrequired(optional = nullable type) or an object allows extras.effort: "max"is not a Codex level; it is sent asxhigh.maxTokenscounts every request's full context, so a one-command task is ~10k tokens; keep the ceiling ≥ 50k.
Deploying the codex engine, in order: the caller's key file holds a gateway token valid on the codex leg (and on
the anthropic leg, if the caller also runs claude agents); @openai/codex installed with its platform package
(codex --version works as the daemon's user); bubblewrap installed; if engines.codex.baseUrl is signed by a
private CA, start the daemon with NODE_EXTRA_CA_CERTS=<ca.pem> — the meter's outbound TLS uses Node's trust store,
and the CLI itself only ever talks plain HTTP to the meter on loopback. A certificate failure surfaces on the agent
as meter: upstream unable to verify the first certificate with that hint.
| profile | CLI tools | confinement |
|---|---|---|
| think | none (--tools=); StructuredOutput only when a schema is given |
— |
| read | Read, Grep, Glob, WebFetch, WebSearch | Read(//<cwd>/**), which also governs Grep and Glob; reads outside are denied |
| write | + Edit, Write, Bash | Edit/Write(//<cwd>/**); Bash is not confined, so trusted callers only |
Every claude subagent also runs with:
--permission-mode=dontAsk: anything not allowed is denied, never asked.--setting-sources=(empty): no user, project or local settings, so a settings dir planted in the cwd is ignored.--settingsdeny rules for credential directories,.envand token files.--strict-mcp-configwith an empty MCP config.--no-session-persistence.- A minimal environment.
Its cwd is the run's work/ dir unless the caller's policy allows another; the path is realpath-pinned. A plain
--allowedTools=Read was verified to let Read and Grep reach files outside the cwd, which is why the rules are
path-scoped.
Each started agent reserves its cap, opts.maxUsd (default max($0.50, budget/8)), and runs with
--max-budget-usd=<cap>. Spend is settled when the agent ends and the unused reservation is returned. An agent that
can't reserve at least $0.03 waits while others hold reservations; when nothing can free up, agent() throws.
Overshoot is bounded by one model turn per running agent. Killed agents (skip, cancel, timeout) are costed from the
per-message usage they streamed.
Codex agents reserve their token ceiling (opts.maxTokens, default maxTokensDefault, clamped to maxTokensMax)
from the run's token budget (budgetTokens, default runTokensDefault, at most runTokensMax) the same way, and
settle what they used when they end. A codex agent without a price row still holds a USD reservation while it runs
(so paid agents' spend can refuse it) but settles at $0; the token budget is what bounds it.
The daemon serves an HTTP dashboard on the same port:
- A run list.
- A page per run with per-phase agent rows, live over SSE.
- Per-agent prompt and transcript drill-down.
Publish it tailnet-only with tailscale serve --bg --https=7360 http://127.0.0.1:7360; never use Funnel. Viewing is
open to the tailnet. Cancel and skip require a Tailscale-User-Login listed in dashboard.adminLogins plus an
X-SwarmBase: 1 header, which blocks cross-site POSTs. A Host allowlist blocks DNS rebinding.
- Build:
npm install && npm run build. Run:node dist/src/index.js. - Service: on macOS,
ops/swarmbase.plist.templateis a launchd template. - Disk: a run's
work/(clones, toolchains, scratch; the bulk of a run on disk) is deleted when the run ends, and the work dirs of ended runs are swept on boot, unless the run was started withkeepWork: trueor the config setsdeleteWorkOnEnd: false.resumestarts a fresh run with its ownwork/; cached agent results replay as recorded, so one that names a file under the oldwork/points at nothing (keep the run's work, or have the agent return the content). - Restart: stops every in-flight subagent. Runs are marked
interruptedon the next boot, callers are notified, andresumereplays finished agents for free. If the daemon died uncleanly, leftover agent process groups (claude and codex) are reaped on boot and interrupted runs' throwawayCODEX_HOMEs are deleted. - Tests:
npm test(the codex engine suite runs its live cases when a Codex CLI is found —CODEX_BINor the usual install paths — else skips them).node test/runner-harness.mjsexercises the runner sandbox.node test/client.mjs <caller> <tool> '<json>' [--wait]is a minimal MCPL host.
- There's no per-caller model allowlist.
- Codex agents: the outer wrapper shares the host network (the CLI needs it to reach the meter), so the
writeshell's network has no domain rules and reaches loopback services;readhas no network for that reason. Undersandbox: "none"other runs' work dirs are readable (a deny entry for them would also deny the agent's own cwd). Athinkcodex agent still gets the CLI's own system prompt, which describes a coding assistant. - An aborted claude stream is metered from what was streamed before the abort. A codex response cut off by a kill is not metered at all (the CLI's usage comes only with the terminal event); the gap is bounded by one response.
isolation: 'worktree'andworkflow()nesting aren't implemented and fail loudly.- The runner sandbox is defense in depth. Node 20's permission model doesn't restrict network.
- The dashboard trusts
Tailscale-User-Login. A local process running as the same user could forge it, but it could also read~/.swarmbasedirectly.
{ "port": 7360, "publicUrl": "https://<machine>.<tailnet>.ts.net:7360", // used in links handed to callers "engines": { "claude": { "bin": "/usr/local/bin/claude", "baseUrl": "https://<gateway>/anthropic" }, // baseUrl optional; null = the vendor API "codex": { "bin": "/usr/local/bin/codex", "baseUrl": "https://<gateway>/codex", // omit baseUrl to leave the engine off "sandbox": "bwrap", "maxTokensDefault": 3000000, "maxTokensMax": 3000000 } // a bare bin name is resolved on PATH at start }, "maxConcurrentAgents": 10, "perRunConcurrency": 8, "maxAgentsPerRun": 200, "dashboard": { "adminLogins": ["you@example.com"] }, // may cancel runs and skip agents "callers": [{ "name": "resident-a", "tokenSha256": "<sha256 of the caller's MCPL token>", // the token itself is never stored here "apiKeyFile": "~/.swarmbase/resident-a.key", // API key or gateway token, 0600 "engines": ["claude", "codex"], "maxProfile": "read", "cwdRoots": ["/srv/resident-a/files"], "defaultModels": { "claude": "claude-sonnet-5-5", "codex": "gpt-6-astra" }, "runUsdDefault": 5, "runUsdMax": 25, "maxActiveRuns": 3, "runTokensDefault": 10000000, "runTokensMax": 30000000 // per-run token budget for codex agents }], "prices": { } // optional extra rows for spend estimates }