Sandboxed compute
With the computer capability the agent gets an isolated Linux sandbox: it can run commands,
read and edit files, transform a spreadsheet, build and test code, then hand the result back
as a download. State persists across turns in a conversation, and the sandbox pauses when
idle so you don't pay for a machine nobody is using.
Enable it
- Capabilities: turn on Computer / sandbox for the project.
- Token: include
computerin the capabilities you mint. Leave it out and the tools don't exist for that user. - Request:
enable_computerdefaults totrue; passfalseto decline it for a turn.
That's it — there is no backend to choose, no credentials to paste and no machine spec to fill in. Every project on the platform gets the same isolated Linux machine, and Dashboard → Computer shows you what it is and what's running.
A sandbox is only provisioned if the agent actually reaches for it — enabling the capability costs nothing on turns that don't use it.
What the machine is
A microVM of its own per conversation: its own kernel, its own disk, nothing shared with
another customer or another end-user. The agent is root on it, with a working sudo.
| Linux | Ubuntu 24.04, /workspace as the working directory |
| Python | numpy, pandas, scipy, matplotlib, pillow, openpyxl, requests, beautifulsoup4, lxml, uv |
| Node | node + npm |
| Documents | pandoc, LibreOffice, poppler, qpdf, ghostscript, tesseract + ocrmypdf, typst |
| Media | ffmpeg, sox, ImageMagick, libvips, the image optimisers, yt-dlp, exiftool |
| Development | clang/gcc, cmake, ninja, gdb, git, sqlite3, psql/mysql/redis clients |
| Shell tools | ripgrep, fd, fzf, jq, yq, dasel, bat, eza, tree, the archivers |
| Resources | 2 vCPU, 2 GiB RAM, 8 GiB disk |
Anything missing, the agent installs itself: sudo apt-get install -y <it> works, and the
change stays in that conversation's workspace.
What the agent can do
| Tool | |
|---|---|
computer_bash | run a shell command (state persists between calls) — output streams to you as it appears, and it can run in the background |
computer_jobs | check on or stop a command running in the background |
computer_read_file / computer_write_file | read (with line offset/limit) and write text files |
computer_edit_file | replace an exact string, with an ambiguity check |
computer_list / computer_glob / computer_grep | explore the workspace |
computer_fetch_document | copy one of your uploaded documents in, whole — by filename or id |
computer_fetch_attachment | copy a file already in the conversation in — one it generated, one you uploaded, one it exported earlier |
computer_export_file | bring a sandbox file into the conversation — send_file is what hands it to the user |
computer_view_file | look at an image in the sandbox to check its own work |
computer_expose_port | publish a port at a URL (only where the deployment supports it) |
Long commands
Two things make a slow command bearable, and both are automatic.
You see the output as it happens. While a command runs, its output arrives on the
stream as command_output chunks, grouped by command_id. Append them to a pane and a
five-minute test run looks like a test run instead of a hang:
await ai.chat.stream({ message: "run the test suite" }, {
onCommandOutput: ({ command_id, delta }) => appendToPane(command_id, delta),
onToken: (_d, full) => setAnswer(full),
});
Really long work runs in the background. The agent can start a build and keep working;
when it finishes, the agent is told and carries on. You get a command_finished event if
the turn is still going — and if it had already ended, the result arrives as a new turn
in the same session, exactly like a scheduled run, so it reaches the user through
whatever you already do with those (including the turn.stopped webhook if the tab is
closed). Nothing to poll.
computer_view_file is the one worth knowing about: after rendering a chart the agent can
open the PNG, see it, and fix it before showing the user — instead of describing a file it
never looked at.
It reads before it writes
Both writers refuse a file the agent hasn't read: computer_edit_file for a file it has
never seen, computer_write_file for an existing file it hasn't seen in full — that one
replaces everything, including whatever it didn't look at. And both refuse again if the
file changed since it was read, because a command it ran, a background job or your own
user may have written to it in between; the agent reads it again and works from what is
actually there.
"Hasn't read" means in the current turn. The record of what the agent has looked at is scoped to one turn, deliberately: the guarantee is not that somebody looked at the file once, it is that its current contents are in front of the model as it decides — and across turns they may have scrolled out of the window or been compacted away. So the agent re-reads before editing on a later turn. That costs one tool call and is the whole reason the guard catches a file that changed while nobody was looking.
You don't configure this and there is nothing to handle: it costs the agent a read_file
call, and it is what stops a confident edit from quietly deleting a paragraph nobody knew
was in the file.
A worked example — the agent pulls a document in, computes what it needs, plots it, checks the result, and hands it over:
computer_fetch_document bookings.csv # a document from the corpus, into the sandbox
computer_write_file data/revenue.csv
computer_bash python plot.py # matplotlib → out/chart.png
computer_view_file out/chart.png # look at it before claiming it's done
computer_export_file out/chart.png # into the conversation, as kind: "image"
send_file <id from the export> # ...and now the user has it
That last line is not optional. Exporting stages the file; send_file delivers it. The
separation is deliberate — see Files — and
leaving it out is a silent failure: the export succeeds, the file is really there, nothing
errors, and attachments comes back empty.
rag_search returns the passages that matched — a ranked fragment, never the whole
document. So for anything that depends on all of a file (a total, a count, a sum, a
maximum, any per-row arithmetic) the agent must computer_fetch_document it and compute
in the sandbox.
It is told this, and the retrieval results say it too. Worth knowing anyway, because the failure is silent when it goes wrong: arithmetic over a fragment produces an exact-looking number that is simply short, with a row count and a filename that make it read as verified. If you have a domain where this matters, a skill naming the procedure is the strongest guarantee available.
One thing to get right for this to work: aggregate in SQL rather than pulling raw rows — only the result
preview passes through the agent, so group by beats fetching 10,000 rows to sum them
in Python.
Files, both directions
In. Attach a file with to_sandbox and the original bytes land in the workspace
instead of being extracted to text for the model. That's what you want for data files:
await ai.chat.send({
message: "Clean this export and give me a per-region summary as CSV",
attachments: [{
kind: "file",
name: "sales.csv",
url: `data:text/csv;base64,${b64}`,
to_sandbox: true,
}],
});
The agent is told the path it landed at. Already-uploaded documents don't need re-sending —
the agent copies them in itself with computer_fetch_document.
Out. Anything the agent hands over arrives on the normal channel: res.attachments, or
the attachments stream event — which now fires the moment the file is produced, so a
download or a screenshot appears next to the step that made it rather than after the agent
has finished talking.
res.attachments;
// [{ kind: "file", url, name: "summary.csv", mime_type: "text/csv", size: 8213 },
// { kind: "image", url, name: "chart.png", mime_type: "image/png", size: 41022 }]
kind follows the file's type, so a chart the agent rendered arrives as an image your
UI can show inline rather than a download card — while a CSV or a zip stays a file.
URLs are signed and short-lived; download the bytes if you need them permanently.
A rendered chart also comes back into the next turn, so "same chart, but log scale" works without re-deriving it.
Sessions
Each sandbox is a session with a stable id. By default one is bound to the chat session, so a
conversation keeps its workspace; pass computer_session_id to attach a specific one
instead — that's how you reconnect a user to work from yesterday.
const s = await ai.computers.create(); // start one explicitly
await ai.computers.upload(s.id, file, "inbox/data.csv"); // push a file in
await ai.chat.send({ message: "Summarise inbox/data.csv", computer_session_id: s.id });
await ai.computers.pause(s.id); // stop paying while idle
await ai.computers.resume(s.id); // same workspace, later
await ai.computers.destroy(s.id);
await ai.computers.list({ include_stopped: false });
await ai.computers.exec(s.id, { command: "ls -la", timeout_s: 30 });
await ai.computers.download(s.id, "out/summary.csv"); // -> Blob
Paused sandboxes resume with the workspace intact, and you rarely need to pause one by hand: an idle sandbox does it itself, in stages.
| idle for | what happens | what coming back costs |
|---|---|---|
| 1 minute | it sleeps — memory written to disk | nothing you can feel: it wakes exactly where it was, processes and all |
| 5 minutes | memory is dropped, files kept | a boot (about a second); running processes are gone, the workspace isn't |
| 10 minutes | the disk moves to object storage | a few seconds while it comes back |
| 7 days | it's deleted | a fresh workspace |
A sandbox running a command is never put to sleep, however long the command takes. And if a sandbox has genuinely gone, the next tool call recreates it rather than failing the turn.
Isolation and limits
A sandbox reaches the public internet — it can fetch a page, install from any registry, call an API you point it at. What it cannot reach is anything private: the platform's own services, its own network, another sandbox. That boundary is enforced on addresses rather than hostnames, so there is no allowlist to keep up to date.
It also cannot resolve them. The sandbox's resolver asks public DNS and never the deployment's own — otherwise a sandbox could look up the platform's internal service names and read its topology off the answers, which the address filter would block it from reaching but had already told it about. IPv6 is dropped outright, since a VM here is IPv4-only by construction and an unfiltered v6 packet is traffic no policy was written for.
The defaults:
| Limit | Default |
|---|---|
| working directory | /workspace — every path is confined here |
| CPU / RAM / disk | 2 vCPU, 2 GiB, 8 GiB per sandbox |
| per-command timeout | 5 minutes by default; up to your project's limit (30 min ceiling), then it's killed |
| command output | 30,000 characters, then explicitly truncated — whatever the command printed |
| file read/write/export | 20 MB |
| concurrent sandboxes | your project's setting; 10 by default |
How long a command may run
Two of those numbers are yours, not the deployment's:
await oberik.computer.set({ execTimeoutS: 120, maxSessions: 2 });
await oberik.computer.get(); // { execTimeoutS: 120, maxSessions: 2 }
execTimeoutS is the project's own default for one command — the figure
GET /computers/host reports as exec_default_timeout_s, which is why that page tells
you to read it rather than hard-code five minutes. maxSessions is how many sandboxes
this project may hold at once. null on either means the deployment's own.
Both are clamped at the point of use, not on the way in, and that distinction is worth
knowing: computer.set({ execTimeoutS: 18000 }) stores 18000 and computer.get() reads it
back, while a command on a host whose maximum is 1800 is killed at 1800 and says so
([command timed out after 1800s]). So the number you stored is what you asked for, and
exec_max_timeout_s on GET /computers/host is what you will get — read both if you are
sizing your own client's timeout.
Paths are validated server-side, so ../../etc/passwd is rejected before it reaches the
sandbox — the model's arguments are never trusted. Isolation is a virtual machine with its
own kernel, not a shared container: there's no host mount, no Docker socket, and neither your
provider keys nor the JWT secret are ever placed inside it. Every command, write and export
is recorded in the audit log.
One thing to keep in mind: sandbox output is untrusted input. A file the agent downloads could contain text aimed at the model. It comes back as a tool result, never as instructions, and guardrails still screen the final answer — but treat sandbox-derived content the way you'd treat user-supplied content in your own app.
Read-only sandboxes
Because the sandbox tools go through the same per-tool gate as everything else, you can hand out a look-but-don't-touch workspace with no extra machinery:
await ai.chat.send({
message: "What's in the workspace?",
allowed_tools: ["computer_list", "computer_read_file", "computer_grep", "computer_glob"],
});