Daemon HTTP API¶
The shuttle daemon binds 127.0.0.1:4000 and serves its whole surface under
/api/v1. This page is the route inventory.
An operator surface, not a stability contract
These routes exist so the board and the operator tooling can talk to the daemon. The shape moves with the board — a route added, renamed or removed is paired with a bundle rebuild, not with a deprecation window. Script against it for your own machine; do not build a product on it.
daemon/lib/shuttle_web/router.ex is the authority, and it carries per-route
rationale comments this page does not repeat.
How a route is routed¶
Three patterns, and knowing which one a route follows explains most of its behaviour on a multi-host setup.
| Pattern | Meaning |
|---|---|
| owner-routed | The request carries an origin; the daemon runs it locally or forwards it to the daemon that owns the fiber. A fiber's files live on its owner's disk. An origin matching nothing degrades to local, where the fiber lookup is the arbiter. |
| host-addressed | Owner-routed, but an origin this daemon cannot place is refused rather than degraded. The subject is a host rather than a fiber — a config file, a store list, a tunnel job, a quarantine — and every host has one, so degrading would not fail: it would succeed on the wrong machine. |
| host-scoped | The route answers for this machine only, because what it reads (a transcript, an event stream, a ledger) lives on the machine that wrote it. |
| fan-in | A /composite sibling that merges this host's live read with each configured remote's cached read, reporting per-origin freshness. |
There is no shared routing plug: each controller decides for itself. The
per-controller @moduledoc says which, and is the thing to read when this table
is not enough.
Write plane¶
| Route | Routing | Purpose |
|---|---|---|
POST /dispatch |
owner-routed | Launch a worker for a fiber now, bypassing the poll |
POST /transition |
owner-routed | The unified kanban write: move a fiber to a column, one call per drag |
POST /lifecycle |
owner-routed | Invoke a named lifecycle action on a fiber — see below |
POST /kill |
owner-routed | Stop a CLI worker or interrupt and release an app conversation |
POST /claim |
owner-routed | Associate a tmux worker or a verified native app conversation with a fiber |
POST /capture |
owner-routed; meeting setup is local first | Launch a session from a free-text prompt; meeting mode starts local hark recording before routing the scribe capture |
POST /meeting/join |
local recording; delivery owner-routed | Start local hark recording and join the meeting to an existing constitution's worker |
POST /meeting/stop |
local | Stop the local hark capture or dismiss its failed tmux pane |
POST /attachments |
owner-routed | Store images pasted into the board's composer on the fiber's host and return their paths |
POST /deliver |
owner-routed | Put text in front of a fiber's worker: message a live session, else resume (or dispatch, if it never ran) with the text as From User |
POST /felt-edit |
owner-routed | Shell felt edit on the owning host — felt keeps the validation |
POST /felt-nest |
owner-routed | Shell felt nest on the owning host |
POST /fiber/create |
owner-routed | Create a fiber |
POST /felt-stores |
host-addressed | Persist a daemon's registered felt stores (whole list; takes expected_digest) |
POST /projects |
host-addressed | Register a picker project and initialize its .felt/ when needed, or set the whole list with projects: [...] (which takes expected_digest) |
POST /config/:id |
host-addressed | Replace one operator file's text, validated first by whoever owns its grammar |
POST /agents/effort |
host-addressed | {id, effort} sets one agent's default effort, effort: null resets it — shells shuttle agents effort <id> <level>\|--reset |
POST /fleet/remotes |
host-addressed | Add, replace or remove one remote — shells shuttle remotes add\|rm |
POST /tunnels |
host-addressed | install or preview a host's supervised tunnel jobs — shells shuttle tunnels install [--dry-run] |
POST /choose-folder |
host-addressed | Open the named host's native folder picker and return the chosen path. Blocks for as long as the human takes, so the forward outlasts the dialog's own five-minute bound |
POST /attach |
not owner-routed | Open a tmux session in kitty — a worker's, or a past session's resume — where the human is, ssh-ing out for a remote host |
POST /sessions/resume |
local | Start (or find) the resume-<uuid> tmux session resuming a past harness session on this host |
POST /messages |
host-addressed | Deliver a durable, idempotent text message to an exact shuttle://HOST/HARNESS/NATIVE_ID address |
POST /messages/files |
host-addressed | Deliver a message with receiver-local attachment copies to an exact session address |
POST /felt-edit also accepts a collaboration object to replace a fiber's
optional role roster. It is an exact replacement map from role slugs to arrays
of collaborator slugs, for example
{"vizier":["fable","astra"],"organizer":[]}. An empty collaborator
array keeps a role-only entry. Send this separately from body, status, or
other document edits: the owner runs one locked
shuttle assign --json-assignment write. This changes the roster without
launching a worker or changing the execution agent. The roster stores readable
names, not copied profile bodies; workers synchronize their store and read
relevant role and collaborator content locally. The request's top-level
origin still routes the task edit. See
Collaborators.
Lifecycle actions¶
POST /lifecycle takes {fiber, action, origin?} plus the action's own
fields, and runs the matching shuttle <action> on the owning host:
install, pin, repeat, reshape, pause, resume, accept,
set-model, set-agent, set-outcome or uninstall. accept and resume
run the Shuttle CLI's write verb (shuttle <verb> <fiber> --local) inside the
owning daemon's Poller, serialized with its state changes, which then refreshes
that fiber's document cache; a poll read in flight sees the old document or
the new one, whose status and handed_off_at land in one atomic write. The
outcome is always kept. A success is 200 with the Shuttle CLI's output as
text. A Shuttle refusal is 422 with shuttle exited <status>: <message>; an
unknown action or a missing field is 400.
Capture and meeting mode¶
POST /capture accepts surface: "app" for a Codex agent or "cli" for
terminal execution. It also accepts an optional
meeting: {mode: "call" | "room" | "phone"} object. Meeting mode starts hark on
the daemon that receives the request before owner-routing the capture, so the
recording starts immediately while the scribe runs beside the project. Meeting mode rejects surface: "app",
allows prompt to be omitted, and replaces the prompt with the meeting's facts
(mode and transcript path, plus a pointer to the shuttle skill's
references/meeting.md) followed by the user's note.
The capture scribe infers the meeting's identity, participants, and filing location from the transcript and felt tree; it doesn't require a title before recording.
It replaces meeting with
meeting_launch, the recording's launch id, before forwarding, so the owner
handles an ordinary terminal capture whose supplied Claim body carries
meeting: <launch id>. The transcript is mirrored to the
remote project host when origin names a configured remote with an SSH alias.
A successful terminal capture answers with session_uuid when the launch
assigned the harness session. A successful meeting capture adds meeting; if capture fails after recording
starts, its status and error body also include meeting and recording: true.
Each meeting gets a transcript name no earlier recording used. The daemon watches
the new recording for a few seconds: if hark exits before it starts recording, the
scribe is not launched and the response is 503 with the failed meeting row
and recording: false. A recording that is still loading, or whose state tmux
can't report yet, counts as started (state: "starting").
Codex app conversations¶
Existing-fiber dispatch reads the persisted
shuttle.surface; omission preserves CLI execution. Model and effort remain
agent-registry choices. App execution requires the owning host's local Codex
App Server and reports an error if it cannot be reached.
An unavailable managed server returns HTTP 503 with
reason: "app_server_unavailable" and guidance to choose a configured remote
host or CLI execution. Shuttle does not start an independent app runtime or
silently change the selected surface.
Successful app capture, dispatch, and claim responses carry surface: "app",
project_id, thread_id, session_uuid, transcript_session_uuid, and
tmux_session: null. For app workers, session_uuid is the resumable
conversation identity (the same value as thread_id);
transcript_session_uuid is the native transcript identity, which can differ
for a fork. Session ledgers keep the transcript identity in session and carry
thread_id as the Codex App address identity. CLI responses carry
surface: "cli" and their real tmux_session. A claim can attach an existing
native Codex conversation only after the connected App Server read-verifies its
exact id and active or idle state. The claim records ownership without starting,
resuming, naming, or interrupting a turn. An app conversation waiting
for the next phone reply remains assigned; its idle state does not authorize
another launch.
App responses and runtime rows can also carry desktop_link, the validated
codex://threads/<thread_id> desktop route. This is separate from a verified
phone session_link. The board uses native working, waiting, and attention
states without treating an idle conversation as released ownership.
A created conversation whose first turn could not be confirmed returns HTTP
502 with reason: "app_launch_failed", its conversation id, and a recovery
message. Ownership remains reserved; inspect and resume that conversation
instead of creating a duplicate. Confirmed missing conversations are marked
blocked and can be explicitly stopped or replaced. A temporary connection
failure remains unknown.
To claim an app conversation, post fiber_id, surface: "app", and
session_uuid to /claim. A Shuttle-created capture claims its durable record
directly; an existing native conversation is adopted only after exact
read-verification by this host's App Server. In either case it must not belong
to another fiber. CLI claims use tmux_session. The app dispatch prompt
supplies the claim information and the appropriate completion instructions.
A claim carrying meeting stamps it on the fiber as shuttle.runtime.meeting.
/attach is a terminal operation. An app conversation's UUID
is not a terminal name or a verified mobile URL. Phone conversation access
uses the host's Codex project listing until a direct app URL is available.
/attach takes {tmux_session, shuttle_host?} for a live worker, or
{session, shuttle_host?} for a past harness session (the card's History). For
the second, the host that ran the session starts a tmux session named
resume-<uuid> running the harness's own resume — the command the dispatcher
resumes workers with, for the agent that host's ledger recorded, in the working
directory the transcript records — or finds the one already running; a remote
host is asked through /sessions/resume. The tab then attaches to it like any
worker's. The name does not end in -shuttle, so no dispatch, adoption or
orphan path treats it as a worker. 409 when the session is that host's running
worker (attach to its tmux instead); 422 when that host has no transcript or no
recorded working directory to resume in. The other way round, a dispatcher
resume of a session whose resume-<uuid> is open is refused
(session_open_in_resume) and the fiber shows as blocked, rather than two
harness processes sharing one transcript.
Read plane¶
| Route | Routing | Purpose |
|---|---|---|
GET /fibers |
local | Every fiber this daemon's stores expose |
GET /fibers/composite |
fan-in | The cross-host board feed, with reconciled per-host liveness |
GET /fibers/*id |
owner-routed | One fiber by canonical id, body fetched from its owner |
GET /search |
local | Search constitution bodies in this daemon's configured stores |
GET /agents |
host-addressed | The effective agent registry (shells shuttle agents --json) — a per-host fact, since the built-in layer travels with that host's shuttle binary |
GET /felt-stores |
fleet-aggregating | The registered store list, this host's live and each remote's off the cached owner feed (stores block) |
GET /config |
host-addressed | Every operator file on a host: path, whether it exists, size, mtime, and any environment variable overriding it |
GET /config/:id |
host-addressed | One operator file's text and digest — stores, projects, agents, remotes or host — plus entries for the two path lists |
GET /fleet |
host-addressed | A host's fleet as rows: the normalized fleet file and its discovered tailnet peers (each row's source says which), joined to live reachability and each remote's build |
GET /file |
owner-routed | Raw bytes by absolute path, with ETag / Last-Modified conditional GET for live HTML, markdown, and text readers |
GET /file-info |
owner-routed | File existence, mtime, and size without downloading bytes — metadata for browser-native artifact refreshes |
GET /transcript |
host-routed | Availability receipt for a native session transcript, including its authoritative path and digest |
GET /transcript/raw |
host-routed | Exact native JSONL bytes for a session — no parsing or normalization |
GET /sessions/links |
host-routed | For a batch of sessions: transcript present, harness, and a bridged Claude session's claude.ai URL |
GET /peers |
fleet fan-in | Discover addressable live sessions; ?local=true serves only this daemon's owner-local sessions |
GET /meeting |
host-addressed | Report the selected host's hark availability, supported meeting modes and meeting state |
GET /meeting/audio |
local | WebSocket: relay a phone's microphone into the live phone meeting's hark socket |
GET /meeting?origin=<host> returns {available, modes, meeting} from the selected daemon.
modes lists the recording inputs that host supports: call (local microphone and system audio), room (local microphone), or phone (streamed phone microphone).
Without hark, the list is empty.
Capture reads the serving daemon's modes, narrows them to phone on mobile, and hides the mode selector when only one mode remains.
A live meeting on the serving daemon prevents starting another there.
The board's host picker chooses the scribe host, not the recorder: open the desired recording host's board to record there.
When the scribe runs elsewhere, Capture names both hosts beside the Meeting toggle.
Each origin in GET /felt-stores also reports browser_capable.
It is true only for a macOS daemon whose user has an active GUI session.
Capture and Stash offer --chrome only when both the selected host and selected agent support it.
GET /peers returns {host, sessions, gaps}. Fleet discovery queries each
configured daemon once with local=true; an offline, old, timed-out, or
malformed peer becomes an explicit {host, error} gap while successful peers
remain usable. A session row includes fiber and fiber_uid when its host
knows the pairing. A Codex App row also includes transcript_id when the
addressed thread and native transcript have different ids. Returned addresses
use the configured routing alias and canonical harness name:
shuttle://HOST/HARNESS/NATIVE_ID, where HARNESS is
claude, codex, or pi.
POST /messages accepts {address, text, from, wake, message_id} with a full
shuttle:// address. The parser accepts known ledger or registry spellings,
such as claude-code, and normalizes the routed address and receipt to the
canonical harness name. The CLI also resolves unique native session IDs and
fiber paths, slugs, or UIDs before calling this endpoint. Omitting wake
requests an active task turn; set wake: false explicitly for context-only
delivery. A failed wake remains a failure in the receipt and is
never silently downgraded to context-only delivery. The address selects the
owner strictly; an unknown host is refused rather than attempted locally. Its
receipt reports the furthest observed submission stage (accepted,
context_added, submitted, queued, unknown, or rejected), transport, and detail;
it does not claim that the recipient acted on the message. For Claude-native
delivery, queued means the receiver queues it behind its current turn,
submitted means the transcript shows it admitted as a user turn, and accepted
means a correlated real assistant reply appears. Hook-mailbox queued is
context-only and starts no turn. With wake: true, only Claude-native
queued/submitted receipts are valid; context_added is never a wake result.
Native admission alone never means a model reply.
A processed delivery returns HTTP 200 for any receipt status, including
rejected and unknown. HTTP 400 corresponds to invalid_address,
wrong_host, invalid_request, unsupported_harness, and preflight_failed
(which releases the message ID), even when Felt supplies a rejection receipt.
A message_id_conflict returns 409. Other rejected receipts—including
session_unavailable, session_not_found, wake_required, and wake_refused—
return 200. HTTP 5xx means Felt did not provide a valid receipt or the owner
could not be reached; the daemon may include a synthetic unknown receipt.
Reusing a message_id with the same request returns its durable receipt. A
concurrent identical request waits until the owner's recorded observation
deadline plus 1.5 seconds, capped by the request deadline. If it remains in
progress or its owner stops before saving a result, felt returns unknown
without resending. A later retry of a completed Claude-native queued,
submitted, or unknown receipt rechecks the transcript for up to two seconds
from its saved offset and upgrades the receipt if it finds later evidence; it
never sends to the receiver again. Receipts without a saved offset remain
unchanged. Reusing the ID for changed content is rejected by the local felt
adapter.
POST /messages/files accepts the same envelope plus one to eight
attachments, each {name, data, sha256}. name is a portable basename,
data is base64, and sha256 is lowercase hexadecimal. Decoded attachments
may total at most 20 MiB. Successful receipts include
files: [{name, path, sha256, size}], where each path names the receiver-local
copy. File-bearing envelopes are refused on /messages; this dedicated route
prevents an older daemon from silently dropping fields it does not recognize.
GET /meeting returns {available, modes, meeting} for the serving daemon, or the host named by origin.
Hark exposes no capability-reporting CLI protocol, and its help includes device flags even on Linux.
The daemon therefore derives modes from hark availability and the OS: macOS offers call/room/phone; other hosts offer phone.
The row is null when the addressed daemon has no meeting capture to report. Otherwise it carries
state, title, started_at, tail, transcript, mirror_host,
fiber, joined, phone, launch, tmux_session, and error. phone is
true when the meeting takes its audio from a phone (mode: "phone"), so a page
can offer to connect one; launch is the recording's launch id, which a phone's
audio socket binds to. tail is the
transcript's last spoken lines (at most 30, oldest first, # lines left out),
read from the end of the file on each request, so the route stays cheap to
poll. fiber names the fiber whose card the meeting rides on. For a joined
meeting (joined: true) it is the constitution. For a capture meeting it is
null until the scribe claims, then the fiber whose shuttle.runtime.meeting
equals the recording's launch id, looked up on each request across this
daemon's fibers and the remote feeds it polls. The scribe's host never has to
reach this daemon, and a renamed fiber is still found. mirror_host is the configured remote name when
the transcript mirror's SSH alias matches a remote; otherwise it is the alias.
A null mirror host means the transcript is local.
POST /capture accepts meeting: {mode: "call" | "room" | "phone"}. call
records this machine's microphone and system audio, room its microphone
alone, and phone a phone's microphone streamed in over GET /meeting/audio
(hark --phone, an in-person meeting diarized like room). The daemon derives
the meeting title and transcript name from the first line of prompt, or uses
Meeting when no note is supplied. It starts hark in the local hark-meeting
tmux session, then continues the normal local or forwarded capture flow.
For a remote origin, hark writes locally and mirrors to
~/.hark/meetings/<name>.txt through that remote's configured SSH alias.
The capture agent receives the scribe message and the optional user note as its
prompt. project_dir remains required by the ordinary capture flow.
An unavailable hark executable returns 503. An existing meeting in
starting, loading, live, or stopping returns 409 with its row.
Meeting mode rejects surface: "app", an invalid mode, or a mode unsupported by the recorder with 422.
If capture fails after hark starts, the capture's status and error body include
the meeting row and recording: true; the local recording continues.
POST /meeting/join accepts {fiber_id, origin?, meeting: {mode}, note?}. It
starts hark exactly as meeting capture does, mirrored toward origin, and names
the recording after the note's first line or, with no note, the fiber's leaf.
The meeting message (the same facts as a meeting capture's, plus a line saying
the meeting joins this constitution) and the note go to the fiber's worker
through /deliver on its owner. The answer is {meeting, delivery}, where
delivery is /deliver's body; if delivery fails after hark starts, it carries
the delivery's status with recording: true and error, and the recording
continues. Start errors are the same as meeting capture's.
POST /dispatch accepts {fiber_id, force?, ad_hoc?, resume_mode?,
user_message?, project_dir?} (plus origin to forward). A forced start
(force or ad_hoc) never runs a worker in the felt store. Every check runs
before a session is cut or a document written: the owning daemon needs a
project_dir that is a directory on its host. It then arms the fiber in one
write — re-arming a closed or parked role, or reopening a closed fiber with
shuttle reopen — cuts any open session for a fresh start, and spawns. A start
that cannot arm its fiber answers 422 {dispatched: false, reason:
"arm_refused", fiber_id, host, message, needs?}: message is the refusal (the
Shuttle CLI's own words when the CLI refused), host is the owning daemon,
where any command the message names has to run, and needs: "project_dir"
says the directory is the problem: the block has none, or the one declared or
confirmed is not a directory there. Resend with project_dir, a directory a
human confirmed: the daemon resolves it with shuttle resolve-dir, then saves
it and arms the fiber in the same write (shuttle reopen --project-dir), and
the worker starts in the directory the CLI saved.
POST /deliver accepts {fiber_id, text, from?} (plus origin to forward). A
fiber with a live worker receives text through session messaging at
shuttle://<host>/<harness>/<session_uuid>, woken, and the answer is
{delivered, delivery: "message", receipt}. Otherwise the owner force-dispatches
it with text as the From User: delivery: "resume" continues its previous
conversation, delivery: "dispatch" starts one when it has none. These answer
with /dispatch's body and statuses plus delivered and delivery.
POST /attachments accepts {fiber, attachments: [{name, mime, data,
sha256}]} (plus origin to forward), with data base64 and sha256 the hex
digest of the decoded bytes. The owning daemon resolves fiber in its own
stores (404 when it does not own it, 504 when felt timed out) and writes
each image to <data dir>/attachments/<fiber uid>/<first 16 hex of
sha256>.<ext> (0600, in a 0700 directory; the data dir is $SHUTTLE_DATA_DIR,
default ~/.shuttle). The name is content-addressed, so re-sending an image
returns the same path. The answer is {files: [{name, path, sha256, size}]} in
request order, path absolute on the owning host. The batch is all or nothing,
and 400 {error} names the first broken rule: mime is one of
image/png, image/jpeg, image/gif, image/webp and the bytes carry its
signature; each image is at most 10 MB; at most 8 per request and 25 MB in
total; data decodes; sha256 matches. The route's JSON body ceiling is sized
to that total, each socket read may take up to 120 s, and a forward allows
120 s. Images are kept 30 days: every successful store removes files under
attachments/ whose mtime is older than that, and fiber directories left
empty, without following links. Re-sending an image refreshes its mtime. The board's composer uploads here
before it sends a directive and appends one [Image: <path>] line per image.
POST /meeting/stop returns HTTP 202 with {meeting} or 404 when no meeting
exists. It sends at most one SIGINT to a live hark process, even while hark's
lifecycle file still reports loading or live. A stopping meeting is a
no-op; a starting or failed meeting dismisses its tmux session.
GET /meeting/audio?launch=<id> upgrades to a WebSocket that feeds the live
phone meeting whose launch id is id (the row's launch). The page sends
binary frames of raw s16le 16 kHz mono PCM, which the daemon writes unchanged
into hark's Unix socket, the path meeting.json names as phone. Nothing is
buffered: hark places phone audio on its own wall clock and pads gaps with
silence, so audio replayed late would be counted twice and shift every later
timestamp. Until hark's socket accepts a connection (it binds once the models
load), frames are discarded and the daemon retries every half second. hark
takes one sender; a new connection replaces the old one. The daemon answers in
small JSON text frames: {"state":"waiting","reason"} while there is no socket
yet (audio is dropped), {"state":"connected"} once audio reaches hark, and
before closing, {"state":"refused","reason"} (close code 4404: no meeting, a
different meeting than launch, or one recording this machine's microphone),
{"state":"ended","reason"} (4410: the meeting stopped, failed, or gave way to
another), or {"state":"replaced","reason"} (4409: hark took another sender).
The frame carries the full reason; the close frame's reason is cut, on a
character boundary, to the 123 bytes a close frame holds. Without launch the
socket feeds whichever phone meeting is live. Every meeting state upgrades, so
the page can read why; a request that is not a WebSocket upgrade gets 426.
Browsers open WebSockets cross-site without CORS, so the upgrade takes the same
origin rule as a write: no Origin, an allowlisted dev origin, or the page's
own; any other origin gets 403. An idle socket closes after a minute.
Meeting writes and audio control the serving daemon's recording and never owner-route it.
POST /capture and POST /meeting/join start that recording before routing the agent's half to the project's or the fiber's owner.
GET /meeting?origin=<host> can inspect another host's capabilities and recording without transferring recording ownership.
/file sits outside the JSON pipeline on purpose: it returns arbitrary content
types, so a strict Accept: application/pdf would otherwise 406 before the
controller ran. A 200 response carries a weak content-digest ETag
(W/"sha256-<hex>") and Last-Modified; only a matching If-None-Match
returns a bodyless 304, because a whole-second timestamp can't see a
same-second rewrite. Owner-routed reads forward these validators and relay the
owner's cache headers, including a remote 304. The board sends If-None-Match
only with a digest ETag; an owner that offers none is read in full and the
board compares a content fingerprint, leaving unchanged views untouched.
/transcript accepts session=<uuid> and an optional host=<name>. Its JSON
receipt carries availability (available_local, available_remote,
transcript_missing, host_unreachable, or the fleet-level
identity_pending state), host, harness, source_path, byte_count, and
sha256. /transcript/raw serves the authoritative JSONL bytes unchanged and
adds X-Transcript-Byte-Count and X-Transcript-SHA256 headers. Agents should
use ordinary jq/rg recipes on that file; Shuttle deliberately does not
define a transcript reader or search language.
/sessions/links accepts sessions=<uuid>,<uuid>,… (at most 50) and an
optional host=<name>, and answers {host, links} with one entry per session
in request order: session, availability (available_local,
transcript_missing or host_unreachable), harness and url — a Claude
Code transcript's last remote_session_change bridge URL, and only when that
is a https://claude.ai/ address. A remote's answer is re-checked by the
daemon that relays it: entries for sessions not asked about are dropped, and a
URL of any other shape is nulled. The board's card History asks for the
sessions it lists, one request per host. Each answer is cached against the
transcript's {mtime, size}, so an ended session is read once, and a session
with no transcript on the host is remembered as missing for a minute. It is a
sibling of /transcript because that receipt hashes the whole file.
Temporal read plane¶
The feeds behind Chronicle and the Board canvas. The four host-scoped feeds
each have a /composite fan-in sibling; /sent-files is owner-routed instead
and has none. See Telemetry and the ledgers for what
writes the files underneath.
| Route | Reads | Serves |
|---|---|---|
GET /activity |
events.jsonl |
Per-minute activity buckets (agent and reply overlap — see Telemetry) |
GET /sessions |
sessions.jsonl |
Which fiber each harness session belonged to; Codex App rows may carry a separate thread_id; uid= narrows to one fiber |
GET /commits |
commits.jsonl |
Which session made each commit, with --shortstat counts |
GET /sent-files/all |
events.jsonl |
Every SendUserFile push on this host |
GET /sent-files |
events.jsonl |
One fiber's sent-files trail, capped at 50 |
/activity takes from_ms and to_ms and answers
{host, from_ms, to_ms, buckets}. It rounds the window inward to whole minutes
(buckets are minute-stamped, so the answer is the same), and its weak ETag is
that window plus the {mtime, size} of events.jsonl and its rotated sibling
— a repeat request over an unchanged file is a 304. As with the sent-files
routes below, that validator almost never matches on a busy host and is not
what keeps the route cheap: Shuttle.EventStream holds the histogram in
memory, folded once from both files and then only over appended bytes, and a
request reads its window out of that.
/sent-files is owner-routed like /file — one fiber's trail is read on the
host that owns the fiber; its LOCAL leg carries a weak ETag and honors
If-None-Match with a 304 (the forwarded remote leg does not, because
OriginRouter.forward_get/4 carries no headers either way).
/sent-files/all is the host-scoped feed with the composite.
/sent-files takes uid (required; 400 without it) and origin. Its ETag
covers the events.jsonl pair and the session ledger. The trail is whatever
events.jsonl.1 and events.jsonl hold, so a send survives one rotation and is
gone after the second.
Neither route is defended by the 304, and neither needs to be: events.jsonl
is the live hook stream for every session on the host and moves every few
seconds, so a conditional request on a busy host essentially never hits. What
makes both routes cheap is Shuttle.EventStream, which holds the parsed
sent-file events in memory and reads only the bytes appended since its last
poll. A request costs one stat; the files are read once, at boot.
The composite siblings are:
| Route | Reads | Serves |
|---|---|---|
GET /activity/composite |
local feed + remote caches | Cross-host activity buckets with per-origin freshness |
GET /sessions/composite |
local ledger + remote caches | Cross-host fiber/session pairings; uid= narrows to one fiber |
GET /commits/composite |
local ledger + remote caches | Cross-host commit narration and shortstat counts |
GET /sent-files/all/composite |
local feed + remote caches | Cross-host SendUserFile pushes |
The board reads only these composites; the single-host routes are what a hub
asks each peer for. A composite asks each remote for that feed when it is
requested, not on a timer: a remote already asked within the last minute is served from the
hub's cache, and one that does not answer within five seconds is served from
its last good copy (kept on disk under remote-temporal/<feed>/). origins
marks a remote stale once its last success for that feed is more than ten
minutes old. Each composite carries a
weak ETag over its local inputs and every remote's cached copy, and answers
304 when none of them has moved.
The operator files¶
GET/POST /api/v1/config/:id is a text plane over the five JSON files a
daemon reads from ~/.config/shuttle/ — stores, projects, agents,
remotes, host. It deliberately does not parse a file into a structure and
re-encode it: that round trip drops every key the structure does not know
about, and remotes.json carries several (auth, ssh_flags,
tunnel.label, per-entry timeouts) that no CLI flag can even express.
A write is refused unless the tool that really reads the file accepts it first. The candidate goes to a temporary file, the owning reader is pointed at it through its own path-override environment variable, and only a clean exit commits:
| File | Validator |
|---|---|
remotes |
shuttle remotes list --configured --json under SHUTTLE_REMOTES_FILE |
agents |
shuttle agents --json under SHUTTLE_AGENTS_FILE |
host |
shuttle host --json under SHUTTLE_HOST_CONFIG_FILE |
stores, projects |
shape-checked in the daemon — no felt verb validates them |
A refusal is a 400 carrying that tool's own sentence verbatim. Empty text
removes the file, which is the same vocabulary the structured writers already
speak (saving an empty list deletes stores.json; dropping the last remote
deletes remotes.json).
A read serves a digest — a hash of the bytes as sent, and of those bytes
rather than of a second read of the file. Send it back as expected_digest and
the write is refused with a 409 carrying conflict: true if the file has
moved since, which matters because this board is reachable from two hubs and a
phone at once and an editor left open while a CLI writes the same file would
otherwise save its stale text back over the new one. The two whole-list
endpoints (/felt-stores, /projects) take the same key and answer the same
409. Omit it entirely for last-write-wins, which is what a script wants. (A
hash rather than an mtime: POSIX mtime is second-granular, so a write landing
in the same second as the read is invisible to it, and that is exactly the
interleaving a fast tool produces.)
409 is its own status because a client branches on it: a conflict is the one refusal with a recovery move attached — show me what it says now — and an affordance keyed to a status survives a rewording of the sentence.
A 503 is not a refusal of the bytes. It means the host could not RUN the
check — felt or shuttle is missing from a supervised daemon's PATH, or a
CLI timed out — so nothing is known about what was sent and nothing the author
retypes will help.
A 400 says "fix this"; a 503 says "ask again".
Reads are owner-routed as well as writes, which is unusual here and is the
point: a config file describes the daemon that reads it, and only that daemon
can see its own ~/.config/shuttle/. A host whose daemon predates these routes
answers 404, and the board renders that as "deploy it to configure it from
here" rather than as a missing file.
Operator routes¶
| Route | Purpose |
|---|---|
GET /version |
Daemon build stamp and liveness probe, including ready and boot duration; deploy verifiers watch git_short_sha AND booted_at; also carries host (the frozen host id this daemon dispatches under, by which other daemons' discovery names it), listen, host_class, peer-gate mode/uid/source, tailnet_dial (with socket_source: configured, default, system or none, and default_socket_refused when an untrusted default socket was passed over), and discovery (this daemon's tailnet peer discovery: enabled, state of pending/ok/unavailable/disabled, via of cli/localapi, error, last_run_at, the peers found with name, url, dns_name and last_seen_at, and the nodes rejected with a reason) |
GET /state |
Full local state: running workers, blocked and pending_launch rows, standing roles, boot quarantine, contract check and poll_health |
GET /state/composite |
The same plus per-origin remote snapshots |
POST /quarantine/release |
Release the boot quarantine (host-addressed; shuttle daemon release) |
POST /remotes/:name/reset |
Reset a remote's tripped circuit breaker, forcing a cascade now rather than waiting out the trip cooldown — one reset buys exactly one cascade, and it 409s when the breaker is not tripped |
The endpoint binds before synchronous store resolution, orphan adoption,
event-stream seeding, and Tailnet bridge reconciliation finish. Until every
application child has started, /version returns HTTP 200 with ready: false
and a boot duration. Routes that depend on initialized state return HTTP 503
with {"error":"booting","ready":false,...} and no Retry-After header.
The liveness-safe exceptions are the board shell GET /, static assets (served
before the gate), GET/HEAD /version, GET /peers, GET /sessions, and
POST /messages plus /messages/files. Session discovery and direct message
delivery do not call into the initializing Poller. Remote message delivery can
still return its explicit no_bridge result before this host's Tailnet bridge
has initialized. shuttle daemon status reports a bound listener as alive while
booting, and the launcher polls every five seconds until it is ready or stops
answering.
A TailnetDial bridge is ready when its private listener is bound and no
dial/relay failure is recorded. Upstream reachability is observed on actual
requests, not by a synthetic probe. A recorded request error clears when a
later dial succeeds; a relay error from that request can set the status back
to error.
A TCP peer refused by the uid gate receives HTTP 403 before static assets are served or a request body is parsed. Exposed hosts refuse TCP listeners at boot; this response also describes the gate's defense-in-depth behavior if an exposed TCP request reaches the plug:
{"error":"peer_refused","reason":"uid 2000 is not the daemon's uid 1000"}
When /proc cannot resolve the peer, reason is
"peer uid unresolved: no matching /proc TCP row". The peer_gate field on
GET /version reports whether this admission check is active for the daemon's
bound class and listener.
curl -s http://127.0.0.1:4000/api/v1/version | jq
curl -s http://127.0.0.1:4000/api/v1/agents | jq
GET / outside /api/v1 serves the board's index.html, or a 404 with a build hint when ui/dist has never been built.
GET /phone redirects to /.
Capture starts a phone meeting with an optional prompt; the scribe infers the meeting and where to file it from the transcript.
On a mobile viewport, Capture defaults to Meeting with phone audio and no mode selector, and remembers the scribe host and project from the last meeting start.
Desktop Capture defaults to an ordinary idea; enabling Meeting uses the recorder's first supported mode and shows a selector only when several modes are available.
The Start tap opens the microphone, then binds its audio session to the returned meeting's launch id at GET /api/v1/meeting/audio.
The live card offers Connect mic when this tab isn't streaming, a level meter, Restore mic after an interruption, and Stop.
Keep the screen on and the browser in front; on iOS, screen lock stops the microphone.
The tab requests a screen wake lock to prevent auto-lock where supported.
A background-interruption warning retains the time the tab went hidden even when timers were suspended; Restore mic may ask for permission again.
Audio is sent only while hark listens, and the card says when it doesn't: "Loading models — speech isn't captured until Listening" while hark loads, "Reconnecting — audio lost since HH:MM:SS" after a drop.
Nothing is queued for later, and an open socket more than about 2 s behind drops audio rather than queueing it.
Browsers grant the microphone only in a secure context, so reach the board from a phone through an HTTPS front such as tailscale serve.