conductors/http is a small node:http service — a remote conductor host — that lets a
Tailscale-connected agent trigger sparra phases and role-runs on a Mac it doesn't have a shell on.
It's built entirely on conductors/core (the same host-agnostic runRole/runUnit the Pi conductor
uses); this doc covers the HTTP-specific surface. Setup/on-ramp: conductors/http/README.md.
make bridge-install (equivalently node bin/sparra-bridge-setup.mjs install) is the whole install:
it auto-derives the LaunchAgent plist from this checkout (node path, a PATH env var carrying node's
bin dir so the tsx re-exec resolves under launchd's bare environment, bin/sparra-bridge.mjs,
working directory, ~/Library/Logs log paths, ~/.sparra/bridge.yaml config), generates a crypto-random
Bearer token, seeds ~/.sparra/bridge.yaml once (never clobbering an existing one), writes the
plist mode 0600, and loads it via launchctl. The token is printed once on stdout as a
ready-to-paste export SPARRA_BRIDGE_TOKEN=<token> line — it lives only in the plist otherwise, never
in a log. A re-install preserves that token; make bridge-install ROTATE=1 (or install --rotate-token) rotates it. make bridge-update restarts, make bridge-remove uninstalls (keeping
bridge.yaml). Full walkthrough + bridge.yaml fields: conductors/http/README.md.
GET / serves the Sparra Bridge Console: a self-contained, responsive web dashboard (dark
instrument-console styling, light/dark theme, no external assets or new dependency) for driving +
monitoring the bridge from a browser — the /projects targets rail, a live job feed, cancel, and
/role//unit summary readouts rendered as finite holdout-safe cards (never scrollback). The feed's
lifecycle status is driven by ONE GET /events?since=<cursor> poll per 1.5s tick covering EVERY tracked
job (not a per-job sweep) — see GET /events below; the
currently-selected job's detail stage (its already-redacted streaming phase log + the full
pendingDecisions projection, neither of which /events carries) is polled separately, via
GET /jobs/:id, and ONLY while that job is running. It is served WITHOUT auth, like GET /health — a
browser's top-level navigation can't attach an Authorization header — but every data call the page's
own client script makes is still Bearer-gated; the token is entered once via an on-page modal and held
only in sessionStorage. bridge.yaml's dashboard field (default true) gates the route: false
→ 404, for operators who want zero unauthenticated HTTP surface beyond /health.
Two operating modes. A header segmented switch (conduct | full cycle) chooses the operator's
posture; the choice persists across reloads (localStorage). The default is conduct:
- A full-width Conduct Deck sits above the three columns, bound to the selected target. It carries a
target selector (chips synced with the rail selection), a large mono multi-line prompt (the hero of
the page, autofocused on entry), a subtle pipeline strip —
decompose ▸ contract ▸ generate ▸ evaluate ▸ decide— that names what one line drives, the conduct controls (brainhybrid|llm, max units, theauto/commit/merge/land/pushtoggles forming a landing-tier chain where each deeper toggle implies every shallower one (pushimplieslandimpliesmergeimpliescommit), budget, max turns), the primaryconductlaunch button (disabled with a reason until a target is selected and the prompt is non-blank), and a secondary runId +resume runaffordance. Each target keeps its own prompt draft across selection switches. - Target cards in the rail slim to identity + status (name, path, phase chip,
next ▸) and act as selectors for the deck — no per-card action buttons.
Switching to full cycle hides the deck and restores the full per-card action surface — per-target
build/reflect/resume/init/freeze triggers, unit, the role-kind select + run-role row, budget/maxTurns
steppers, and the fresh toggle. The conduct controls live only in the deck (not duplicated here).
Every action reachable in one mode is reachable in exactly one of the two. The jobs feed and detail
stage (job list, terminal, pending-decision cards, /role//unit readouts) are present in both modes.
The client logic lives in conductors/http/dashboard.client.js — a DOM-free, plain-ESM module (no
Node built-ins) that is BOTH inlined verbatim into the served page (by handlers/dashboard.ts, read
- assembled once and cached) AND imported directly by
dashboard.test.ts, so the tests exercise the exact code the browser runs.dashboard.htmlholds only the CSS/markup and a thin realviewadapter (DOM writes) + boot wiring — no request-building, schema, holdout-projection, or change-detection logic is duplicated there. The client is also the single source of truth for "what is displayed": its pure render-plan helpers (planJobFeed/applyJobFeed,planStage/applyStage) diff the previously-displayed snapshot against the next, so an identical 1.5s poll writes NOTHING to the DOM (no blink). The elapsed-time counter lives in its own node updated viatextContentonly, theriseentrance animation fires just for a first-appearing job row, and the log pane is rewritten only on a real log-content change (following the tail if scrolled to the bottom, else preserving the reader's offset).
| Method + Path | Body | Response |
|---|---|---|
GET / |
— (unauthenticated) | 200 text/html; charset=utf-8 (the dashboard) or 404 when dashboard: false |
GET /health |
— (unauthenticated) | 200 { ok: true } |
GET /projects |
— | 200 { projects: [{ root, phase, next }] } — one entry per allowlisted root, unless discoverProjects:true (see below) |
GET /jobs |
— | 200 [Job, …] — every tracked job NEWEST-FIRST; each entry is the GET /jobs/:id projection MINUS log ({ id, kind, root?, status, exitCode?, result?, createdAt, pendingDecisions? }). Jobs are in-memory since bridge boot (last-N, dropped on restart) — see below |
POST /init |
{ root, mode?, docs? } |
202 { jobId } |
POST /freeze |
{ root } |
202 { jobId } |
POST /plan |
{ root, content } |
200 { ok: true } (403 unless allowRemotePlan: true) |
POST /build |
{ root, fresh?, only?, step?, budget?, maxTurns? } |
202 { jobId } |
POST /reflect |
{ root, apply? } |
202 { jobId } |
POST /resume |
{ root } |
202 { jobId } |
POST /conduct |
fresh: { root, prompt, auto?, mode?, maxUnits?, concurrency?, budget?, maxTurns?, commit?, merge?, land?, push? } · resume: { root, resume, auto?, commit?, merge?, land?, push? } (EXACTLY ONE of prompt|resume) |
202 { jobId } |
POST /jobs/:id/decision |
{ seq, answer, note? } |
200 { ok, seq, chosen } or 404/409/400 |
POST /role |
{ root|workspace, kind, brief?, briefPath?, contractPath?, holdoutPath?, backend?, model?, effort?, worktree?, unitWorktree?, budget?, maxTurns? } |
200 ParentSummary (verbatim, holdout-redacted) |
POST /unit |
{ root|workspace, brief?, briefPath?, contractPath?, holdoutPath?, backend?, generatorModel?, evaluatorModel?, effort?, worktree?, unitWorktree?, budget?, maxTurns?, maxRounds?, contractRounds?, proceedIfNotAgreed? } |
200 UnitProjection ({ outcome, contract: { agreed, rounds }, cycle? }) |
GET /jobs/:id |
— | 200 Job ({ id, kind, root?, status, log, exitCode?, result?, createdAt, pendingDecisions? }) or 404 |
POST /jobs/:id/cancel |
— | 200 Job (now status: "canceled") or 404 |
GET /events |
— | 200 { events: [BridgeEvent, …], cursor } — cursor-delta feed across ALL jobs (see below) |
Every route but GET /health and GET / (the dashboard page load) requires
Authorization: Bearer <$SPARRA_BRIDGE_TOKEN>; a missing/wrong token is 401 before routing details
are revealed. Every strict-schema body rejects unknown
fields (400). A request body over 1 MiB is 413; malformed JSON is 400. /init, /freeze,
/build, /reflect, /resume, /conduct, /plan, /role (writer kinds only), and /unit each
acquire the per-target mutation lock (see below) — a second writer for a target already in flight gets
409 naming the holder's jobId.
POST /conduct triggers sparra conduct "<prompt>" as an async job exactly like the other phase
triggers: the argv is built server-side from the validated body only (conduct <prompt> [--auto] [--brain <mode>] [--max-units N] [--concurrency N] [--budget N] [--max-turns N] [--commit] [--merge] [--land] [--push]), the child runs with cwd = the guarded root, and it holds the per-target lock
while it runs. Numeric fields are validated to CLI-meaningful values (positive integers for
maxUnits/concurrency/maxTurns, a non-negative number for budget), and mode ∈ hybrid|llm — an
out-of-range value is a 400, not a spawn.
Landing tiers (commit/merge/land/push). The optional booleans commit/merge/land/push
are forwarded verbatim as --commit/--merge/--land/--push (the CLI owns the full
--push⇒--land⇒--merge⇒--commit implication; the bridge synthesizes none of them), so a
remote/dashboard-triggered run can self-land its accepted units onto their sparra/<name> branch
(commit), integrate onto the run branch (merge), fast-forward the target's default branch to the run
branch's tip (land), and push that landed default branch to its upstream remote (push) — instead of
being report-only. land/push additionally require conduct.landToDefault/conduct.push to be true
in the TARGET project's own sparra.config/bridge.yaml-adjacent config; the CLI enforces this with a
hard error, which surfaces through the normal job-failure path (GET /jobs/:id → failed with the
CLI's stderr) — the bridge does not read or re-implement that gate itself.
Resume (resume: "<runId>"). The SAME endpoint resumes a crashed/interrupted run: send
{ root, resume: "<runId>" } (plus any of auto/commit/merge/land/push) and the argv becomes
conduct --resume <runId> [--auto] [--commit] [--merge] [--land] [--push]. EXACTLY ONE of
prompt|resume must be present — both or neither is a 400. A resume body may carry ONLY
root, resume, commit, merge, land, push, auto; any run-shaping field
(mode/maxUnits/concurrency/budget/maxTurns) alongside resume is a fail-closed 400 (the
CLI's --resume accepts only --commit|--merge|--land|--push|--auto — and re-evaluates the
land/push config gates over the persisted run state). The runId is validated as a safe
single-segment id (isSafeRunId) BEFORE any lock or spawn — an unsafe id (.., a separator, a leading
-) is a 400 with zero side effects. A resumed run re-announces its run-START line, so
pendingDecisions on GET /jobs/:id and POST /jobs/:id/decision work identically to a fresh run. The
bridge.sh client exposes both:
bridge conduct <root> <prompt> [--commit] [--merge] [--land] [--push] [extra-json] and
bridge resume <root> <runId> [--commit] [--merge] [--land] [--push] [--auto].
A conduct run parks its important decisions (U2's decision engine) under
.sparra/conduct/<runId>/decisions/. The bridge learns the run's runId/runDir by parsing a stable
run-START line the conduct phase prints at run start, then surfaces the still-PARKED decisions on
GET /jobs/:id as a pendingDecisions array — projected to exactly
{ seq, unit, kind, question, options, default, expiresAt } (nothing else from the request file
crosses; the job stays running while parked). Answer one remotely with:
curl -s -X POST $H $J -d '{"seq":1,"answer":"finalize"}' "$SPARRA_BRIDGE_URL/jobs/$JOB_ID/decision"The answer is validated against the parked request's options (off-menu → 400), resolved
in-process via the same engine functions the CLI's conduct --decide uses (writeDecisionAnswer +
applyFileDecisionToRunState) — the bridge never shells out and never reimplements the
<seq>.decision.json write. An unknown job/run/seq is 404; a second answer for a resolved seq is
409 (the first answer stands). The audit line for a decision records only the seq + the chosen
option key + result — never the free-text note. This is holdout-safe by construction: the decision
requests/answers and job projections are all ParentSummary-derived, and no endpoint reads a run
artifact beyond the decisions dir + the run.json projection.
GET /events?since=<cursor> returns everything NEW across ALL tracked jobs since the caller's last
cursor, in ONE request — a lighter alternative to a client enumerating jobs and polling each one's
GET /jobs/:id individually. The bridge-only EventLog (conductors/http/events.ts) is an in-memory,
bounded ring (~1000 events) fed by a single shared instance: JobStore emits into it, and this route
reads from the SAME instance, so nothing observed by one is invisible to the other. It also persists to
eventsLogPath (append-only JSONL, tailable) and re-seeds the ring + its id counter from that file at
startup, so a cursor a client already holds keeps working across a bridge restart. A direct (non-bridge)
CLI run never emits here — this feed is bridge-only, like bridge-audit.log.
Response shape: { events: BridgeEvent[], cursor: number } — cursor is the highest event id emitted
so far; pass it back as since on your next poll (an omitted/non-numeric/negative since is treated as
0, returning everything currently retained). cursor never regresses, even once old events have
scrolled out of the ring past ringSize.
Three event types (type field):
job_started—{ jobId, kind, root? }, emitted whenJobStore.createJobregisters a job.job_done—{ jobId, status, root? }(status∈succeeded|failed|canceled), emitted onfinish/cancelJob.decision_parked—{ jobId, root?, runId, seq, question?, kind? }, emitted when aconductjob parks a judgment point. The conduct child prints aconduct: decision-parked <runId> <seq>line (runId + seq only, never free text); the bridge's stdout observer trusts only those two values and readsquestion/kindfrom the run's realpath-guarded<seq>.request.jsonfile (fail-closed — no recorded run dir, a foreign runId, or an unreadable/guard-rejected request emits nothing). Thequestionis the parked request's ownParentSummary-derived text, holdout-safe by construction. It flows onGET /eventslike the other two (and each(runId, seq)is emitted at most once per job).
Every field beyond the log-assigned id/ts is defensively bounded before it ever reaches the ring or
the file — jobId is char-classed + length-capped exactly like the audit log's job ids, and every other
string field is control-character-stripped and length-capped — so no unbounded or secret-smuggling
value can ride an event, holdout-safe by construction like every other bridge surface.
curl -H "Authorization: Bearer $TOKEN" "http://$HOST:$PORT/events?since=0"loadBridgeConfig (conductors/http/config.ts) validates every field of BridgeConfig:
| Field | Required | Meaning |
|---|---|---|
roots |
yes | Absolute project-root allowlist — the single source of truth for the path guard. |
port |
no (default 8787) |
TCP port to listen on. |
bind |
no | Explicit bind-address override (still refused if it resolves to a wildcard). |
lastNJobs |
no (default 50) |
Max jobs retained in the in-memory JobStore. |
auditLogPath |
no (default ~/.sparra/bridge-audit.log) |
Where the append-only request audit log is written. |
eventsLogPath |
no (default ~/.sparra/bridge-events.jsonl) |
Where the append-only lifecycle events feed (backing GET /events) is written; read back at startup to seed the in-memory ring so cursors survive a restart. |
allowRemotePlan |
no (default false) |
Whether POST /plan is permitted at all. |
dashboard |
no (default true) |
Whether GET / serves the Sparra Bridge Console; false → 404. |
discoverProjects |
no (default false) |
Opt-in recursive project discovery for GET /projects (see below). |
discoverDepth |
no (default 3) |
Max depth the discovery walk descends below a root (root itself = 0); validated to 0–8. |
By default (discoverProjects unset or false) GET /projects reports exactly one entry per
allowlisted root — that root's OWN phase, unchanged from before this feature existed. Set
discoverProjects: true to instead WALK each allowlisted root (up to discoverDepth levels, root =
depth 0) and report every directory that contains a .sparra/ as its own project entry
({root, phase, next}), which is useful when a root (e.g. ~/code) is a parent of many projects
rather than a project itself. The walk stops descending once it finds a project (a nested .sparra/
below an already-found project is not a second entry), skips common noise directories
(node_modules, .git, .hg, .svn, dist, build, .next, target, .venv, venv,
__pycache__, .cache, DerivedData), NEVER follows a symlinked directory (so it can't cycle or
escape the allowlist), and caps total results at 500 for a deterministic, sorted response.
- Tailscale-only bind.
resolveBindnever yields a wildcard address (0.0.0.0/::) — it throws instead. Default: this host's Tailscale IPv4 (probed viatailscale ip -4, trying the PATH binary then the App Store/standalone.appbundle and Homebrew CLI locations, accepting only a well-formed IPv4), or127.0.0.1when Tailscale is unavailable. Override via$SPARRA_BRIDGE_BINDorbridge.yaml'sbind(still refused if it's a wildcard). - Fail-closed auth. The bridge refuses to construct a server (
createRequestListener/createServer) with an unset/empty token, and refuses to start (startBridge) with an unset/empty$SPARRA_BRIDGE_TOKEN— there is no state in which it comes up allow-all. Bearer comparison is constant-time (checkBearer), including for a wrong token of the SAME length as the real one. - Path allowlist before any effect. Every request-supplied path field (
root/workspace,briefPath,contractPath,holdoutPath,worktree,unitWorktree, and/plan's server-computeddocsDir-joined write target) is resolved throughresolveWithinAllowlistBEFORE any spawn, read, or write. Arootand aworkspaceon the same request are each checked INDEPENDENTLY, so a caller can't smuggle an out-of-allowlist target behind an in-allowlist one. - Audit line, never the raw path. Exactly one audit line is emitted per request (accepted or
rejected), carrying the MATCHED ROUTE TEMPLATE (e.g.
/jobs/:id, or the<unmatched>sentinel on 404) and the matched allowlist ROOT ENTRY — never the raw request path or the sub-path below the matched root.
/role and /unit are the load-bearing holdout-safe endpoints — they never shell out themselves,
never parse a raw role envelope, and never open a holdout file in-process:
/roledelegates toconductors/core'srunRole, which already redacts the runner's raw envelope to aParentSummaryvia the allowlist inconductors/core/summary.ts. The handler returns thatParentSummaryverbatim — it adds no field of its own./unitdelegates toconductors/core'srunUnitand projects the result down toUnitProjection— built SOLELY from already-redacted, decision-relevant fields (outcome,contract.agreed,contract.rounds.length, and — if a build cycle ran —cycle.outcome,cycle.rounds.length, andcycle.finalVerdictwhich is itself aParentSummary). It never carries a raw round record,resultText, atraceDir, or a raw verdict dump.holdoutPathis forwarded to the role runner as a--holdout <path>ARGUMENT and is never read by the bridge process itself — the file is opened only inside the runner's own isolation boundary, same as every other conductor built onconductors/core.- No endpoint reads an arbitrary file and returns its content. There is no file-read endpoint at
all;
GET /jobs/:idreturns only the job's own streamed subprocess log (Sparra's own already-redacted phase log, not a raw file) plus — for a conduct job — thependingDecisionsprojection read solely from the run'sdecisions/dir (each fieldParentSummary-derived by construction), andGET /projectsreturns only thephasecontrol field of.sparra/state.jsonplus a static hint string — never any other state content.
An in-memory JobStore (conductors/http/jobs.ts) tracks every triggered async run:
- Bounded, insertion-ordered retention. At most
bridge.yaml'slastNJobs(default 50) jobs are kept; the OLDEST is evicted first once the cap is exceeded. There is no disk persistence — a bridge restart drops job history (in-flight subprocesses are also lost, since nothing survives the process that spawned them). - Lifecycle.
running→succeeded|failed(phase jobs: exit code 0 vs non-zero;/role//unit: no thrown error vs one) |canceled(viaPOST /jobs/:id/cancel). Asettledguard on phase jobs prevents a late subprocesscloseevent from overwriting an already-canceledstatus back tofailed. - Cancel.
cancelJobinvokes the job's registered cancel callback (if any) and marks itcanceledregardless of whether a callback existed or threw. For a spawned phase subprocess, cancel sendsSIGTERM, then escalates toSIGKILLafter a grace period if the process hasn't exited./role//unitregister a cancel callback that releases the per-target mutation lock. - Log. Phase jobs stream the child's stdout+stderr into the job's
logfield verbatim — this is Sparra's OWN already-redacted phase log, so no extra redaction is applied, but nothing else (a trace dir, a verdict file) is ever surfaced through it.
A single TargetLock (conductors/http/spawn.ts), shared across BOTH the phase routes and the
conductor routes (conductors/http/register.ts constructs one and threads it into both), admits at
most ONE in-flight mutating job per resolved target (root or workspace). This is because a project's
.sparra/state.json is not concurrency-safe across simultaneous writers. Concretely:
- A
/buildin flight on a root blocks a/unit(or another/build,/reflect,/plan, …) on that SAME root — the second request gets409naming the first'sjobId, rather than racing. - Read-only work never locks:
GET /projects, and the read-only role kinds (evaluator,reviewer,contract-evaluator) over/role. - The lock is released exactly once per job, whether it settles by completing, failing, or being canceled.
No TLS termination (the tailnet IS the transport encryption), no multi-tenant auth (one shared token), no public-internet exposure (the bind guard forbids it), and no file-read endpoint of any kind — by design, so the holdout wall has no HTTP-shaped way around it.
The bridge runs under launchd, which starts services with a bare environment (PATH =
/usr/bin:/bin:/usr/sbin:/sbin, no login shell). Two symptoms follow from that; both are handled by
the installer/resolver, but if you see them on an older plist or a hand-edited config:
env: node: No such file or directoryin~/Library/Logs/sparra-bridge.err.log, service crash-loops withlast exit code = 127. The bridge re-execstsxvia a#!/usr/bin/env nodeshebang, and launchd's barePATHomits an nvm/Homebrew node. Fix: the rendered plist sets aPATHenv var (running node's bin dir prepended to the system PATH). Re-runmake bridge-installto regenerate a plist that has it.- Health works on
127.0.0.1:8787but the tailnet IP refuses the connection —lsof -nP -iTCP:8787 -sTCP:LISTENshows it bound to127.0.0.1. Auto-bind fell back to loopback because the GUI (Mac App Store / standalone) Tailscale CLI can't reach its helper from a launchd session and so can't report the tailnet IP. Fix: setbind:to this host's Tailscale IPv4 explicitly in~/.sparra/bridge.yaml(find it with/Applications/Tailscale.app/Contents/MacOS/Tailscale ip -4), thenmake bridge-update. The open-source/Homebrewtailscaleddoesn't have this limitation.
Quick end-to-end check: curl http://<tailnet-ip>:8787/health → {"ok":true}, and
curl -H "Authorization: Bearer $SPARRA_BRIDGE_TOKEN" http://<tailnet-ip>:8787/projects lists your
allowlisted roots.