This example follows a command that needs ongoing interaction: create a terminal session, attach to its PTY, run a command, send input, then reattach after the socket drops.
GET /v2/sandbox should report an available capabilities.exec.pty.
GET /v1/capabilities should report exec with pty.
A socket attaches to a session that already exists; opening one without naming a session gets a shell that is reclaimed on idle. Create the session first to keep it — durable and restore also need an explicit id and answer 400 without one:
The id is yours to choose, so a caller that already has a name for the work can address the terminal by it.
The daemon mints the id; there is no way to ask for one.
A command does not need a socket connection.
POST /v2/pty/sessions/{id}/exec {command} runs the command in the same terminal.
POST /v1/shell/exec {command, id} runs the command in the same terminal.
The response includes:
REST and WebSocket share one shell, so a file created through REST is visible to ls in the attached terminal.
Both routes speak the same messages, so one client covers either — it sends a command, prints what comes back, and runs until you stop it:
| Direction | type | Meaning |
|---|---|---|
| ← recv | ready | First frame after the upgrade |
| → send | input | Keystrokes or command text |
| → send | resize | Resize the PTY |
| → send | ping | Optional keep-alive |
| ← recv | output | Terminal output, ANSI included |
| ← recv | restore_output | Buffered history replayed on reattach |
| ← recv | pong | Reply to a ping |
| ← recv | error | Attach or session failed |
Include \n in input to run a line. Non-JSON text is treated as raw input. cols and rows may also sit under data.
protocol=binary switches the data frames only: a binary frame is raw PTY bytes in either direction, and a text frame is still a JSON control message — ready, resize, ping, pong.
A socket opened without a session id gets one anyway, and the two routes differ in what they say about it:
GET /v2/pty/ws opens a shell that lives exactly as long as the socket. It is not addressable afterwards, so nothing announces an id — ready carries one only because every ready does, and the paint of the empty screen follows straight after:
GET /v1/shell/ws without session_id mints a session that outlives the socket and is reclaimed on idle, so the id is worth having: it arrives in a session_id frame ahead of ready, before the paint of the empty screen:
AIO_SHELL_BACKEND=auto (default) uses tmux when present, otherwise a native PTY. Sessions survive a daemon restart only under tmux. native pins the native PTY.
cd and environment changes persist for the life of a session. Limits: 20 concurrent sessions, 3600 s idle timeout (AIO_SHELL_MAX_SESSIONS, AIO_SHELL_SESSION_TIMEOUT_SECS).
A session created with an id stays until it is deleted:
DELETE /v2/pty/sessions/{id}: deletes the session.DELETE /v1/shell/sessions/{session_id}: deletes the session.durable=true keeps the terminal's half of the attachment alive while no client holds it, and lets the next socket take the terminal over. Start something slow, drop the socket, reconnect:
The reattach ends with one frame more than the reconnect produced: attaching to an id the caller already had makes the daemon nudge the foreground job to repaint, so a full-screen program draws itself again instead of leaving the renderer with a fragment. At a bare prompt the nudge shows up as the reprinted prompt line above.
The second socket gets resumed: true and a relay_resumed frame, then only the output produced while nothing was attached. restore=true asks for a bounded snapshot of the whole buffer instead, sized by replay_bytes — 10 MiB by default, clamped to 256 KiB…10 MiB. Retained output lives in the daemon's memory. Only the tmux backend keeps the shell process across a daemon restart.