Skip to content

Bridge API reference

Updated

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.

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.

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

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.

These two cases are the ones worth memorizing:

  • Unknown workspace404 {"error": "workspace not found: <repo>/<name>"}. The bridge routes from an in-memory index built from every online ship’s /events stream. A workspace it has never seen — or one whose only owner has been deregistered — is simply not in that index.
  • Offline ship503. Every routed operation (GET, diff, branch, activate, deactivate, DELETE, terminal target) requires the owning ship to be online; otherwise ship "<name>" hosting <repo>/<name> is offline. A ship that fails at the transport layer mid-call is flipped offline and the call becomes ship "<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.

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.

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 } // request

The 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>.

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.

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

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.

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.

{ name: string; url: string; provider: string }[]
{ name: string; url: string; provider?: string } // request

provider 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>.

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.

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.

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.

{
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.
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.
{
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.

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.

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.

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.

Same query parameters as the ship’s diff route (staged, stat, nameOnly, range, paths, includeUntracked), forwarded unchanged. Returns raw diff text.

// 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:

  1. it reads the issue through the repo’s provider — so this form needs provider: "github" and a token with repo write scope;
  2. 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;
  3. it asks the provider to create that branch and record it as the issue’s linked development branch (GitHub’s “Development → create a branch”);
  4. 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.

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.

{ branch: string } // request
{ ok: true } // response

POST /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.

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 ship field. The emitting ship is on each workspace instead (workspace.ship, workspaces[].ship).
  • sync and workspace.removed from any ship are republished as a fresh aggregate sync snapshot 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.

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.