• English
  • Commands (Bash)

    A command is a fresh process. Each call spawns one, and tears it down when it exits or times out. No shell is left running afterward.

    A command session persists only the working directory, the run-as identity, any environment it was created with, and retained output between calls.

    For a live shell that stays attached and open, use Terminals instead. The v1 and v2 routes side by side are in Migration from 1.x.

    Requirements

    Call GET /v1/capabilities first to see the command capabilities available on the host. The relevant fields are exec and bins:

    curl "$BASE_URL/v1/capabilities"

    Relevant fields look like this:

    {
      "exec": {
        "shell": true,
        "bash": true,
        "pty": true
      },
      "bins": {
        "bash": "/bin/bash"
      }
    }

    Run a command

    curl -X POST "$BASE_URL/v2/commands" \
      -H "Content-Type: application/json" \
      -d '{"command": "pwd && ls -la", "cwd": "/workspace", "timeout": 30}'

    Request body:

    {
      "command": "pwd && ls -la",
      "cwd": "/workspace",
      "timeout": 30
    }

    The body takes these fields:

    FieldValuesMeaning
    commandstring, requiredThe script to run; with shell: "none", the program path
    argsarrayargv for shell: "none"; rejected in shell modes
    shellauto, bash, sh, powershell, cmd, noneWhich shell wraps the command; auto is the platform default
    cwdstringWorking directory
    envobjectPer-command variables; they override the session's
    userstringAccount to run as; fixed on the session at first use; Linux only
    sessionstringSession to run in; absent means a generated one
    timeoutsecondsHow long the call waits; absent waits for the command to end
    hard_timeoutsecondsWhen the process is killed; absent never kills it
    max_output_lengthcharactersWhat this response carries, 50000 by default; 0 is unlimited
    modesync (default), asyncasync returns at once with a command_id
    curl -X POST "$BASE_URL/v1/bash/exec" \
      -H "Content-Type: application/json" \
      -d '{"command": "pwd && ls -la", "exec_dir": "/workspace", "timeout": 30}'

    Request body:

    {
      "command": "pwd && ls -la",
      "exec_dir": "/workspace",
      "timeout": 30
    }

    The body takes these fields:

    FieldValuesMeaning
    commandstring, requiredThe script to run
    session_idstringSession to run in; created on first use
    exec_dirstringWorking directory
    envobjectPer-command variables
    userstringAccount to run as; AIO_DEFAULT_USER, then aiod's own account, when absent
    async_modebooleanReturn at once with status: "running"
    timeoutsecondsHow long the call waits; absent waits for the command to end
    hard_timeoutsecondsWhen the process is killed; absent never kills it
    max_output_lengthcharactersWhat this response carries, 50000 by default; 0 is unlimited

    When you want to pick the shell, or run a program without one (shell: "none" with args), switch to v2:

    data carries the command and its result either way:

    • session_id, command_id — the session the command ran in and the command's own id
    • statusrunning, completed, or timed_out
    • stdout, stderr — each stream on its own, null on v1 while empty; output is stdout followed by stderr
    • exit_codenull until the command ends; -1 after a kill or a hard timeout
    • offset, stderr_offset — bytes of each stream produced so far

    Past max_output_length the middle of each stream is replaced by \n... output truncated ...\n, while offset still counts everything the command produced. Only this response is shortened; a later read returns the stream whole.

    Stream output as it is produced

    Send Accept: application/x-ndjson to receive one JSON object per line instead of a final result:

    curl -N -X POST "$BASE_URL/v2/commands" \
      -H "Content-Type: application/json" \
      -H "Accept: application/x-ndjson" \
      -d '{"command": "echo one; echo two >&2; exit 3"}'
    curl -N -X POST "$BASE_URL/v1/bash/exec" \
      -H "Content-Type: application/json" \
      -H "Accept: application/x-ndjson" \
      -d '{"command": "echo one; echo two >&2; exit 3"}'

    The frames for that command:

    {"type":"started","session_id":"SESSION_ID","command_id":"COMMAND_ID","seq":1}
    {"type":"output","stream":"stdout","data":"b25lCg==","offset":0,"seq":2}
    {"type":"output","stream":"stderr","data":"dHdvCg==","offset":0,"seq":3}
    {"type":"exit","status":"completed","exit_code":3,"stdout_offset":4,"stderr_offset":4,"seq":4}
    • data — base64 of the raw bytes
    • offset — the chunk's position in its stream
    • seq — the server's frame order

    A gap frame carries lost_from and resume_offset when retention trimmed output past the reader's position; a ping frame follows 30 s without one. The stream ends with exit or error. Closing the connection kills a command started in sync mode, and leaves an async one running.

    Routes

    RoutePurposeNotes
    POST /v2/commandsRun a commandmode: "async" returns a command_id at once
    GET /v2/commands/{id}Read a command's outputoffset, stderr_offset, wait, wait_timeout
    POST /v2/commands/{id}/stdinWrite to the command's stdininput goes in verbatim; no newline is added
    POST /v2/commands/{id}/killSignal a running commandSIGTERM by default, SIGKILL or SIGINT
    POST /v2/commands/sessionsCreate a sessionid, cwd, env, user
    GET /v2/commands/sessionsList the live sessionsDirectory, command count, last use
    DELETE /v2/commands/sessions/{id}Close a sessionEnds its commands, SIGTERM then SIGKILL
    RoutePurposeNotes
    POST /v1/bash/execRun a commandasync_mode: true returns at once
    POST /v1/bash/outputRead a session's outputsession_id required, command_id optional
    POST /v1/bash/writeWrite to the command's stdininput goes in verbatim; no newline is added
    POST /v1/bash/killSignal a running commandsignal, and command_id when several run
    POST /v1/bash/sessions/createCreate a sessionsession_id, exec_dir, user, snapshot_path
    GET /v1/bash/sessionsList the live sessionsDirectory, command count, last use
    POST /v1/bash/sessions/{id}/closeClose a sessionEnds its commands, SIGTERM then SIGKILL

    Reading is incremental, and the fields are the same on both:

    • offset, stderr_offset — where to start in each stream; the response's own offset and stderr_offset go into the next read
    • wait — long-poll: the read returns as soon as new output arrives, the command ends, or wait_timeout (30 s by default) elapses. Without it the read returns whatever is already there
    • stdout_start_offset, stderr_start_offset — the earliest position still retained. Each stream keeps 10 MiB and is cut back to 5 MiB past that
    • stdout_gap, stderr_gaptrue when the requested offset is older than what is retained, so the bytes in between are gone
    • command{command_id, command, status, exit_code}

    A session addresses its last 100 commands; past that the oldest ids are dropped and answer 404. input is capped by the 2 MiB JSON body limit, and reaches stdin exactly as sent — a line-buffered program waits for the \n.

    Timeouts and status

    FieldAbsentPresent
    timeoutThe call waits for the command to endThe call returns there with status: "running"; the command runs on
    hard_timeoutThe command runs until it exitsThe process is killed there and status becomes timed_out

    status is the lifecycle, and exit_code only means something once the command has ended:

    statusThe commandexit_code
    runningStill running, addressable by command_idnull
    completedExited on its own, or was killedIts own code, -1 after a kill
    timed_outKilled at hard_timeout-1

    Check status first, then exit_code. The wire contract also carries pending and killed, which no request produces.

    Patterns for an agent

    Most commands finish inside the call. A long one gets a timeout and a second call reads the rest; a program that never exits runs in the background and is killed at the end; an interactive one is fed through stdin.

    ScenarioStartThen
    ls -lasyncRead the result from the same response
    npm installsync with timeoutOn running, read with wait until it completes
    npm run devbackgroundRead the startup log; kill it when done
    python3 -ibackgroundWrite a line, read both streams
    a script with input()backgroundAnswer each prompt, ending with \n

    Sessions

    A cd inside a command does not carry over to the next one: set the directory on the request, or once on a session.

    • A command with no session gets a generated one. It is listed like any other and counts against the limit.
    • Session env is inherited by every command in it, and a per-command env wins.
    • user is fixed the first time a session sees it; a different value later is a 400.
    • Closing a session ends whatever it is running: SIGTERM, then SIGKILL a moment later.
    • On v1, snapshot_path names an existing file that every bash command in the session sources through BASH_ENV; other shells ignore it.
    • Up to 50 sessions at once, each idle-closed after 3600 s (AIO_BASH_MAX_SESSIONS / AIO_BASH_SESSION_TIMEOUT_SECS). At the limit the least recently used idle session is closed to make room, and a create with every session busy is a 400.

    When a session should carry its own environment variables, switch to v2:

    Run in the background

    Suppose the agent has generated a static site under /workspace/site and wants to open it in the sandbox browser before handing it over. The server has to keep running while the pages are checked, so it cannot be a normal command that returns when it exits. A dev server, a watcher, or anything else that never exits on its own is started in the background, read as its output arrives, and killed when it is no longer needed:

    Aio is the envelope-aware helper from Examples: it unwraps data and raises when success is false.

    Python
    TypeScript
    sb = Aio(BASE_URL)
    
    # 1. A session fixes the directory and the environment for every command in it.
    session = sb.post("/v2/commands/sessions", cwd="/workspace/site",
                      env={"PORT": "3000"})
    
    # 2. Start the server in it; async returns as soon as the process is spawned.
    started = sb.post("/v2/commands", command="python3 -u -m http.server $PORT",
                      session=session["session_id"], mode="async")
    command_id = started["command_id"]
    
    # 3. Read what it printed; wait long-polls until output arrives.
    log = sb.get(f"/v2/commands/{command_id}", offset=0, stderr_offset=0,
                 wait=True, wait_timeout=5)
    print(log["stdout"].strip(), "|", log["command"]["status"])
    # Serving HTTP on :: port 3000 (http://[::]:3000/) ... | running
    
    # 4. Stop the command, then drop the session.
    sb.post(f"/v2/commands/{command_id}/kill", signal="SIGTERM")
    sb.delete(f"/v2/commands/sessions/{session['session_id']}")
    Python
    TypeScript
    from agent_sandbox import Sandbox
    
    client = Sandbox(base_url=BASE_URL)
    
    # 1. A session fixes the working directory for every command in it.
    session = client.bash.create_session(exec_dir="/workspace/site").data
    
    # 2. Start the server in it; async_mode returns as soon as it is spawned.
    client.bash.exec(
        command="python3 -u -m http.server 3000",
        session_id=session.session_id,
        async_mode=True,
    )
    
    # 3. Read what it printed; wait long-polls until output arrives.
    log = client.bash.output(
        session_id=session.session_id,
        offset=0,
        stderr_offset=0,
        wait=True,
        wait_timeout=5,
    ).data
    print(log.stdout.strip(), "|", log.command.status)
    # Serving HTTP on :: port 3000 (http://[::]:3000/) ... | running
    
    # 4. Stop the command, then close the session.
    client.bash.kill(session_id=session.session_id, signal="SIGTERM")
    client.bash.close_session(session.session_id)

    After the kill the command ends as completed, with exit_code: -1. Every signal reaches the whole process tree, not just the shell that was spawned; killing a command that has already finished is a 400. One thing to know about pipes: a program that block-buffers stdout when it is not a terminal shows nothing until it flushes or exits; Python is one, which is why the example runs it with -u.

    Interactive programs

    Suppose the agent wants to try expressions one at a time and keep the interpreter's state between them, the way a person works in a Python REPL, or it has to answer a script that stops and asks a question. A REPL or a script that asks questions runs in the background and is fed through stdin. Two facts about pipes decide the shape: a line-buffered program reads nothing until the \n arrives, and prompts often go to stderr, so read both streams.

    Python
    TypeScript
    sb = Aio(BASE_URL)
    
    # 1. Start Python in interactive mode (-i: stdin is a pipe, not a terminal).
    repl = sb.post("/v2/commands", command="python3 -i", mode="async")
    command_id = repl["command_id"]
    
    # 2. The banner and the first prompt arrive on stderr; read them in order.
    banner = sb.get(f"/v2/commands/{command_id}", offset=0, stderr_offset=0,
                    wait=True, wait_timeout=5)
    prompt = sb.get(f"/v2/commands/{command_id}", offset=banner["offset"],
                    stderr_offset=banner["stderr_offset"], wait=True,
                    wait_timeout=5)
    print(repr(prompt["stderr"]))   # '>>> '
    
    # 3. Send a line, then read the result.
    sb.post(f"/v2/commands/{command_id}/stdin", input="1 + 1\n")
    out = sb.get(f"/v2/commands/{command_id}", offset=prompt["offset"],
                 stderr_offset=prompt["stderr_offset"], wait=True, wait_timeout=5)
    print(repr(out["stdout"]), repr(out["stderr"]))   # '2\n' '>>> '
    
    # 4. Leave: the process ends with exit_code 0.
    sb.post(f"/v2/commands/{command_id}/stdin", input="exit()\n")
    Python
    TypeScript
    # 1. Start Python in interactive mode (-i: stdin is a pipe, not a terminal).
    started = client.bash.exec(command="python3 -i", async_mode=True).data
    session_id = started.session_id
    
    # 2. The banner and the first prompt arrive on stderr; read them in order.
    banner = client.bash.output(
        session_id=session_id, offset=0, stderr_offset=0, wait=True, wait_timeout=5
    ).data
    prompt = client.bash.output(
        session_id=session_id,
        offset=banner.offset,
        stderr_offset=banner.stderr_offset,
        wait=True,
        wait_timeout=5,
    ).data
    print(repr(prompt.stderr))   # '>>> '
    
    # 3. Send a line, then read the result.
    client.bash.write(session_id=session_id, input="1 + 1\n")
    out = client.bash.output(
        session_id=session_id,
        offset=prompt.offset,
        stderr_offset=prompt.stderr_offset,
        wait=True,
        wait_timeout=5,
    ).data
    print(repr(out.stdout), repr(out.stderr))   # '2\n' '>>> '
    
    # 4. Leave: the process ends with exit_code 0.
    client.bash.write(session_id=session_id, input="exit()\n")

    A script that calls input("Name: ") is the same loop with one prompt per turn: the read returns stdout: "Name: " while the process blocks, the answer goes in as "Alice\n", and the next read returns Hello Alice. cat and other line-buffered programs want the trailing \n too.

    Run as a user

    user selects the execution identity: the process actually runs as that account. Files use a different model; see File.

    Add user to the /v2/commands request body:

    {
      "command": "id -un",
      "user": "alice"
    }

    v1 also accepts user in the request body at POST /v1/bash/exec:

    {
      "command": "id -un",
      "user": "alice"
    }
    • Omitted: the default identity. With AIO_DEFAULT_USER unset, that is aiod's own account. Setting it changes the default; any other account still requires an explicit user.
    • The account is resolved before the process is spawned. A missing one answers 400 no such user: alice, and a non-root aiod answers 400 cannot run as root: aiod is running as uid 501 and only root can change identity. It never runs silently under another identity.
    • An account that maps to uid 0 is refused, even when aiod is root.
    • A failure that comes from AIO_DEFAULT_USER rather than the request is a 503: the daemon is misconfigured, the call is not.
    • Linux only. On Windows, an explicit user is a 400, and a configured AIO_DEFAULT_USER is a 503.

    Shells

    auto resolves AIO_BASH_BIN first, then /bin/bash, /usr/bin/bash, /bin/sh, /usr/bin/sh on Linux and macOS, and pwsh.exe, powershell.exe on Windows. A named selector never falls back: sh on Windows, cmd off Windows, and a shell that is not installed all answer 503 naming what was missing.

    shell: "none" runs a program directly — command is the path, args is its argv, and nothing is parsed or expanded. args in any other mode is a 400.

    On Windows, shell: "cmd" runs a batch file under cmd.exe /d /c (UTF-8), and shell: "bash" resolves Git Bash: AIO_BASH_BIN, a non-WSL bash on PATH, or Git's install directories. Exit codes, kill semantics, and stdin on Windows are in Windows.

    When you need cmd or Git Bash instead of PowerShell on Windows, switch to v2:

    Error handling

    HTTP success only means the request was accepted; the command's own outcome is status and then exit_code. The failures that belong to this plane:

    AnswerWhen
    400Killing or writing to a command that is not running; args without shell: "none"; a user the daemon cannot switch to; a session that is already busy at the limit
    404An unknown command_id or session_id, or one that retention has dropped
    422A malformed body, with an errors list naming the field
    503No shell for the selected shell, or an AIO_DEFAULT_USER that cannot be used

    413 is the shared 2 MiB JSON body limit; large payloads belong in a file. See Error Handling.