Skip to content

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.