Ship API reference
A ship serves an Elysia app on the port given by
fleet ship --port (default 4700). There is no route prefix: paths are
absolute from the origin. The app is composed of four plugins — workspaces
(including the terminal WebSocket), events, system resources, and the armory.
Authentication on these routes is opt-in and comes from one environment variable,
FLEET_BRIDGE_TOKEN. With it set, every route requires an Authorization: Bearer
header: that token reaches everything, and this run’s agent token (published in
atlas.json) reaches only GET /agent/credentials and a workspace’s own
agent/init and agent/status. Anything else is a 401. With it unset every
route answers anyone who can reach the port, and the ship warns about that at
startup.
A second variable, FLEET_SHIP_TOKEN, does not affect these routes at all: it is
what the ship presents outbound when POST /armory/sync sends it to fetch the
manifest from the bridge. See authentication.
Routes at a glance
Section titled “Routes at a glance”| Method | Path | Success | Body |
|---|---|---|---|
| GET | /workspaces |
200 | WorkspaceSummary[] |
| GET | /workspaces/:repo/:name |
200 | WorkspaceStatus |
| GET | /workspaces/:repo/:name/diff |
200 | raw diff text |
| POST | /workspaces |
201 | WorkspaceSummary |
| 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 } |
| POST | /workspaces/:repo/:name/agent/init |
200 | AgentStatus |
| GET | /workspaces/:repo/:name/agent/status |
200 | AgentStatus or null |
| POST | /workspaces/:repo/:name/agent/status |
200 | AgentStatus |
| GET | /system-resources |
200 | SystemResources |
| POST | /armory/sync |
200 | ArmorySyncState |
| GET | /armory |
200 | ArmorySyncState |
| WS | /workspaces/:repo/:name/terminal |
— | webterm protocol |
| WS | /events |
— | FleetEvent stream |
Path parameters are the same everywhere:
| Parameter | Meaning |
|---|---|
:repo |
Repo name — the directory the workspace lives under. |
:name |
Workspace name, unique within its repo. |
Both must be valid fleet identifiers; anything else is a
400 with {"error": "invalid repo identifier"} or
{"error": "invalid workspace identifier"}.
Error shape and status codes
Section titled “Error shape and status codes”Every handler catches its errors and returns a JSON object with a single
error string:
{ error: string }The status comes from the thrown WorkspaceError; anything else is a 500
carrying the error’s message.
| Status | Raised when |
|---|---|
400 |
Invalid repo/workspace identifier; a path that escapes the fleet directory; invalid workspace create request; branch must not be empty; could not create branch "<branch>": <git's reason>; remote has no usable HEAD to create branch "<branch>" from; workspace not active: <repo>/<name>; workspace already active: <repo>/<name>; agent not initialized: <repo>/<name>. |
404 |
workspace not found: <repo>/<name> — the directory does not exist or is not a git working tree. |
409 |
The clone destination already exists (POST /workspaces). |
422 |
Elysia schema validation — a missing or wrongly typed request body/query field. Note this is Elysia’s own error shape, not {error}. |
500 |
Any error that is not a WorkspaceError. |
GET /workspaces
Section titled “GET /workspaces”Lists every workspace directory under the ship’s fleet directory that is a git working tree.
| Query | Type | Default | Meaning |
|---|---|---|---|
active |
string | absent | "true" → only active, "false" → only inactive. Any other value (including garbage) is treated as absent, i.e. no filter. |
Response: an array of WorkspaceSummary.
{ repoName: string; name: string; branch: string; active: boolean; agent: AgentStatus | null;}[]Disk entries that are invalid, or that disappear between discovery and use, are silently omitted rather than failing the request.
GET /workspaces/:repo/:name
Section titled “GET /workspaces/:repo/:name”Detailed status for one workspace. The response is a discriminated union on
state.
// state: "inactive"{ state: "inactive"; repoName: string; name: string; branch: string }
// state: "active"{ state: "active"; repoName: string; name: string; branch: string; diff: { added: number; removed: number; commits: number }; agent: AgentStatus | null; issue: null; mergeRequest: null; ship: string; // this ship's configured name}diff.added / diff.removed are line counts from git diff --numstat HEAD
(binary files are skipped) and diff.commits is the number of commits ahead of
upstream.
Errors: 400 invalid identifier, 404 workspace not found.
GET /workspaces/:repo/:name/diff
Section titled “GET /workspaces/:repo/:name/diff”Raw git diff output as text, not JSON. Works whether or not the workspace is
active, since it reads the on-disk tree.
| Query | Type | Default | Meaning |
|---|---|---|---|
staged |
boolean | absent | Diff the index against HEAD (--staged). |
stat |
boolean | absent | Emit a diffstat summary (--stat) instead of the full patch. |
nameOnly |
boolean | absent | List only changed paths (--name-only). |
range |
string | absent | Commit or range to diff, e.g. HEAD~1 or main..feature. |
paths |
string[] | absent | Restrict the diff to these paths. Repeat the key: ?paths=a.ts&paths=b.ts. |
includeUntracked |
boolean | absent | Append synthesized add-file diffs for untracked files. |
Omitted query keys are omitted from the options object entirely, not defaulted
to false.
Errors are still JSON: a 404 returns {"error": "workspace not found: …"} in
the response text.
POST /workspaces
Section titled “POST /workspaces”Creates a workspace by cloning url into <fleetDirectory>/<repoName>/<name>
on branch. Returns 201.
// request body — all four fields required{ url: string; repoName: string; name: string; branch: string }
// 201 response{ repoName: string; name: string; branch: string; active: false; agent: null }branch is trimmed, then looked up on the remote (git ls-remote). A name the
remote advertises as a branch or a tag is checked out by the clone
(git clone --branch; a tag lands detached, as git does). One it advertises as
neither is created off the repo’s default branch after the clone
(git switch --create), so the create succeeds instead of failing. Such a branch
is never pushed — it exists only in the workspace until something pushes it.
Errors: 422 if a body field is missing or mistyped, 409 if the destination
directory already exists, and 400 for an invalid identifier,
invalid workspace create request, branch must not be empty (blank or
whitespace-only), a branch name git refuses to create, or a remote whose HEAD
does not resolve to a commit (an empty repo, or one whose HEAD names a deleted
branch). Any failure after the destination is claimed removes it again, so a retry
is never blocked by the 409; should that removal also fail, the response is a
500 naming both the original failure and the directory left behind.
A new workspace always starts inactive; the workspace.created event is emitted
on /events.
POST /workspaces/:repo/:name/branch
Section titled “POST /workspaces/:repo/:name/branch”Switches the workspace to branch, creating it if it does not exist
(git switch --create).
{ branch: string } // request{ ok: true } // 200 responseEmits workspace.branch_changed.
POST /workspaces/:repo/:name/activate
Section titled “POST /workspaces/:repo/:name/activate”Starts the workspace’s tmux session. No request body. Responds { ok: true }.
Errors: 400 workspace already active: <repo>/<name>, 404 workspace not
found. Emits workspace.activated.
POST /workspaces/:repo/:name/deactivate
Section titled “POST /workspaces/:repo/:name/deactivate”Kills the workspace’s tmux session and clears its in-memory agent status. No
request body. Responds { ok: true }.
Errors: 400 workspace not active: <repo>/<name>, 404 workspace not found.
Emits workspace.deactivated.
DELETE /workspaces/:repo/:name
Section titled “DELETE /workspaces/:repo/:name”Kills the session if one is up, deletes the workspace directory recursively, and
clears its agent status. Responds { ok: true }.
| Query | Type | Default | Meaning |
|---|---|---|---|
force |
boolean | absent (unconditional) | false refuses the delete when the clone holds work no remote has |
With force=false the ship checks three things before touching anything, and
answers 409 naming everything it found — for example
workspace repo/ws holds work that is not on a remote: 1 uncommitted file; 2 commits not on any remote:
- a working tree that is not clean, untracked files included;
- commits absent from every remote on any local branch, not just the one
checked out (
git log --branches --not --remotes) — so a branch that was never pushed counts in full; - a stash.
A check that cannot be run counts as work held: refusing to delete is the
recoverable mistake. Omitting force keeps the unconditional behaviour, which
is what the CLI, the web GUI, and the bridge’s own DELETE all use; the bridge
passes force=false only for ephemeral
cleanup.
Errors: 404 workspace not found; 409 as above. Emits workspace.removed,
whose workspace.branch is the branch captured immediately before deletion
("" if it could not be read).
POST /workspaces/:repo/:name/agent/init
Section titled “POST /workspaces/:repo/:name/agent/init”Attaches (or resets) an agent session on an active workspace, seeding its
status to idle with the description
Created session at <ISO timestamp>.
// request body — all three fields required{ model: string; provider: string; harness: string }
// 200 response{ state: "idle"; description: string; model: string; provider: string; harness: string;}Errors: 422 missing body fields, 400 workspace not active: <repo>/<name>,
404 workspace not found. Emits workspace.agent_status_changed.
GET /workspaces/:repo/:name/agent/status
Section titled “GET /workspaces/:repo/:name/agent/status”Returns the live AgentStatus, or null when no agent is attached — in which
case the HTTP response body is empty.
POST /workspaces/:repo/:name/agent/status
Section titled “POST /workspaces/:repo/:name/agent/status”Updates the live status, preserving the session’s model / provider /
harness.
// request body{ state: "idle" | "planning" | "building" | "verifying" | "awaiting"; description: string }
// 200 response: the full AgentStatus after the updateErrors: 422 if state is outside the union or description is missing, 400
agent not initialized: <repo>/<name> when agent/init has not run, 404
workspace not found. Emits workspace.agent_status_changed.
Agent status is in-memory runtime state tied to the tmux session. It is never persisted and is dropped on deactivate, remove, or ship restart.
GET /system-resources
Section titled “GET /system-resources”A point-in-time snapshot of the host, gathered from node:os. CPU usage is
sampled over a 100 ms window, so this route takes at least that long to respond.
{ uptimeSeconds: number; os: { type: string; platform: string; release: string; version: string; arch: string; machine: string; hostname: string; }; cpu: { model: string; // first core's model, or "unknown" cores: number; usage: number; // 0..1 busy fraction loadAverage: [number, number, number]; // 1/5/15-minute }; memory: { total: number; free: number; used: number; usage: number };}This route has no error mapping — it always returns 200 on a healthy host.
POST /armory/sync
Section titled “POST /armory/sync”The bridge’s push telling this ship to re-pull and re-install the
armory. A ship holds no bridge address of its own, so
bridgeUrl is how it learns where to pull from — but only the bridge it is
pinned to: fleet ship --bridge-url, or, unset, whichever bridge pushed to it
first. Any other origin is refused with 403 before anything is fetched (see
the Armory).
{ bridgeUrl: string; revision: string } // requestrevision is a hint that something changed, not an instruction: the ship applies
whatever revision the manifest it then fetches reports, because the armory may
change again between the push and the fetch.
The ship pulls GET /armory and GET /armory/file from bridgeUrl, verifying
every file’s sha256 against the manifest before writing it into
~/.config/autosmith/fleet-ship/armory/files/, then installs. Responds with the
resulting ArmorySyncState (below). The call is synchronous — it returns after
the install, not when the pull is queued.
A single bad file fails the whole sync: the ship keeps the revision it already
had and records lastError, rather than recording a revision that promises an
armory it only half applied.
| Status | Cause |
|---|---|
400 |
bridge url must be http(s): <url>; invalid bridge url: <url>. |
403 |
armory push refused: this ship is pinned to bridge <expected> but the push named <offered> — nothing was fetched and the applied state is untouched. |
422 |
bridgeUrl or revision missing. |
500 |
armory install failed: <detail> — the pull succeeded, the install did not. |
502 |
The pull failed: bridge answered <status> for the armory file <path>, a manifest or file that did not validate, an unsafe path, or a file whose bytes did not match the manifest hash. |
GET /armory
Section titled “GET /armory”What this ship has pulled and applied. Read-only; it triggers nothing.
{ revision: string | null; // applied revision; null until the first successful sync bridgeUrl: string | null; // the bridge it last pulled from syncedAt: string | null; // ISO timestamp of the last successful sync fileCount: number; install: { // the last install applied from the cache; null until one has run skillCount: number; pluginCount: number; dotfileCount: number; // symlinks in place; a conflicted or skipped mapping is not one removedCount: number; // files uninstalled because the armory no longer carries them conflicts: string[]; // destinations left alone because something unmanaged was there warnings: string[]; installedAt: string | null; } | null; lastError: string | null; // most recent failed sync or install, cleared by the next success}A ship that has never synced answers with revision, bridgeUrl, syncedAt,
install, and lastError all null, and fileCount 0 — a cold cache is not
an error.
The bridge aggregates this across the fleet as
GET /armory/ships.
WS /events
Section titled “WS /events”A read-only broadcast of workspace and agent state changes. Anything the client sends is ignored.
On connect the ship sends a sync snapshot, then one event per change. Every
frame is JSON text and matches the FleetEvent union documented in
protocol:
{ type: "sync"; ship: string; at: string; workspaces: WorkspaceSummary[] }{ type: "workspace.created" | "workspace.branch_changed" | "workspace.activated" | "workspace.deactivated" | "workspace.agent_status_changed" | "workspace.removed"; ship: string; at: string; workspace: WorkspaceSummary }ship is the ship’s configured name and at is an ISO 8601 timestamp.
Changes emitted while the initial snapshot is still being built are buffered and replayed immediately after it, so no event is lost on connect.
| Close code | Reason | Cause |
|---|---|---|
1011 |
Failed to build workspace snapshot |
The snapshot could not be produced on connect. |
1009 |
Terminal buffer limit exceeded |
More than 256 KiB of events queued while the snapshot was pending. |
WS /workspaces/:repo/:name/terminal
Section titled “WS /workspaces/:repo/:name/terminal”Attaches a terminal to the workspace’s tmux session by running
tmux -L fleet-ship attach -t ws-<sha256>. The wire format is the webterm
protocol: the server parses the shell’s VT bytes and streams the cell grid; the
client sends keystrokes and acknowledges frames.
Client → server messages (JSON text only):
{ type: "init"; cols: number; rows: number } // must be first, exactly once{ type: "input"; data: string }{ type: "resize"; cols: number; rows: number }{ type: "ack"; seq: number } // frame `seq` arrived{ type: "resync" } // sequence lost; send a snapshotcols is 1–1024, rows is 1–512, input.data is at most 256 KiB of UTF-8, and
seq is a non-negative integer. The socket’s max payload is 1,572,992 bytes, and
the socket negotiates permessage-deflate when the client offers it.
Server → client messages: a full grid snapshot to open the connection and
after every resize or resync, a patch of changed cell runs for every other
frame, and a final { type: "exit", code: number }.
Frames are paced by the client’s acks: with two frames unacknowledged the server stops sending until one is acked, or until five seconds pass, after which it sends one full snapshot and resumes. A client that never acks therefore sees a snapshot every five seconds rather than a live terminal.
Connection rules:
| Behavior | Detail |
|---|---|
| One terminal per workspace | A second connection for the same session immediately receives {"type":"exit","code":1} and is closed. The guard is released when the first connection closes. |
init deadline |
The first message must be init within 5000 ms, else close 1008 / terminal init timeout. |
init exactly once |
A non-init before init, or a second init, closes 1008 / Invalid terminal message. |
| Undecodable frame | Close 1008 / Invalid terminal message. |
| Binary frame | Close 1003 / Binary terminal messages are not supported. |
| Shell exit | The server sends exit, then closes and releases the guard. |