Skip to content

Ship API reference

Updated

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.

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"}.

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.

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.

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.

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.

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.

Switches the workspace to branch, creating it if it does not exist (git switch --create).

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

Emits workspace.branch_changed.

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.

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.

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

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.

Returns the live AgentStatus, or null when no agent is attached — in which case the HTTP response body is empty.

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 update

Errors: 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.

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.

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

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

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.

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.

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 snapshot

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