REST API
Scripting the control plane over HTTP, covering auth paths, the live OpenAPI docs, and the type surface you can build against.
Everything the browser UI can do, your scripts can do: the control plane is a JSON REST API mounted under /api, with better-auth's sign-in and session routes under /api/auth/* and the terminal streams on WebSockets. Your own instance documents itself live: open http://<your-server>:3080/docs for the interactive reference, generated from the exact build answering you, not a snapshot.
Which credential reaches which surface
Three kinds of credential exist, and where each is accepted is a rule, not a convention:
| Credential | Who holds it | Where it works |
|---|---|---|
| Session cookie | your browser, after password or passkey sign-in | Everything you have access to, and the only credential admin surfaces accept. |
| Subshell token | a running pane (minted at launch, baked into its environment) | That subshell's own surface, and only its owner's subshells. Shared grants and the admin boost are switched off for bearer actors, so a pane can never act on a subshell merely shared with its owner. It can mint a terminal attach token only for its own subshell. |
| System API key | your scripts and tooling | General API use. Never management; see below. Mint and revoke under Settings → API keys (System API Keys). |
Bearer keys go in an Authorization: Bearer subshell_… header. On top of the table, three refusals are deliberate and will bite a first script:
- Admin surfaces reject bearer credentials with 403.
/api/userswrites,/api/system-keys,/api/pluginswrites (the plugin listing is open to any authenticated caller, since the launch pickers read against it),/api/network, and the/api/admin/*group are cookie-session, admin-role only: machine credentials can never manage the instance. - Sharing is a browser act. The routes that grant or revoke access to a subshell are cookie-only.
- A node's agent key does nothing on REST: presented to the API it is refused by name. Its entire job is authenticating that machine's WebSocket to the control plane.
The terminal WebSocket doesn't take your credential on the socket either: you mint a short-lived, single-use attach token (POST /api/auth/ws-token) and hand the socket that. A browser-session mint is unrestricted: the token attaches wherever your own access reaches. A bearer key may mint as well, but only a bound token: it names one subshell at issue and is refused at redemption on every other pane (a system key may name any subshell; a running pane's own token may name only itself), and it is refused on the whole-user live feed outright.
The route families
/api/subshells (rows, logs, restart, terminate, pane input, sharing, WS-attach), /api/workspaces, /api/presets, /api/nodes (a bearer machine token may list its owner's own nodes; the detail read and every write are cookie-only), /api/channels and /api/identities (the encrypted agent logs), /api/devices and /api/notifications (push for your own sessions), /api/files and /api/uploads, /api/settings, /api/users, /api/audit (admin), /api/plugins, /api/network, /api/system-keys, /api/setup (first-run), /api/downloads/node/… (the node binaries enrollment fetches), and /api/admin/* (status, deployment, updates, all cookie-admin). Two reads sit before sign-in: GET /api/setup/status (has the first account been created yet?) and GET /api/settings/instance, which returns just the instance's display name so the sign-in page can tell you which plane is asking for your password; outside the first-run window, that second one is the only pre-auth read. The Scalar page at /docs is the authoritative, always-current listing.
The typed path
If you write TypeScript, you don't need the OpenAPI document: the type surface is the product. The server exports a single App type that describes every route, request, response and error, and Eden Treaty infers a fully typed client from it:
import { treaty } from "@elysiajs/eden";
import type { App } from "@internal/server";
const api = treaty<App>("http://localhost:3080");
const { data, error } = await.api.subshells.get();Nothing is generated and nothing drifts: types flow from the server's own definition through the client call, so a route change breaks your script at compile time rather than at runtime. That is exactly how the SPA itself talks to its backend, through the workspace package @internal/backend-client, which is the same two lines of glue. It is a repository package, not a published artifact; the point is the pattern: take the declarations and infer. If you work in another language, the OpenAPI document behind /docs is your input.
Building a client is not copyleft
Subshell is dual-licensed by directory: everything under apps/server/ (the control plane) is AGPL-3.0-only, and everything else in the project is Apache-2.0. The AGPL is aimed at the one piece someone might fork into a hosted service, but it raises an obvious question for an API user: does talking to the server, or building on its type declarations, infect my code?
It does not, and that is explicit. The license carries an additional permission under AGPL section 7, the API Type Surface exception (full text):
"API Type Surface" means the TypeScript type declarations that describe this software's externally reachable interfaces: its HTTP routes and their request, response and parameter shapes, its WebSocket frames, and its MCP tools, including the
Apptype exported by@internal/server, and any TypeScript declaration file (.d.ts) generated from those declarations.
The permission lets you use that surface under Apache-2.0 instead of the AGPL, and it names its purpose outright: API clients, SDKs, test harnesses and tools may be written for Subshell (distributed, or offered as a network service) without falling under the AGPL. The boundary is equally explicit about what stays copyleft: the exception covers declarations, not implementation. No executable code or server internals are in it. In practice, using HTTP, WebSocket, or MCP interfaces plus the type surface keeps you entirely outside the AGPL.
Errors all look the same
Every failed request (validation, authorization, or a server fault) carries one JSON shape:
{
"errId": "H2dYk9…",
"code": "INPUT_VALIDATION_ERROR",
"message": "working_dir: expected string",
"statusCode": 400,
"reqId": "1a2b3c…",
"metadata": { "field": "working_dir" }
}Branch on code, the machine-readable value, never on message (wording is prose and may change). errId is unique to the occurrence: it is the line to quote in a bug report, because it matches a server-side log entry for logged errors. statusCode mirrors the HTTP status; reqId and metadata appear only when populated, and error responses may carry additional fields, so ignore what you don't read.
Rate limiting: only sign-in
Email sign-in answers repeated failures with an exponential backoff. Nothing else on the API is rate-limited: no per-key throttle, no list caps beyond the query parameters the routes themselves define. That is the trusted-network posture stated plainly: the service is hardened for a network you control (VPN, tailnet, LAN), and if you ever put it somewhere that isn't one, closing this gap is a named step on the checklist, not an oversight to discover.
See also
- System API Keys: the credential your scripts should carry
- API Keys: how the two bearer kinds are minted and managed
- Security model: the posture the rate-limiting and admin-refusal rules assume
Last updated on
