Shell
A bash shell next to the browser, or on its own: run a command, environment variables, a persistent shell, streaming, background jobs, installs, disk sizes, graphics without a GPU, and best practices.
Add "shell": true when you create a session and it gets a bash shell next to the browser, on the same machine and the same /workspace disk. There is one shell machine, with Python (numpy, pandas, requests), Node (TypeScript, tsx, pnpm, yarn), ffmpeg, ImageMagick, poppler-utils and sudo. For a shell without a browser, also pass "browser": false.
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"shell": true}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create({ shell: true });from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create(shell=True)session_create{"shell": true}boxline sessions create --shellSessions with a shell keep running until you stop them or they reach their timeout, even when no browser client is connected.
A shell without a browser
A shell-only session is an isolated machine with a shell and the workspace, and no browser. Everything on this page works in it, and so do files, events and the terminal. It has nothing to record. See the three kinds of session.
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"browser": false, "shell": true}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create({ browser: false, shell: true });from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create(browser=False, shell=True)session_create{"browser": false, "shell": true}boxline sessions create --no-browser --shellliveUrlandconnectUrlarenull, and the calls that need a browser (actions, computer use, scripts, cookie export, the live view) answer409 browser_disabled.- Nothing lands in
/workspace/downloadsby itself, because no browser downloads anything. Fetch files in the shell withcurlorwget. - A session without a shell is the other way round: the calls on this page answer
409 shell_disabled, andterminalUrlisnull.
Run a command
POST/v1/sessions/:id/shell/exec
The command runs once the session’s setup commands have finished. A stopped session answers 409 session_not_running: resume it first. Every field is in the API reference: Run a command.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"command": "ls -la downloads", "timeoutMs": 30000}'const r = await session.exec("ls -la downloads", { timeoutMs: 30_000 });console.log(r.exitCode, r.stdout, r.stderr);r = session.exec("ls -la downloads", timeout_ms=30_000)print(r["exitCode"], r["stdout"], r["stderr"])shell_exec{"sessionId": "SESSION_ID", "command": "ls -la downloads", "timeoutMs": 30000}boxline exec $SESSION_ID -- ls -la downloads # streams the output, exits with the command's exit code| Field | Description |
|---|---|
command | The bash command to run. Required. |
timeoutMs | How long it may run, in milliseconds. Default 120,000, at most 3,600,000. |
cwd | The directory for this command only, inside the workspace. The shell stays where it was. |
env | Variables for this command only. It may set PATH, HOME and the like for itself. |
credentials | Credentials exported for this command only. See Credentials. |
shell | Which persistent shell: "default", any other name for a separate one, or false for a fresh process that keeps nothing. |
stream | true to stream the output as NDJSON. See Streaming output. |
{ "stdout": "report.csv\n", "stderr": "", "exitCode": 0, "timedOut": false, "truncated": false, "durationMs": 41}Environment variables
For the whole session
env on POST /v1/sessions (with shell: true) sets variables for everything in the session’s shell: every command, new terminals, scripts and setup commands.
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"shell": true, "env": {"REGION": "eu-west", "LOG_LEVEL": "debug"}}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create({ shell: true, env: { REGION: "eu-west", LOG_LEVEL: "debug" } });const r = await session.exec('echo "$REGION $LOG_LEVEL"');console.log(r.stdout); // eu-west debugconsole.log(session.data.env); // the names only, never the valuesfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create(shell=True, env={"REGION": "eu-west", "LOG_LEVEL": "debug"})r = session.exec('echo "$REGION $LOG_LEVEL"')print(r["stdout"]) # eu-west debug- Names use letters, digits and
_, do not start with a digit, and are at most 128 characters. Not a name the platform or bash sets:PATH,HOME,PWD,OLDPWD,SHLVL,IFS,ENV,SHELLOPTS,BASHOPTS,PS4,_, or a name starting withBOXLINE_,SANDBOXD_orBASH_. - At most 100 variables. Each value up to 32 KB, and 64 KB together. Numbers and true or false are taken as text.
- They are kept encrypted with the session and in the machine’s memory, and set again on every new machine (after a resume, or when the platform replaces a lost machine). The session shows the names in
env, never the values. - During a platform update, a session on an older machine cannot take them:
409 machine_too_old. Start a new session.
For API tokens and passwords, use credentials instead: credentials: ["GITHUB_TOKEN"] exports a saved secret as $GITHUB_TOKEN, and a password such as SHOP as $SHOP_USERNAME and $SHOP_PASSWORD. For a password with a 2FA code source, boxline-otp SHOP prints the current code (with codes your own system sends, it waits for the next one: see Credentials). The values are hidden in the output.
For one command
cwd, env and credentials on exec apply to that command only. Afterwards the shell is back in its own directory, with its own variables.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"command": "npm run build", "cwd": "app", "env": {"NODE_ENV": "production"}}'const r = await session.exec("npm run build", { cwd: "app", env: { NODE_ENV: "production" } });console.log(r.exitCode);r = session.exec("npm run build", cwd="app", env={"NODE_ENV": "production"})print(r["exitCode"])boxline exec $SESSION_ID --cwd app --env NODE_ENV=production -- npm run buildA persistent shell
Commands run in one persistent bash that starts in /workspace, not a fresh shell each time. cd and export carry over to the next command, as they would in a terminal:
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec -H "x-api-key: $BOXLINE_API_KEY" -H "content-type: application/json" \ -d '{"command": "mkdir -p proj && cd proj && export STAGE=build"}'curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec -H "x-api-key: $BOXLINE_API_KEY" -H "content-type: application/json" \ -d '{"command": "echo \"$(basename \"$PWD\") $STAGE\""}'# {"stdout": "proj build\n", …}await session.exec("mkdir -p proj && cd proj && export STAGE=build");const r = await session.exec('echo "$(basename "$PWD") $STAGE"');console.log(r.stdout); // proj buildsession.exec("mkdir -p proj && cd proj && export STAGE=build")r = session.exec('echo "$(basename "$PWD") $STAGE"')print(r["stdout"]) # proj buildshell: "build" (any name) gives you a second persistent shell with its own state, and shell: false runs a fresh process that keeps nothing. If a shell gets into a bad state, restart it with POST/v1/sessions/:id/shell/restart (body { shell? }, 204), or session.restartShell() (Python: restart_shell()). That stops the shell and everything it started, background jobs too, and starts a fresh one in /workspace with the session’s env and credentials. Files are not affected.
Shell best practices
How the shell behaves, and how to get the most from it. The platform checks each of these on the running service.
Exit codes and stderr
exitCodeis the command’s own ((exit 7)gives 7), andstderris kept apart fromstdout. Check both.exit 4, or a failure underset -e, ends that command with its code. The next command gets a new shell in the same directory, with the same exported variables.- A new shell forgets what was not exported: plain variables, functions, aliases,
setoptions and the jobs table.exportwhat later commands need.
Every command is in the session’s events
Each command the session runs is recorded as an exec event, whoever ran it. The event holds the command (credential values hidden), its exit code and its duration. data.by says who ran it:
data.by | Who |
|---|---|
api | A call to POST /v1/sessions/:id/shell/exec. |
agent | The bash tool of an agent run; data.runId is the run. |
setup | One of the session’s setup commands. |
script | The run of a script. |
The output stays with whoever ran the command: you get it in the answer to your call, and an agent’s output is in the run’s steps. Read or stream the events while the session runs and after it stops: Recordings and events.
Long jobs: background commands, streaming and time limits
- Time limit. A command past its
timeoutMsis stopped with everything it started (timedOut: true,exitCode: null). The shell keeps its directory and exported variables, and background jobs started earlier keep running. - Background jobs. Put anything longer than a minute or two (servers, builds, downloads) in the background, with its output going to a log file:
nohup … > job.log 2>&1 &. The call returns at once and the job keeps running. Readjob.loglater. - Streaming. With
stream: trueyou see output as it comes, and nothing is cut. A streaming caller that disconnects stops its command, as a timeout does.
# Start it. It returns at once, because all its output goes to job.log.curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec -H "x-api-key: $BOXLINE_API_KEY" -H "content-type: application/json" \ -d '{"command": "nohup python train.py > job.log 2>&1 & echo $! > job.pid"}'# Later: the end of the log, with a command or with the files API.curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec -H "x-api-key: $BOXLINE_API_KEY" -H "content-type: application/json" \ -d '{"command": "tail -n 20 job.log"}'curl -G https://api.boxline.dev/v1/sessions/$SESSION_ID/files -H "x-api-key: $BOXLINE_API_KEY" --data-urlencode "path=job.log"await session.exec("nohup python train.py > job.log 2>&1 & echo $! > job.pid"); // returns at once// Later: the end of the log, with a command or with the files API.const { stdout } = await session.exec("tail -n 20 job.log");const log = await session.files.readText("job.log");session.exec("nohup python train.py > job.log 2>&1 & echo $! > job.pid") # returns at once# Later: the end of the log, with a command or with the files API.tail = session.exec("tail -n 20 job.log")["stdout"]log = session.files.read_text("job.log")shell_exec{"sessionId": "SESSION_ID", "command": "nohup python train.py > job.log 2>&1 & echo $! > job.pid"}boxline exec $SESSION_ID -- 'nohup python train.py > job.log 2>&1 & echo $! > job.pid'boxline exec $SESSION_ID -- tail -n 20 job.log # laterboxline files get $SESSION_ID job.log - # or read the whole fileThe shell does the rest of the job control; there are no extra calls for it, and a background job reads no input. Use pgrep -af train.py to see whether it still runs, kill "$(cat job.pid)" to stop it, and tail -f job.log in the terminal to follow it. A stuck process can be stopped from another shell (shell: false) while the default one is busy.
Background jobs stop when the session stops or the shell is restarted, and if the platform has to replace a lost machine. Write results to /workspace as you go, so a job that starts again can pick up from them.
Big output is cut, with a marker
Without stream, each of stdout and stderr keeps its first 64 KB and last 448 KB, with [… N characters not shown …] in between, and truncated: true. For big output, write it to a file (cmd > out.txt) and download it, or stream it. Output is UTF-8 throughout.
One command at a time per shell
A shell runs one command at a time. A second command sent to a busy shell waits for it; its own time limit starts when it runs. For parallel work, use shell: false, another shell name, or a background job:
# Both run at once: a fresh process, and a second persistent shell.curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec -H "x-api-key: $BOXLINE_API_KEY" -H "content-type: application/json" -d '{"command": "./fetch-a.sh", "shell": false}' &curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec -H "x-api-key: $BOXLINE_API_KEY" -H "content-type: application/json" -d '{"command": "./fetch-b.sh", "shell": "second"}' &waitconst [a, b] = await Promise.all([ session.exec("./fetch-a.sh", { shell: false }), // a fresh process session.exec("./fetch-b.sh", { shell: "second" }), // a second persistent shell]);from concurrent.futures import ThreadPoolExecutorwith ThreadPoolExecutor() as pool: a = pool.submit(session.exec, "./fetch-a.sh", shell=False) # a fresh process b = pool.submit(session.exec, "./fetch-b.sh", shell="second") # a second persistent shell print(a.result()["exitCode"], b.result()["exitCode"])No input
A command’s input is empty: read, prompts and REPLs get end-of-file at once. Pass -y, set DEBIAN_FRONTEND=noninteractive, or use the terminal for interactive programs.
The workspace
/workspace(also$BOXLINE_WORKSPACE) is one disk shared by the shell, the browser and the files API. A file one writes, the others see.- The disks have fixed sizes:
/workspace10 GiB (5 GiB in a browser-only session), and about 4 GiB for everything outside it, such as installed packages. A full disk fails the write at once with “No space left on device”. Delete files to make room, and keep large data in/workspace. - Browser downloads land in
/workspace/downloads, so the shell can work on them at once. Wait for one withGET /v1/sessions/:id/files/wait?pattern=downloads/*.csv. (A shell-only session has no browser, so it has no downloads of this kind.) - Keep results in the workspace: it is the part that is saved when the session stops, and what you can download before you delete it, also after it has stopped.
What a stop and resume keeps
When a session stops and resumes, it comes back on a fresh machine with its saved state. The same is true when the platform replaces a lost machine, from the last checkpoint.
| Kept | Not kept |
|---|---|
| /workspace: files, downloads, a venv or node_modules in it | Running processes: commands, servers, background jobs. They stop, and a lifecycle event lists them. Open terminals reconnect to a fresh shell. |
| The session's env and exported credentials (set again on the new machine) | Anything outside /workspace: system packages, ~, /tmp. setup commands run again for them. |
| Each persistent shell's directory and exported variables | Variables that were not exported, functions, aliases, set options and the jobs table. |
- An exported variable whose value holds a credential’s value is not stored by a stop: export it again after a resume. The credential itself is always set again.
- A value over 32 KB, and anything past 256 KB in total, is not kept.
Streaming output
With "stream": true, the response is newline-delimited JSON: one line per piece of output, then a final exit line. The SDKs parse it for you.
| Line | What it means |
|---|---|
{"type":"stdout","data":"…"}, {"type":"stderr","data":"…"} | A piece of the command’s output. |
{"type":"exit","exitCode":0,"timedOut":false,"durationMs":…,"truncated":false} | The last line of a command that ran: how it ended. |
{"type":"waiting","for":"setup"} | The session’s setup commands are still running, so your command has not started. Sent every 15 seconds. |
{"type":"ping"} | Nothing to show: sent after 25 seconds without output, so a long, quiet command keeps the connection alive. |
{"type":"error","status":…,"code":"…","message":"…","requestId":"…"} | Something failed after the answer had started (the machine could not be reached, an unknown credential, …). It is the last line. It is the same error a plain exec answers with that status. |
The headers go out at once, also while setup commands still run, so a client never waits minutes for the start of the answer (Node’s fetch gives up after 300 seconds without headers or data). That is why errors come as a line and not as an HTTP status once the answer has started. Read every line, and ignore types you do not know. The Node SDK’s exec() reads this stream too, and the Python SDK’s exec() allows for 10 minutes of setup in its time limit.
curl -N -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/shell/exec \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"command": "pip install -r requirements.txt", "timeoutMs": 300000, "stream": true}'# {"type":"stdout","data":"Collecting pandas\n"}# {"type":"exit","exitCode":0,"timedOut":false,"durationMs":…,"truncated":false}const exit = await session.execStream( "pip install -r requirements.txt", (stream, data) => (stream === "stdout" ? process.stdout : process.stderr).write(data), { timeoutMs: 300_000 },);console.log("exit code", exit.exitCode);proc = session.exec_stream("pip install -r requirements.txt", timeout_ms=300_000)with proc: for stream, text in proc: print(text, end="")print("exit code", proc.result["exitCode"])boxline exec $SESSION_ID --timeout 300 -- pip install -r requirements.txtInstall packages
Every session is its own sandbox, and you have sudo inside it, so you can install anything, up to about 4 GiB outside /workspace (then “No space left on device”). Only /workspace is saved when the session stops, so choose where things go:
| Tool | How | Kept after a stop and resume? |
|---|---|---|
| Python | python3 -m venv .venv && . .venv/bin/activate && pip install … in /workspace. pip install also works without a venv, and without sudo. | A venv in /workspace: yes (the activated PATH is exported, so it is kept too). Elsewhere: no. |
| Node | npm install in a project folder under /workspace. npm install -g goes to your home folder. | node_modules in /workspace: yes. Global: no. |
| System packages | sudo apt-get update && sudo apt-get install -y … (root only inside the session's own sandbox). | No: put them in setup, which runs again on the new machine. |
To have packages ready every time, pass setup when you create the session: up to 20 commands that run in order in the session’s shell. Your own commands wait until they finish, and they run again on every new machine (after a resume).
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"shell": true, "setup": ["sudo apt-get update", "sudo apt-get install -y cowsay", "pip install yt-dlp"]}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await bx.sessions.create({ shell: true, setup: ["sudo apt-get update", "sudo apt-get install -y cowsay", "pip install yt-dlp"],});from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create( shell=True, setup=["sudo apt-get update", "sudo apt-get install -y cowsay", "pip install yt-dlp"],)The session reports setupStatus (running, done or failed, with setupError), and each step appears in the session’s events as an exec event with data.by: "setup". Each new session installs again, so pin versions and keep setup to what you need: heavy installs make resumes slower.
Graphics without a GPU
Session machines have no GPU. Everything renders in software on the machine’s CPUs, so a heavy 3D scene is slow, not wrong, and it counts against the machine’s CPU and memory like any other program.
- The browser has WebGL 1 and 2. Pages and your scripts get a WebGL context with nothing to set up. It reports its software renderer (SwiftShader) as it is.
- The shell has OpenGL through Mesa (llvmpipe), for programs that render by themselves, such as PyOpenGL, pyrender, Blender or a game engine’s headless mode. Without a display, use EGL (
EGL_PLATFORM=surfaceless, orPYOPENGL_PLATFORM=egl); OSMesa works too. A program that needs X gets a virtual display withxvfb-run -a, for examplexvfb-run -a glxinfo -B.
Interactive terminal
terminalUrl on the session (null without a shell) is a signed WebSocket attached to a real terminal on the machine. Read the session again for a fresh one, and treat it like a password: see Live view. Send text frames of JSON, either {"type":"input","data":"ls\r"} or {"type":"resize","cols":120,"rows":32}; the server sends raw terminal output as binary frames, ready for a terminal emulator such as xterm.js. It shares the workspace with exec, and hides exported credentials like exec does.
From the command line
The CLI runs commands and opens a terminal from yours:
boxline exec $SESSION_ID -- ls -la # streams the output, exits with the command's exit codeboxline exec $SESSION_ID --timeout 600 -- 'pip install yt-dlp && yt-dlp --version'boxline shell $SESSION_ID # an interactive terminal; Ctrl+D leaves, the session keeps runningReuse the browser login in the shell
Signed in with the browser and want to call the same site from the shell? Export the browser’s cookies as a Netscape cookie file that curl and wget understand. It needs a session with a browser, and the shell is where you use the file.
POST/v1/sessions/:id/browser/export-cookies
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/browser/export-cookies \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{}'# {"path": "/workspace/cookies.txt", "count": 12}const { path, count } = await session.exportCookies(); // /workspace/cookies.txtawait session.exec("curl -b cookies.txt -o invoices.json https://example.com/api/invoices");session.export_cookies() # /workspace/cookies.txtsession.exec("curl -b cookies.txt -o invoices.json https://example.com/api/invoices")The body takes an optional path (default cookies.txt in the workspace) and the response is { path, count }. The file holds live credentials, so delete it when you are done. A shell-only session has no browser and no cookies: the call answers 409 browser_disabled. See Export cookies to a file.
Use it as Claude’s bash tool
Claude’s bash tool asks your code to run commands. Point it at a session and every command runs in an isolated, persistent shell instead of on your own machine. This loop also handles restarts, refusals (with server-side fallbacks) and paused turns:
import Anthropic from "@anthropic-ai/sdk";import { Boxline } from "@boxline/sdk";const anthropic = new Anthropic();const client = new Boxline();const session = await client.sessions.create({ browser: false, shell: true,});const messages: Anthropic.Beta.BetaMessageParam[] = [ { role: "user", content: "Use Python to list the 10 largest files under /usr/lib." },];try { while (true) { const res = await anthropic.beta.messages.create({ model: "claude-opus-5-5", max_tokens: 16000, betas: ["server-side-fallback-2026-07-01"], fallbacks: "default", tools: [{ type: "bash_20250124", name: "bash" }], messages, }); if (res.stop_reason === "refusal") break; if (res.stop_reason === "pause_turn") { messages.push({ role: "assistant", content: res.content }); continue; } const calls = res.content.filter( (b): b is Anthropic.Beta.BetaToolUseBlock => b.type === "tool_use", ); if (calls.length === 0) break; messages.push({ role: "assistant", content: res.content }); // Each bash call runs in the session's persistent shell const results: Anthropic.Beta.BetaToolResultBlockParam[] = []; for (const call of calls) { const input = call.input as { command?: string; restart?: boolean }; if (input.restart) { await session.restartShell(); results.push({ type: "tool_result", tool_use_id: call.id, content: "Shell restarted." }); continue; } const run = await session.exec(input.command ?? "", { timeoutMs: 120_000 }); results.push({ type: "tool_result", tool_use_id: call.id, content: run.stdout + run.stderr || "(no output)", is_error: run.exitCode !== 0, }); } messages.push({ role: "user", content: results }); }} finally { await session.stop();}These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.