Docs
Dashboard
Guides / SessionsBeta

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}'

Sessions 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}'
  • liveUrl and connectUrl are null, and the calls that need a browser (actions, computer use, scripts, cookie export, the live view) answer 409 browser_disabled.
  • Nothing lands in /workspace/downloads by itself, because no browser downloads anything. Fetch files in the shell with curl or wget.
  • A session without a shell is the other way round: the calls on this page answer 409 shell_disabled, and terminalUrl is null.

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}'
FieldDescription
commandThe bash command to run. Required.
timeoutMsHow long it may run, in milliseconds. Default 120,000, at most 3,600,000.
cwdThe directory for this command only, inside the workspace. The shell stays where it was.
envVariables for this command only. It may set PATH, HOME and the like for itself.
credentialsCredentials exported for this command only. See Credentials.
shellWhich persistent shell: "default", any other name for a separate one, or false for a fresh process that keeps nothing.
streamtrue to stream the output as NDJSON. See Streaming output.
Response
{  "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"}}'
  • 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 with BOXLINE_, SANDBOXD_ or BASH_.
  • 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"}}'

A 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", …}

shell: "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

  • exitCode is the command’s own ((exit 7) gives 7), and stderr is kept apart from stdout. Check both.
  • exit 4, or a failure under set -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, set options and the jobs table. export what 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.byWho
apiA call to POST /v1/sessions/:id/shell/exec.
agentThe bash tool of an agent run; data.runId is the run.
setupOne of the session’s setup commands.
scriptThe 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 timeoutMs is 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. Read job.log later.
  • Streaming. With stream: true you 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"

The 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"}' &wait

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: /workspace 10 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 with GET /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.

KeptNot kept
/workspace: files, downloads, a venv or node_modules in itRunning 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 variablesVariables 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.

LineWhat 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}

Install 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:

ToolHowKept after a stop and resume?
Pythonpython3 -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.
Nodenpm install in a project folder under /workspace. npm install -g goes to your home folder.node_modules in /workspace: yes. Global: no.
System packagessudo 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"]}'

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, or PYOPENGL_PLATFORM=egl); OSMesa works too. A program that needs X gets a virtual display with xvfb-run -a, for example xvfb-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:

Shell
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 running

Reuse 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}

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:

agent.ts
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.