Saphan StudioDocs
The console

The live terminal

Opening a terminal on this host or on another seat, the wire beneath it, and what closing the tab does.

This page describes the terminal endpoint of the previous console, served by the projection server (saphan server start, or the saphan-server daemon). It is an endpoint, not a screen: the previous console draws no terminal, so you drive it with your own websocket client.

⚠ The current console's terminal is different. Its dock opens the saphan CLI on the host that serves the console, with that server's user and workspace — not a shell, and never on another machine. Its endpoint opens a terminal on its own host only; a ?machine= naming another machine is refused there.

Before you open one

The terminal is behind the console session — the same one the queue at /console is behind. So, in order:

  1. A credential exists for this workspace. In a shell on the host:

    saphan console admin status

    If it says the console is not provisioned, set one. The verb asks for the password on your terminal, twice, with echo off — it is never typed on a command line:

    saphan console admin set --user <name>

    Then restart the server (saphan server stop, then saphan server start): the console credential is resolved once per process. Signing in is the full first-login procedure.

  2. You are logged in. Open /console/login in the browser and sign in. The terminal is not a second login; it rides the session you already have.

  3. You reach the console at the address the browser shows. The terminal refuses a handshake whose Origin — scheme and host — is not this console (see the refusals below).

Opening a terminal on this host

ws://<console address>/console/pty
wss://<console address>/console/pty        (when the console is served over TLS)

Optional query parameters:

parametermeaningdefault
machinelocal (or omitted) for this host; otherwise a machine idlocal
colsinitial window width, 1–100080
rowsinitial window height, 1–50024

The terminal starts in the workspace root, running $SHELL — the SHELL of the process that started the server (/bin/sh if it has none), not anything the browser sends. It sees only TERM=xterm-256color and the server's HOME, PATH, LANG and USER.

Opening a terminal on another seat

ws://<console address>/console/pty?machine=fleet-6

The machine id is the one saphan machine list prints. It is dialled through the engine's own ssh configuration at ~/.saphan/ssh/config — never through your personal ~/.ssh/config — so a machine must have been admitted first:

saphan machine admit …          # this is what writes ~/.saphan/ssh/config
saphan machine list             # this is where the id comes from

There is nothing else to configure. The identity, hostname, port and host-key pin all come from that file, and the connection is made by the same hardened ssh invocation every other fleet dial uses (BatchMode=yes, so an unreachable machine fails fast and says so inside the terminal rather than hanging).

A terminal on another machine is planned like any other session there. Unless your configuration turns recording off, a recorder runs on this host and is reached from the far terminal through an ssh reverse tunnel; a claude-code CLI started in that terminal is pointed at it. ⚠ Only claude-code is pointed at the recorder — codex and every other vendor run in that terminal unrecorded. The first notices the terminal prints say which applies, and where the recording goes. If the configuration cannot be read, or the session is covered by the guardrail and the guardrail cannot run, the terminal is refused with guardrail-unavailable and nothing starts. See Session recording and Guardrails.

The wire, if you are writing a client

Every websocket message, in both directions, is one tag byte followed by a payload. Terminal output is arbitrary bytes and is not valid UTF-8, so everything travels as a binary frame and the tag — not the frame type — says what a message is.

directiontagpayload
browser → engine0x00keystrokes, verbatim
browser → engine0x01{"cols":120,"rows":40} — resize
engine → browser0x00terminal output, verbatim
engine → browser0x02{"notice":"…"} — a sentence for the human
engine → browser0x03{"code":0,"success":true} — the program exited, with its exit status, when it is known

A message with any other tag is dropped, and a frame is at most 64 KiB. Send a 0x01 frame whenever the browser's terminal element changes size; on a remote terminal the far side resizes too, without any further act.

What closing the tab does

Closing the browser tab closes the socket, and closing the socket reaps the whole session the terminal started — not just the shell. In order:

  1. the shell's process group is hung up (SIGHUP), so a shell that forwards a hangup gets to end its own jobs the way it would on a real tty;
  2. a quarter of a second passes;
  3. the shell's process group is killed;
  4. the session is swept: every process still in the terminal's session is found and killed, whatever process group it wandered into;
  5. the shell is waited for, so no defunct entry is left behind.

Step 4 is the one that matters. sleep 300 & typed into a console terminal does not survive the tab closing, although an interactive shell puts each background job in a process group of its own — that is what job control is — so killing the shell's group alone would never reach them.

