API

Server terminal

Issue access tickets and open a terminal on connected or managed Cloud servers.

The Terminal API opens a live shell (a PTY) on a connected server or a managed Cloud server and streams it to the browser over a WebSocket — the dashboard's server Terminal tab, backed by xterm.js. Because a browser can't set an Authorization header on a WebSocket(), auth is split into two steps: a normal HTTP call mints a short-lived, single-use ticket, and the WebSocket upgrade presents that ticket inside its Sec-WebSocket-Protocol header.

Server capabilities

Use the server's capabilities.terminal and terminalSessionLimit fields to determine availability and the concurrent shell limit. Managed Cloud servers support one live host shell at a time through the provider, covering both Docker and bare projects. SSH servers use the configured per-user limit (3 by default). Service shells use /api/services/terminal.

Mint a ticket

Opening a PTY is administrative, so the caller must hold server:admin on the target server. The endpoint also confirms the server exists in your organization (returning the same 404 the WebSocket would) before issuing the token.

POST /api/terminal/ticket

Prop

Type

curl -X POST "$OPENSHIP_URL/api/terminal/ticket" \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"serverId":"srv_123"}'

The response carries the one-shot token and its lifetime:

{ "success": true, "token": "<opaque-single-use-token>", "expiresIn": 30 }

The token is consumed the first time it's presented on an upgrade — it cannot be replayed. Mint a fresh ticket per connection attempt.

No CLI equivalent yet

openship server ssh <serverId> is stubbed as "coming soon" — the CLI doesn't bundle a WebSocket client, so there's no terminal command today. Use the dashboard, or drive the WebSocket directly as shown below.

Open the WebSocket

GET /api/terminal/ws/:serverId

Connect to the wss:// form of this path and pass the ticket as a WebSocket subprotocol. Browsers send subprotocols in the Sec-WebSocket-Protocol header; the value must be the prefix openship.terminal.v1+ followed by the token from /ticket:

const { token } = await fetch("/api/terminal/ticket", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ serverId }),
}).then((r) => r.json());

const ws = new WebSocket(`wss://your-host/api/terminal/ws/${serverId}`, [
  `openship.terminal.v1+${token}`,
]);

The upgrade enforces, in order: the request Origin must be a trusted origin; a presented ticket must be valid (same-origin session cookies are accepted only when no ticket is presented); the ticket's serverId must match the path; the caller must hold server:admin; the server must exist in the org; and the per-user concurrent-session cap must not be exceeded. Any failure opens the socket only to send an error control frame and close with a 4xxx code.

Resuming a parked session

When the socket drops without an explicit close, the shell is parked (kept alive) so the client can reattach. To resume, present a second subprotocol alongside the ticket: openship.terminal.resume+<resumeToken>, where resumeToken came from the previous ready frame. A resume reuses the existing PTY and audit row and does not count against the session cap.

Wire protocol

Once open, framing is:

  • Binary frames, both directions — raw PTY bytes. Client → server bytes are written to the shell's stdin; server → client bytes are shell stdout/stderr.
  • Text frames — JSON control messages.

Client → server control messages:

Prop

Type

Server → client control messages:

Prop

Type

Errors

error frames carry a machine-readable code: ssh_auth, ssh_connect, server_not_found, max_sessions, idle_timeout, session_cap, resume_failed, or server_error. The accompanying close code is in the application 4xxx range.

4401 — unauthorized

No valid ticket and no usable session cookie, no resolvable active organization, or the ticket's serverId didn't match the path. Mint a fresh ticket (they're single-use and short-lived) and reconnect.

4403 — origin not allowed

The upgrade's Origin header isn't in the trusted-origins allowlist. WebSocket terminals are expected to be opened from the dashboard origin.

4404 — server not found

The :serverId doesn't exist in your organization, or the caller lacks server:admin on it. The two are reported identically so server existence can't be probed across tenants.

4429 — too many active sessions

You've hit the per-user concurrent-terminal cap. Close an existing session (or let a parked one time out) before opening another. Resumes don't count against the cap.

SDK and CLI

The remote SDK opens the existing ticket and WebSocket workflow:

import { OpenshipClient } from "openship";

const client = new OpenshipClient({
  baseUrl: "https://ship.example.com",
  token: process.env.OPENSHIP_TOKEN,
});
const session = await client.terminal.openServer("srv_123", {
  origin: "https://ship.example.com", // Trusted dashboard origin for Node callers.
  onData: bytes => process.stdout.write(bytes),
});
session.resize(120, 40);
session.write("pwd\nexit\n");
// session.close() explicitly ends the remote shell.
const exit = await session.closed;

The API token is sent only to the HTTP ticket endpoint. The WebSocket uses a one-use ticket in its subprotocol; credentials are never added to its URL. An explicit resume does not open a replacement shell if resumption fails. Browser callers use their browser's own Origin header.

Use openship server ssh <id> or openship server terminal <id> interactively. The CLI uses the saved dashboard origin; override it with --origin when necessary. Use server exec for noninteractive scripts.

Operations

OperationSDKREST API
Mint a one-shot ticket for a WebSocket handshake against a server.terminal.openServer(id, options)
Remote client
POST /api/terminal/ticket
terminal:write
WebSocket upgrade that opens the remote PTY.terminal.openServer(id, options)GET /api/terminal/ws/:serverId
Handler authentication

On this page