Bridge API reference
A bridge serves an Elysia app on the port given by fleet bridge --port
(default 4800). There is no route prefix: paths are absolute from the origin.
Every route below requires an Authorization: Bearer <token> header, except
POST /auth/login, GET /auth/mode and POST /auth/logout. A missing or
unknown credential is a 401; a credential of the wrong kind — a ship token on a
workspace route, say — is a 403. WebSocket routes take a single-use
?ticket=<t> from POST /auth/ws-ticket instead of a header. A bridge started
with --insecure-no-auth skips all of it and treats every request as an admin.
See authentication.
The workspace surface is a superset of the ship API:
the owning ship is abstracted away — routing is automatic — but kept visible,
since every workspace response carries a ship field. On top of that the bridge
adds ship management, a repo registry, and an aggregate system-resources view.
Relationship to the ship API
Section titled “Relationship to the ship API”| Ship route | On the bridge |
|---|---|
GET /workspaces |
Same path. Merged across ships, deduped, each row gains ship. |
GET /workspaces/:repo/:name |
Same path. Proxied live to the owning ship; response gains ship on both the active and inactive variants. |
GET /workspaces/:repo/:name/diff |
Same path and query. Proxied verbatim. |
POST /workspaces |
Same path, different body: {ship, repoName, name, branch | issueNumber, ephemeral?} instead of {url, repoName, name, branch}. The clone URL comes from the bridge’s repo registry, and the branch may be named outright or derived from an issue. Response gains ship and ephemeral. |
POST /workspaces/:repo/:name/branch |
Same. |
POST /workspaces/:repo/:name/activate |
Same. |
POST /workspaces/:repo/:name/deactivate |
Same. |
DELETE /workspaces/:repo/:name |
Same, minus the ship’s force query — the bridge always deletes unconditionally here. Its own ephemeral cleanup uses force=false against the ship. |
WS /workspaces/:repo/:name/terminal |
Same path; a bidirectional pipe to the owning ship’s terminal. |
WS /events |
Same path, different frames: no top-level ship, and every workspace carries ship. |
GET /system-resources |
Same path, different shape: an array with one entry per ship. The single-host snapshot moves to GET /ships/:ship/system-resources. |
POST /workspaces/:repo/:name/agent/init |
Not present. |
GET/POST /workspaces/:repo/:name/agent/status |
Not present. Agent status still reaches the bridge through each ship’s /events stream, as the agent field on every workspace. |
| POST /armory/sync | Not present. The bridge is the pusher, not a target. |
| GET /armory | Same path, different shape: the bridge serves the armory manifest it owns; a ship serves the sync state it has applied. |
Bridge-only routes: GET/POST /ships, DELETE /ships/:name,
GET /ships/:ship/system-resources, GET/POST /repos,
DELETE /repos/:name, GET /repos/:name/branches, GET /armory/file,
GET /armory/ships.
Routes at a glance
Section titled “Routes at a glance”| Method | Path | Success | Body |
|---|---|---|---|
| GET | /ships |
200 | ShipInfo[] |
| POST | /ships |
201 | ShipInfo (admin only) |
| DELETE | /ships/:name |
200 | { ok: true } (admin only) |
| GET | /ships/:ship/system-resources |
200 | SystemResources |
| GET | /system-resources |
200 | ShipSystemResources[] |
| GET | /repos |
200 | Repo[] |
| POST | /repos |
201 | Repo |
| DELETE | /repos/:name |
200 | { ok: true } |
| GET | /repos/:name/branches |
200 | RepoBranch[] |
| GET | /armory |
200 | ArmoryManifest |
| GET | /armory/file |
200 | ArmoryFile |
| GET | /armory/ships |
200 | ShipArmoryState[] |
| GET | /workspaces |
200 | BridgeWorkspaceSummary[] |
| GET | /workspaces/:repo/:name |
200 | BridgeWorkspaceStatus |
| GET | /workspaces/:repo/:name/diff |
200 | raw diff text |
| POST | /workspaces |
201 | BridgeWorkspaceSummary |
| POST | /workspaces/:repo/:name/branch |
200 | { ok: true } |
| POST | /workspaces/:repo/:name/activate |
200 | { ok: true } |
| POST | /workspaces/:repo/:name/deactivate |
200 | { ok: true } |
| DELETE | /workspaces/:repo/:name |
200 | { ok: true } |
| WS | /workspaces/:repo/:name/terminal |
— | webterm protocol, proxied |
| WS | /events |
— | BridgeWorkspaceEvent stream |
Error shape and status codes
Section titled “Error shape and status codes”Identical in shape to the ship’s:
{ error: string }The status comes from the thrown BridgeError; anything else is a 500.
| Status | Raised when |
|---|---|
400 |
Invalid repo/workspace/ship identifier; unknown ship: <name> (create, or per-ship resources); unknown repo: <name>; invalid repo; a create naming both a branch and an issueNumber, neither, a blank branch, or an issueNumber that is not a positive integer. |
403 |
A credential of the wrong kind for the route, or a member on an admin-only route (this endpoint requires an admin). |
404 |
workspace not found: <repo>/<name> — no ship in the ownership index holds it; ship not found: <name>; repo not found: <name>. |
409 |
ship already registered: <name>; a registering ship holds workspaces already hosted elsewhere; workspace already exists: <repo>/<name>; a create already in progress or of indeterminate outcome for that key; a ship removed mid-request. |
422 |
Elysia schema validation on the request body. |
502 |
ship at <url> did not respond: <message> (POST /ships); a ship returned no data, an invalid summary/status, or a workspace identity that was not requested; GET /repos/:name/branches could not reach the remote. |
503 |
ship "<name>" hosting <repo>/<name> is offline; ship "<name>" is offline (create, per-ship resources); ship "<name>" unreachable: <message>. |
| ship’s status | Any error the owning ship returned is passed through with the ship’s own status and message. |
Offline ships and unknown workspaces
Section titled “Offline ships and unknown workspaces”These two cases are the ones worth memorizing:
- Unknown workspace →
404 {"error": "workspace not found: <repo>/<name>"}. The bridge routes from an in-memory index built from every online ship’s/eventsstream. A workspace it has never seen — or one whose only owner has been deregistered — is simply not in that index. - Offline ship →
503. Every routed operation (GET,diff,branch,activate,deactivate,DELETE, terminal target) requires the owning ship to beonline; otherwiseship "<name>" hosting <repo>/<name> is offline. A ship that fails at the transport layer mid-call is flipped offline and the call becomesship "<name>" unreachable: <message>.
GET /system-resources is the one exception: offline ships are reported inline
with resources: null rather than failing the aggregate.
GET /workspaces also degrades rather than failing: ships that are offline or
that error are skipped, and the response lists whatever the bridge last knew.
Ship management
Section titled “Ship management”GET /ships
Section titled “GET /ships”No query parameters.
{ name: string; url: string; status: "online" | "offline" }[]status is online exactly while the bridge holds an open /events socket to
that ship. Reading the roster is open to any user; the two routes below are not.
POST /ships
Section titled “POST /ships”Admin only. A member gets 403 {"error": "this endpoint requires an admin"}
— the call makes the bridge dial a URL the caller chose.
{ url: string } // requestThe bridge opens a probe connection, waits up to 5000 ms for the ship’s first
sync event, and learns the ship’s name from it — the caller never supplies a
name. On success it adopts the connection, claims that ship’s workspaces, and
persists the roster to ships.json. Returns 201 with the ShipInfo.
| Status | Cause |
|---|---|
403 |
The caller is a member, not an admin. |
422 |
url missing. |
502 |
ship at <url> did not respond: <message> — no sync within the timeout, or an invalid ship identity. |
409 |
ship already registered: <name>, or ship "<name>" has workspaces already hosted elsewhere: <keys>. |
DELETE /ships/:name
Section titled “DELETE /ships/:name”Admin only, for the same reason: it takes a host out of the fleet.
Closes the connection, releases every workspace it owned, drops any pending
create reservations for it, and re-persists the roster. Responds { ok: true }.
| Status | Cause |
|---|---|
400 |
Invalid ship identifier. |
403 |
The caller is a member, not an admin. |
404 |
ship not found: <name>. |
A released workspace key is handed to another ship that also reports it, if any
(online ships preferred, then alphabetically by name); otherwise it leaves the
index and subsequent requests for it return 404.
System resources
Section titled “System resources”GET /system-resources
Section titled “GET /system-resources”Fetches every ship’s snapshot in parallel. Never fails because of one ship.
{ ship: string; status: "online" | "offline"; resources: SystemResources | null; error: string | null;}[]| Ship state | resources |
error |
|---|---|---|
| online, responded | the snapshot | null |
| online, request failed | null |
the error message |
| offline | null |
null |
GET /ships/:ship/system-resources
Section titled “GET /ships/:ship/system-resources”Proxies the request live to one ship and returns its SystemResources object
unchanged (see ship API).
| Status | Cause |
|---|---|
400 |
unknown ship: <name>. |
503 |
ship "<name>" is offline. |
Repo registry
Section titled “Repo registry”The repo registry is owned entirely by the bridge and persisted to repos.json
in its data directory. Ships know nothing about it — a ship is told a clone URL,
never a repo name to look up.
GET /repos
Section titled “GET /repos”{ name: string; url: string; provider: string }[]POST /repos
Section titled “POST /repos”{ name: string; url: string; provider?: string } // requestprovider defaults to "custom" when omitted. Returns 201 with the stored
Repo.
| Status | Cause |
|---|---|
422 |
name or url missing. |
400 |
invalid repo — e.g. name is not a valid fleet identifier. |
409 |
repo already registered: <name>. |
DELETE /repos/:name
Section titled “DELETE /repos/:name”Responds { ok: true }.
| Status | Cause |
|---|---|
400 |
Invalid repo identifier. |
404 |
repo not found: <name>. |
Deleting a repo does not touch any workspace already cloned from it.
GET /repos/:name/branches
Section titled “GET /repos/:name/branches”The branches the repo’s remote currently advertises, sorted by name.
{ name: string; sha: string }[]Answered with git ls-remote --heads against the registered clone URL, not
through the repo’s provider: provider defaults to "custom", for which no
provider exists, so a provider-backed listing would be unavailable for most
repos. ls-remote works against any git URL and needs no token. The probe runs
non-interactively — git never prompts for credentials or host keys, aborts an
http transfer that stalls for 15 s, and is given up on entirely after 20 s.
refs/heads/ is stripped from each name; tags and other refs are omitted, so a
tag the ship would happily clone does not appear here.
| Status | Cause |
|---|---|
400 |
Invalid repo identifier. |
404 |
repo not found: <name>. |
502 |
could not list branches for repo "<name>": <git's stderr> — unreachable, unauthenticated, or timed out. Credentials embedded in the repo URL are redacted from this message. |
Armory
Section titled “Armory”The read side of the armory: the manifest of the bridge’s
<dataDirectory>/armory/ directory, the contents of any file it lists, and what
each ship has applied. Ships use the first two to pull; the third is for
operators. All three are read-only — armory content is edited on the bridge host,
never through the API.
GET /armory
Section titled “GET /armory”{ revision: string; // lowercase hex sha256 of the whole armory entries: { path: string; // POSIX, armory-relative, e.g. "skills/reviewer/SKILL.md" section: "skills" | "plugins" | "dotfiles"; size: number; sha256: string; // lowercase hex mode: number; // normalized to 0o755 or 0o644 }[]; dotfileMap: Record<string, string>; // dotfiles/-relative source → "~/"-rooted or absolute destination}entries is sorted by path. revision is a content address: it changes when
and only when a file’s contents, mode, or path changes, or dotfileMap changes,
so a ship can compare revisions to decide whether to re-pull.
A missing armory/ directory is not an error — it yields an empty manifest.
| Status | Cause |
|---|---|
400 |
invalid <path>/dotfile-map.json: followed by one indented line per offending entry — a malformed map fails the whole manifest. |
GET /armory/file
Section titled “GET /armory/file”| Query | Type | Required | Meaning |
|---|---|---|---|
path |
string | yes | An entries[].path from the manifest. |
{ path: string; section: "skills" | "plugins" | "dotfiles"; size: number; sha256: string; mode: number; encoding: "utf8" | "base64"; contents: string;}The facts repeat the manifest’s so a caller can verify what it fetched without
holding the manifest. encoding is utf8 when the bytes decode as text and
base64 otherwise.
| Status | Cause |
|---|---|
400 |
An unsafe path (absolute, containing .., or with a \ segment); an invalid dotfile-map.json. |
404 |
armory file not found: <path> — not listed in the manifest, or gone since the scan. |
413 |
armory file too large (<size> bytes, limit 10485760): <path>. Oversized files are still listed in the manifest; only serving them is refused. |
422 |
path query parameter missing. |
GET /armory/ships
Section titled “GET /armory/ships”{ ship: string; status: "online" | "offline"; state: { // null for an offline ship, or one whose call failed revision: string | null; // applied revision; null until the first successful sync bridgeUrl: string | null; syncedAt: string | null; // ISO timestamp fileCount: number; install: { // null until an install has run skillCount: number; pluginCount: number; dotfileCount: number; removedCount: number; conflicts: string[]; // destinations left alone warnings: string[]; installedAt: string | null; } | null; lastError: string | null; // cleared by the next success } | null;}[]Always 200. A state of null means the bridge could not ask — a single
unreachable ship never fails the aggregate, and is deliberately distinct from a
ship answering that it holds nothing.
Counts are files, not skills or plugins: a skill is a directory and a plugin is an arbitrary tree, so files are the only unit both share.
Pushing to ships
Section titled “Pushing to ships”There is no route that triggers a sync. The bridge pushes
POST /armory/sync to every online ship on three
occasions: when the armory directory changes on disk, when a ship registers, and
whenever a ship arrives at online (so a restarted or reconnected ship catches
up). Each push carries { bridgeUrl, revision }, where bridgeUrl is the
bridge’s --public-url, defaulting to http://localhost:<port>.
Pushes are fire-and-forget: a ship that fails is logged and skipped, never retried inline, and a registration never waits on the armory.
Workspaces
Section titled “Workspaces”GET /workspaces
Section titled “GET /workspaces”| Query | Type | Default | Meaning |
|---|---|---|---|
active |
string | absent | "true" → only active, "false" → only inactive. Any other value is treated as no filter. |
Before answering, the bridge re-fetches GET /workspaces from every online ship
and refreshes its index, so the list reflects the ships’ current view rather
than only the event stream.
// BridgeWorkspaceSummary{ repoName: string; name: string; branch: string; active: boolean; agent: AgentStatus | null; ship: string; // the extra fields ephemeral: EphemeralWorkspace | null;}[]ephemeral is the bridge’s own record — a ship knows nothing about it — and is
null for every ordinary workspace:
// EphemeralWorkspace{ issueNumber: number; branch: string; // the branch linked to the issue at create time cleanup: "watching" | "blocked"; blockedReason: string | null; // the ship's refusal, truncated to 200 chars blockedAt: string | null; // ISO-8601 pullRequest: { number: number; state: string; url: string } | null;}pullRequest is what the last sweep saw, so it lags the forge by up to one
sweep interval. Render it; do not branch on it.
GET /workspaces/:repo/:name
Section titled “GET /workspaces/:repo/:name”Proxied live to the owning ship, so the diff is fresh. The response is the
ship’s WorkspaceStatus union with ship added to whichever variant is
returned — meaning inactive responses carry ship here even though they do
not on a ship.
{ state: "inactive"; repoName; name; branch; ship: string; ephemeral: EphemeralWorkspace | null }{ state: "active"; repoName; name; branch; diff; agent; issue: null; mergeRequest: null; ship: string; ephemeral: EphemeralWorkspace | null }issue and mergeRequest are the ship’s own fields and are always null;
ephemeral is the bridge’s, and is where an issue-linked workspace’s state
actually lives.
The bridge re-validates the ship’s response: an unparseable status, or one whose
repoName/name differ from the request, is a 502.
GET /workspaces/:repo/:name/diff
Section titled “GET /workspaces/:repo/:name/diff”Same query parameters as the ship’s diff route (staged, stat, nameOnly,
range, paths, includeUntracked), forwarded unchanged. Returns raw diff
text.
POST /workspaces
Section titled “POST /workspaces”// request body — ship, repoName and name are required;// exactly one of branch / issueNumber must be present{ ship: string; repoName: string; name: string; branch?: string; issueNumber?: number; ephemeral?: boolean }ship names the target host and repoName must be a registered repo; the
bridge looks up its clone URL and calls the ship’s POST /workspaces with
{url, repoName, name, branch}. Returns 201 with the ship’s
WorkspaceSummary plus ship.
branch is handled entirely by the ship, so a branch the repo does not have yet is
created in the new workspace rather than rejected — see
ship API.
With issueNumber instead, the bridge resolves the branch itself before calling
the ship:
- it reads the issue through the repo’s provider — so this
form needs
provider: "github"and a token with repo write scope; - it computes the issue’s canonical branch name,
<number>-<slug of title>capped at 60 characters (12-better-create-workspace-issue) — the same function a client can use to preview the name; - it asks the provider to create that branch and record it as the issue’s linked development branch (GitHub’s “Development → create a branch”);
- the name the provider returns is what the ship is told to check out. Use it rather than the computed name — a provider may hand back a different ref.
Step 3 is safe to repeat. If the provider will not create the branch, the bridge
looks for one of the same name — first among the issue’s linked branches, then
as a plain ref (someone may have pushed it by hand) — and uses that. A branch
linked to the issue under a different name is never substituted; that case
fails with 409, since silently checking out a branch the caller never named is
worse than refusing. The branch is created before the clone and is not removed if
the clone then fails, so a retry reuses it.
| Status | Cause |
|---|---|
422 |
ship, repoName or name is missing. |
400 |
Invalid repo/workspace identifier; unknown ship: <ship>; unknown repo: <repoName>; both branch and issueNumber, or neither; a blank branch; an issueNumber that is not a positive integer; ephemeral without issueNumber. |
| provider’s status | Any error resolving or linking the issue is passed through with the provider’s own status — e.g. 401 with no token, 403 for a token without repo write scope, 404 for an unknown issue, 409 when the branch could be neither created nor found under the requested name. |
503 |
ship "<ship>" is offline. |
409 |
workspace already exists: <repo>/<name>; a create for that key is already in progress; the key’s create outcome is indeterminate; the target ship was removed mid-request. |
502 |
The ship returned no data, an invalid summary, or a different workspace identity. |
ephemeral: true additionally records the workspace for automatic cleanup, and
is only accepted alongside issueNumber. The record is written after the ship
confirms the clone, so a failed create leaves nothing behind. See
POST /workspaces/sweep and
Workspaces.
POST /workspaces/sweep
Section titled “POST /workspaces/sweep”Runs one ephemeral-cleanup pass immediately instead of waiting for the timer
(sweepIntervalMs, five minutes by default). No request body.
{ "checked": 3, "destroyed": 1, "blocked": 1, "skipped": 0, "forgotten": 0 }| Field | Meaning |
|---|---|
checked |
records whose pull requests were read this pass |
destroyed |
workspaces deleted |
blocked |
cleanups the ship refused because the clone holds work no remote has |
skipped |
left for a later pass — an offline ship, or a provider that could not answer |
forgotten |
records dropped because the workspace is gone and its ship was online to say so |
Passes never overlap: calling this while one is running returns that pass’s result rather than starting a second.
POST /workspaces/:repo/:name/branch
Section titled “POST /workspaces/:repo/:name/branch”{ branch: string } // request{ ok: true } // responsePOST /workspaces/:repo/:name/activate, /deactivate, and DELETE /workspaces/:repo/:name
Section titled “POST /workspaces/:repo/:name/activate, /deactivate, and DELETE /workspaces/:repo/:name”No request body; each responds { ok: true } and forwards to the owning ship.
WS /events
Section titled “WS /events”A read-only fleet-wide stream. Anything the client sends is ignored. On connect
the bridge sends an aggregate sync built from its ownership index, then
republishes each ship’s events.
{ type: "sync"; at: string; workspaces: BridgeWorkspaceSummary[] }{ type: "workspace.created" | "workspace.branch_changed" | "workspace.activated" | "workspace.deactivated" | "workspace.agent_status_changed" | "workspace.removed"; at: string; workspace: BridgeWorkspaceSummary }Two differences from a ship’s /events:
- There is no top-level
shipfield. The emitting ship is on each workspace instead (workspace.ship,workspaces[].ship). syncandworkspace.removedfrom any ship are republished as a fresh aggregatesyncsnapshot rather than forwarded as-is. Ship registration and deregistration also publish a snapshot.
An event for a workspace the emitting ship does not own (per the index) is dropped rather than republished.
WS /workspaces/:repo/:name/terminal
Section titled “WS /workspaces/:repo/:name/terminal”A dumb bidirectional pipe to the owning ship’s terminal WebSocket. The bridge does not emulate a terminal; it revalidates each client frame against the webterm protocol and forwards it.
| Behavior | Detail |
|---|---|
| Unknown or offline workspace | The bridge cannot send an HTTP status once the socket is open, so it sends {"type":"exit","code":1} and closes — the same convention a ship uses for a busy session. |
| Buffering | Client frames sent before the upstream socket opens are buffered so the browser’s first init is never lost. |
| Buffer cap | More than 256 KiB pending closes both sockets with 1009 / Terminal buffer limit exceeded. |
| Binary frame (either direction) | Closes both sockets with 1003 / Binary terminal messages are not supported. |
| Undecodable client frame | Closes both sockets with 1008 / Invalid terminal message. |
| Upstream close | The ship’s close code and reason are propagated to the client. |
| Client close | Propagated upstream, which releases the ship’s one-terminal-per-workspace guard. |
The socket’s max payload is 1,572,992 bytes, matching the ship’s.