One named limit remains, and it is the same one a real terminal has: a process that calls setsid(2) for itself has left the session and cannot be reached by anything addressing that session. That is what nohup and setsid are for, and it behaves here exactly as it does on a tty.

Step 4 waits, and it says so when it gives up. The sweep does not stop at the first pass that finds nothing to kill; it stops when it has also accounted for the terminal's own shell, because "I saw nothing" and "I could not see" are the same empty answer and only one of them is a successful reaping. (On macOS a dying process can briefly be invisible to a session lookup before the process table marks it finished; the wait covers that.)

The wait is on that state, not on a timer, and it is bounded at two seconds. Every other reason the sweep can fail to account for the shell is decided at once, so an ordinary close is not slowed and a genuinely blind host does not stall. If the bound is reached, the close returns

the session could not be enumerated on this host (not even its own leader was visible), so nothing was reaped and the no-orphan guarantee does NOT hold for this terminal

with the state it was still looking at and the bound it gave up at appended. Read that sentence literally: processes from that terminal may still be running, and ps is the instrument, not this message's absence.

Which machines a terminal will open on

?machine=<id> is resolved against the signed machine registry before any ssh command line is composed. The id must be:

  • admitted — present in the registry (saphan machine list);
  • not retired — retirement is a mark on the row, so an id can be in the registry and still refused;
  • ssh-dialable — transport=ssh, with an ssh host and an ssh key, which is exactly the condition under which ~/.saphan/ssh/config carries a Host <id> stanza for it.

Why this is a refusal and not a convenience. ssh -F <config> does not restrict ssh to the aliases in that file; it only says where to look for them. An unmatched target is treated as a hostname and resolved through DNS. So an id that merely looks like a registry name — example.invalid, prod.example.com — would have become an outbound connection to whatever the host's resolver returned, with the fleet's own hardening attached to it. The registry lookup is what makes the promise above — identity, hostname, port and host-key pin all come from the engine's own ssh configuration — true rather than merely intended.

?machine=local is unaffected and needs no registry: the near path opens $SHELL on this host and never composes an ssh dial.

Which seats a terminal will open on

The three checks above are about the host. Two more are about who is seated on it, and they are asked from the same registry read, before any ssh command line is composed:

  • no master seat. A machine carrying a seat registered role=master is refused. The rule is the role, never a machine name: a second master seat enrolled tomorrow is refused with nobody editing any code.

  • no seat whose lane the ledger reads as spent. The run ledger's own status=lane-exhausted rows are folded with the same rule, and the same default 5-hour window, that the scheduler's own placement filter uses. A later status=ok run clears it immediately — a seat that recovered is not refused.

    A cooldown belongs to the SEAT, not to the slot — and that is a decision, not a detail. A lane-exhausted row records that a vendor allowance ran out, and an allowance is attached to a credential (a config_dir profile or a lane), which travels with the identity. A slot is furniture: machine seat is latest-wins per identity, so you may legally move one identity from slot 0 to slot 1 of the same machine and seat someone else on the slot it vacated, and neither act refills an allowance or spends one. So the fold reads the ledger's identity column, and the consequences are the ones you would want: a seat you re-seat onto another slot takes its cooldown with it, the seat that arrives on the vacated slot does not inherit it, and a later ok run clears an exhaustion even when the two rows sit on different slots.

    The slot still matters, for the rows that can say nothing else. A ledger row's identity is empty on every row written before that column existed and on every run that named no seat, and it is never backfilled — so nothing may claim retroactively whose credential such a row spent. The only thing it is evidence about is the slot it reserved, so an unattributed row refuses whoever is seated on that slot today. That is the fail-closed side, and it is the one place where two seats sharing a slot are refused together.

These two refusals say different things on purpose. Your next act differs:

refusalwho clears ithow
master seatyou, by an owner actre-seat the identity with role=executor — the runbook below, which is a runbook rather than a one-liner for a reason it states
spent lanetimewait. There is nothing to run and nothing to repair; saphan fleet usage asks the vendor how long

