Interactive Terminal

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.

Requirements

GET /v2/sandbox should report an available capabilities.exec.pty.

GET /v1/capabilities should report exec with pty.

Create a session, then attach

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:

Python
TypeScript
BASE_URL = "http://127.0.0.1:18091"
WS_BASE_URL = "ws://127.0.0.1:18091"
sb = Aio(BASE_URL)

session_id = sb.post("/v2/pty/sessions", id="demo-1")["session_id"]
WS_URL = f"{WS_BASE_URL}/v2/pty/sessions/{session_id}/ws"
print(session_id)
# demo-1

The id is yours to choose, so a caller that already has a name for the work can address the terminal by it.

Python
TypeScript
BASE_URL = "http://127.0.0.1:18091"
WS_BASE_URL = "ws://127.0.0.1:18091"
sb = Aio(BASE_URL)

session_id = sb.post("/v1/shell/sessions/create")["session_id"]
WS_URL = f"{WS_BASE_URL}/v1/shell/ws?session_id={session_id}"
print(session_id)
# 3c2a99fd-c16c-408b-9298-5126e1d00def

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:

session_id, command, status, output, console, exit_code

REST and WebSocket share one shell, so a file created through REST is visible to ls in the attached terminal.

Attach a client

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:

Python
TypeScript
import asyncio, json, websockets

async def main():
    # Append &api_key=<key> to WS_URL when AIO_API_KEY is set.
    async with websockets.connect(WS_URL) as ws:
        await ws.send(json.dumps({"type": "input", "data": "uname -s\n"}))
        async for raw in ws:
            msg = json.loads(raw)
            if msg["type"] in ("output", "restore_output"):
                print(msg["data"], end="", flush=True)

asyncio.run(main())
# ~ $ uname -s
# Darwin
# ~ $

Messages

{"type":"ready","session_id":"…","transport":"json","backend":"tmux","resumed":false}
{"type":"input","data":"ls\n"}
{"type":"resize","cols":120,"rows":40}
{"type":"ping","timestamp":<t>}
{"type":"output","data":"…"}
{"type":"restore_output","data":"…"}
{"type":"pong","timestamp":<t>}
{"type":"error","data":"…"}
DirectiontypeMeaning
← recvreadyFirst frame after the upgrade
→ sendinputKeystrokes or command text
→ sendresizeResize the PTY
→ sendpingOptional keep-alive
← recvoutputTerminal output, ANSI included
← recvrestore_outputBuffered history replayed on reattach
← recvpongReply to a ping
← recverrorAttach 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 connection without a session

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:

{"backend":"native","resumed":false,"session_id":"afc14051-206a-4640-82c7-c46119cbaa3b","transport":"json","type":"ready"}
{"data":"\u001b[H\u001b[2J~ $ ","type":"output"}

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:

{"data":"a0bcaea7-16fe-4439-8c9f-45516c81a32e","type":"session_id"}
{"backend":"native","resumed":false,"session_id":"a0bcaea7-16fe-4439-8c9f-45516c81a32e","transport":"json","type":"ready"}
{"data":"\u001b[H\u001b[2J~ $ ","type":"output"}

Backend and limits

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.

Survive a disconnect

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:

Python
TypeScript
import asyncio, json, websockets

url = f"{WS_BASE_URL}/v2/pty/sessions/{session_id}/ws?durable=true"

async def main():
    first = await websockets.connect(url)
    await first.send(json.dumps({
        "type": "input",
        "data": "for i in 1 2 3 4 5; do echo tick $i; sleep 1; done\n"}))

    # Two ticks in, the client drops. The loop keeps running.
    await asyncio.sleep(2.5)
    await first.close()
    await asyncio.sleep(3.5)

    async with websockets.connect(url) as second:
        for _ in range(3):
            print(await second.recv())

asyncio.run(main())
sb.delete(f"/v2/pty/sessions/{session_id}")
Python
TypeScript
import asyncio, json, websockets

url = f"{WS_BASE_URL}/v1/shell/ws?session_id={session_id}&durable=true"

async def main():
    first = await websockets.connect(url)
    await first.send(json.dumps({
        "type": "input",
        "data": "for i in 1 2 3 4 5; do echo tick $i; sleep 1; done\n"}))

    # Two ticks in, the client drops. The loop keeps running.
    await asyncio.sleep(2.5)
    await first.close()
    await asyncio.sleep(3.5)

    async with websockets.connect(url) as second:
        for _ in range(4):
            print(await second.recv())

asyncio.run(main())
sb.delete(f"/v1/shell/sessions/{session_id}")

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.