# Web terminal

> Open an interactive login shell in a running machine from the console or any WebSocket client, and handle its messages, limits, and close reasons.

The web terminal is an interactive login shell in a running machine, carried over a WebSocket. It is full machine access, so it is allowed exactly like [running a command](/docs/guides/commands): Owner, Admin, or Operator members, and API keys with the `use_existing` preset or higher. Viewer and Billing members get `403 forbidden`; a `read_only` key gets `403 insufficient_scope`.

Each connection starts a new shell. Closing the tab, reconnecting, or losing the network ends that shell and whatever it was running in the foreground; run `tmux` or `screen` in the machine for sessions that should outlive a connection, or start long jobs as [background commands](/docs/guides/background-commands).

## In the console

Open a machine, choose the **Terminal** tab, and pick **Shell**. **Connect** opens a terminal sized to the panel; the status line shows who you are signed in as on the machine. The terminal follows the light or dark theme, resizes with the window, and has **Copy**, **Paste**, and font-size controls. **Disconnect** ends the shell; **Reconnect** opens a new one. If the machine isn't running, the panel offers to start it.

The console never records what you type or what the shell prints.

## Connect from a browser

Browsers can't put headers on a WebSocket, so a session first mints a single-use ticket:

```sh tab="curl"
curl -X POST "$VMYARD_API_URL/v1/machines/$MACHINE_ID/terminal/tickets" \
  -b "vmyard_session=$VMYARD_SESSION" \
  -H "x-csrf-token: $VMYARD_CSRF" \
  -H "X-Organization-ID: $VMYARD_ORGANIZATION_ID"
```

```json title="Response 201"
{
  "ticket": "vyt_…",
  "expires_at": "2026-10-12T12:00:30Z",
  "url": "/v1/machines/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f/terminal?ticket=vyt_…"
}
```

The ticket works once, within 30 seconds, for that machine and the session that minted it. Add the initial size to `url` and connect with the session cookie:

```js tab="JavaScript"
const socket = new WebSocket(
  `wss://api.example.com${ticket.url}&cols=${cols}&rows=${rows}`,
);
socket.binaryType = "arraybuffer";
```

The `Origin` of the page must be the console's configured address (`ui_base_url`), or the upgrade is refused with `403 origin_not_allowed`. An expired, used, or foreign ticket is `403 terminal_ticket_invalid`; mint a new one.

## Connect with an API key

Scripts skip the ticket and send the key on the upgrade request. Any WebSocket client works, for example [websocat](https://github.com/vi/websocat):

```sh tab="websocat"
websocat --binary \
  -H "Authorization: Bearer $VMYARD_API_KEY" \
  "wss://api.example.com/v1/machines/$MACHINE_ID/terminal?cols=120&rows=40"
```

An `Origin` header, if sent, must still be allowed. `cols` and `rows` are 1–1000 and default to 80×24; other values are `422`.

## Messages

| Direction | Kind | Content |
| --- | --- | --- |
| Server → client | Text | `{"type":"opened","terminal_id","user"}`, sent first; `user` is the login on the machine. |
| Server → client | Binary | Terminal output, at most 64 KiB per message. |
| Client → server | Binary | Keystrokes and pasted text (UTF-8), at most 64 KiB per message. |
| Client → server | Text | `{"type":"resize","cols","rows"}` (1–1000 each). |
| Client → server | Text | `{"type":"signal","signal"}`: `hup`, `int`, `quit`, `kill`, `term`, `usr1`, `usr2`, `cont`, `stop`, `tstp`, or `winch`, sent to the foreground process group. |
| Client → server | Text | `{"type":"close"}` ends the shell. |
| Server → client | Text | `{"type":"exit","exit_code","signal"}`: the shell ended with that status, or was killed by that signal. |
| Server → client | Text | `{"type":"closed","reason"}`: the terminal ended without the shell's status. |

`exit` and `closed` are final: the server then closes the WebSocket with code 1000 (1008 after an invalid message, 1001 when the server is shutting down). Any other text message from the client ends the terminal as `invalid_message`.

Send a resize when the visible size changes, not on every pixel: the console waits for the size to settle for 150 ms. Send keystrokes as binary messages; a text message is always parsed as control.

## Keepalive and idle time

The server pings every 15 seconds and ends a terminal whose client sent nothing, not even a pong, for 45 seconds (`client_lost`). Browsers answer pings on their own. A terminal with no input and no output for the deployment's idle timeout (30 minutes by default) ends as `idle_timeout`.

Output is acknowledged to the machine only as it is delivered, so a slow reader pauses the program in the machine rather than losing output.

## Limits

| Limit | Default | Answer |
| --- | --- | --- |
| Open terminals per user in an organization | 4 | `429 terminal_limit_reached` |
| Open terminals per organization | 8 | `429 terminal_limit_reached` |
| Opens per user per minute | 20 | `429 rate_limited` with `Retry-After` |
| Terminals per machine, and per host | 16, 64 | `409 terminal_refused` |

Operators can change the first three. Close terminals you no longer use; a tab left open keeps its terminal counted until it closes.

## Close reasons

| `reason` | Meaning |
| --- | --- |
| `exit` | The shell exited. |
| `client_closed` | The client sent `close` or closed the WebSocket. |
| `client_lost` | The client sent nothing for 45 seconds. |
| `idle_timeout` | No input or output for the idle timeout. |
| `invalid_message` | The client sent a text message that isn't a control message. |
| `server_shutdown`, `replica_lost` | The API server restarted or stopped. Reconnect. |
| `channel_lost`, `host_lost`, `peer_lost`, `shutdown` | The machine's host became unreachable or restarted. |
| `sandbox_sleeping`, `sandbox_destroyed` | The machine stopped or was deleted. |
| `revoked` | The organization was [suspended](/docs/guides/billing#suspended-organizations). Reconnecting is refused with `403 organization_suspended` until the suspension is lifted. |
| `replaced`, `fenced`, `identity_reset`, `invalid_frame`, `upgrade_failed` | The connection to the machine was reset. Reconnect. |

Every open and close is recorded in the machine's audit log with who opened it, its size, how long it lasted, and why it closed. Keystrokes and output are never stored.

## Errors

| Status | Code | What to do |
| --- | --- | --- |
| 403 | `forbidden` | Ask an Owner or Admin for the Operator role. |
| 403 | `insufficient_scope` | Use a key with the `use_existing` preset or higher. |
| 403 | `session_required` | API keys don't mint tickets; connect with the key directly. |
| 403 | `origin_not_allowed` | Open the console from its configured address. |
| 403 | `terminal_ticket_invalid` | Mint a new ticket and connect within 30 seconds. |
| 409 | `machine_not_running` | Start the machine and wait until it is running. |
| 409 | `terminal_refused` | The machine or its host has too many terminals. Close some. |
| 429 | `terminal_limit_reached` | Close another terminal first. |
| 429 | `rate_limited` | Wait `Retry-After` seconds. |
| 503 | `terminals_unavailable` | This deployment has no runtime for terminals. |
| 503 | `runtime_unavailable` | The machine's host can't be reached. Retry shortly. |

See [Errors](/docs/guides/errors#web-terminal) for the problem format.
