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.