Five limits, named rather than left to be discovered:

  1. A machine with no seat at all is not refused by these two. They read seat rows; a host nobody is seated on has no master seat and no spent lane. It still had to be admitted, non-retired and ssh-dialable to get this far.
  2. The unit is the machine, which is wider than the seat. ?machine= names a host, not a seat, so a host carrying four executor seats and one master seat is refused whole. That is the fail-closed direction and the only one a host-addressed parameter can take.
  3. The 5-hour window here is the default, always. The per-workspace scheduler.exhausted_window_hours override is not wired into this gate (it loads no config), so on a workspace that has set that key this gate's window is the default while the scheduler's is the override.
  4. A seat's cooldown is read PER MACHINE, not across the fleet. The two ledger reads are both narrowed on the machine you named, so if one identity somehow carries rows on two machines, each host answers only for its own. The registry's cross-machine guard makes moving an identity between machines an owner act of its own rather than an ordinary re-seat, and widening the read would let one host's exhaustion take the shell away on another. This is the fail-open direction of the pair, which is why it is named here.
  5. A cooldown follows the seat's REGISTRY LABEL, so an owner act that re-labels a seat moves its cooldown with the label. The paragraphs above say a cooldown belongs to the seat rather than the chair, and the seat is identified by the identity string on the ledger row — which is the registry row's own subject, an operator-assigned name set by an owner act, not a fingerprint of the credential behind it. Two consequences follow, and both need an owner act on a signed registry row, which is why they are a named limit rather than an open door: re-seating the same credential under a new name presents a seat with no cooldown history, and re-using a retired name for a different credential presents a seat that inherits the old one's. This is the fail-open direction in one case and the fail-closed direction in the other, and neither is reachable from the console: the console only reads this key.

Why a spent seat is refused although an ssh login spends no model allowance: the work a terminal is opened to do on such a seat dies lane-exhausted after burning the wall clock. ?machine=local and every non-spent seat are unaffected.

Runbook — clearing a master role off a seat

This is the owner act the master-seat refusal above sends you to. It is your hand, not the engine's: machine seat writes a signed identity row, and only the owner's root key can sign one.

Read this before you type. machine seat is latest-wins on the whole row, not a patch on one column. Every field you omit is not "left alone" — it is re-derived from that flag's own default and written back. Omit --backend and the new row says claude-code; omit the credential flag and the profile home is regenerated from the naming convention; omit --billing-class and the seat's economic classification lands as unknown. So on a Codex or lane-bound seat, a one-liner that names only --role changes the seat's credential shape and its billing metadata while you were trying to change one word. The steps below exist to make that impossible, and step 1 is not optional.

1. Read the row you are about to replay. Nothing here is remembered or guessed — the seat's own row is the input to step 2.

saphan --workspace <workspace> machine list --json

Find your machine, then the seat under its identities array, and copy down four values verbatim: slot_index, backend, billing_class, and — whichever one is present — config_dir or lane. A lane-bound backend (agent-loop, openai-compat) carries lane and an empty config_dir; every other backend carries config_dir and an empty lane. Naming the wrong one of the two is refused, which is the guard doing its job.

2. Replay the row with every field spelled out, changing only --role. Two shapes, because the credential flag differs — use the one matching the backend you read in step 1.

A config-dir seat (claude-code, codex):

saphan --workspace <workspace> machine seat --identity <identity> --machine <machine> --slot <slot> --role executor --backend <backend> --config-dir <config-dir> --billing-class <billing-class> --added-by <you>

A lane-bound seat (agent-loop, openai-compat):

saphan --workspace <workspace> machine seat --identity <identity> --machine <machine> --slot <slot> --role executor --backend <backend> --lane <lane> --billing-class <billing-class> --added-by <you>

--billing-class is the one field with a hole in it, and it is named rather than left to surprise you. unknown is not a value you may pass — the flag takes subscription | metered | local-energy and refuses anything else. If step 1 read billing_class: unknown, drop the whole --billing-class flag: an omitted class writes an empty field, and an empty field reads back as unknown. That is the only spelling that preserves an unclassified seat.

--added-by is you, the owner performing this act, not whoever seated the row originally — the identity store is an append-only record of acts, and this is a new one. --added-at defaults to now, which is what you want; the original seating instant stays in the superseded row.

3. Read it back and confirm exactly one thing moved.

saphan --workspace <workspace> machine list --json

The seat's role now reads executor. slot_index, backend, billing_class, config_dir and lane must read exactly what you copied in step 1. If any of them moved, the row you just signed is not the row you meant, and step 2 is where a flag was omitted.

What you will not need. --move-from is for a cross-machine move only: re-seating an identity on its own (machine, slot) is not a move and is never refused for the want of it.

⚠ This runbook covers the fields machine seat takes as flags. A value the row carries that the verb has no flag for — an account set with saphan machine set-account, for example — is not checked by it. Compare the whole row from step 1 with the row from step 3, not only the five fields named above.

On this page