Skip to content

21. Sandboxes

Agents are good at deciding what to do, and bad at arithmetic. A sandbox gives them a real machine to do it on. First you’ll run a Python script on a sales CSV in a throwaway Linux machine and read the report it writes. Then you’ll hand the keyboard to an agent: it decides which commands to run, sees what each one prints, and answers a question about the data. You’ll also see what an agent does when a command fails.

You’ll learn

  • capsules: throwaway Linux machines with your files mounted
  • running a script in one and getting its results back
  • sending commands to a capsule that stays up, and getting each one’s exit code and output
  • letting an agent choose commands and read their output and exit codes
  • installing packages, and keeping an agent’s commands away from your Maitento login

New concepts: capsules (sandboxes) · Builds on: 15. Files for agents and 16. Apps · Time: about 30 minutes · Cost: under $0.01 per question to the analyst on DeepSeek · Needs: nothing beyond the tutorials’ setup

In Maitento a sandbox is a capsule: a recipe for a Linux machine (what to install, which file systems to mount, what to run when it starts). Starting a capsule gives you that machine, until its commands finish, you stop it, or it runs out of time.

The data and a Python script go into a file system called sales-data:

data/summarise.py
# Runs inside the capsule. The VFS is mounted at /mnt/data, so files you put in it
# are right there, and anything written back to it is still there after the capsule ends.
import csv
from collections import defaultdict
totals = defaultdict(float)
units = defaultdict(int)
with open("/mnt/data/sales.csv", newline="") as f:
for row in csv.DictReader(f):
totals[row["product"]] += int(row["units"]) * float(row["unit_price"])
units[row["product"]] += int(row["units"])
lines = ["# Sales report", "", "| Product | Units | Revenue |", "|---|---:|---:|"]
for product, revenue in sorted(totals.items(), key=lambda item: -item[1]):
lines.append(f"| {product} | {units[product]} | {revenue:.2f} |")
lines += ["", f"Total revenue: {sum(totals.values()):.2f}"]
with open("/mnt/data/report.md", "w") as f:
f.write("\n".join(lines) + "\n")
print("Wrote /mnt/data/report.md")

The sales-report capsule mounts that file system at /mnt/data, runs the script, and stops when it’s done:

maitento/capsules/sales-report.mtr
// A capsule is a short-lived Linux sandbox. This one mounts the sales-data file system,
// runs a Python script on the CSV in it, and stops when the script finishes.
autoTerminate = true
maxDuration = "00:05:00"
// The file system must exist before you deploy: create it and upload the data first.
vfs = [
{
vfs = @ref("sales-data")
mountName = "data"
}
]
startup = [
{
command = "python3"
arguments = ["/mnt/data/summarise.py"]
wait = true
maxDuration = "00:02:00"
}
]

Done when: you can say where the script finds the CSV (/mnt/data/sales.csv) and where it writes the report.

A file system has to exist before a capsule that mounts it is deployed, so the script creates sales-data and uploads the files first. Run it from examples/21-sandboxes/typescript, python or csharp, as in tutorial 1:

import { readFile } from "node:fs/promises";
import { Maitento } from "@maitento/sdk";
const mt = new Maitento();
// The capsule mounts the sales-data file system, so create it (and the namespace) before deploying.
const namespace = process.env.MAITENTO_NAMESPACE ?? "tutorials";
if (!(await mt.namespaces.find(namespace))) await mt.namespaces.create({ name: namespace });
if (!(await mt.vfs.findByName("sales-data"))) await mt.vfs.create({ name: "sales-data" });
const files = mt.vfs.open("sales-data");
for (const name of ["sales.csv", "summarise.py"]) {
await files.writeText(`/${name}`, await readFile(`../data/${name}`, "utf8"));
}
await mt.deploy("../maitento");
// Start the capsule and wait for it: it runs summarise.py and stops.
const run = await mt.capsules.run("sales-report");
console.log(`Capsule finished: ${run.status}\n`);
console.log(await files.readText("/report.md"));
Capsule finished: Finished
# Sales report
| Product | Units | Revenue |
|---|---:|---:|
| Desk lamp | 10 | 599.00 |
| Tea sampler | 43 | 516.00 |
| Blue mug | 44 | 374.00 |
Total revenue: 1489.00

The capsule started, mounted sales-data, ran python3 /mnt/data/summarise.py, and stopped. The report outlived it, because it was written to the mounted file system.

Done when: you see the report, and the capsule finished with status Finished.

sales-report did all its work as it started, then stopped. A capsule can also stay up and take commands one at a time, which is what an agent will need. That’s the workbench capsule:

maitento/capsules/workbench.mtr
// A capsule that stays up (no autoTerminate), so commands can be sent to it one at a time.
maxDuration = "00:10:00"
vfs = [
{
vfs = @ref("sales-data")
mountName = "data"
}
]
// The commands the agent chooses run as this user. The capsule's default user is signed in to
// Maitento as you, and an agent's commands don't need that.
users = [
{
name = "sandbox"
}
]
// A mounted file system is for the default user only, so give the sandbox user a copy it can read.
startup = [
{
command = "bash"
arguments = ["-c", "rm -rf /tmp/data && cp -r /mnt/data /tmp/data && chmod -R a+rwX /tmp/data"]
wait = true
maxDuration = "00:01:00"
}
]
  • With no autoTerminate, it stays up until it’s stopped or its maxDuration runs out.
  • It creates a second user, sandbox, for commands that shouldn’t run as you. The reason is below.
  • A mounted file system belongs to the capsule’s default user, so the start-up command copies the data to /tmp/data, where sandbox can read it.

commands.ts starts workbench, runs two commands in it as sandbox, and stops it:

import { Maitento } from "@maitento/sdk";
// Run sandbox.ts first: the workbench capsule mounts the sales-data file system it creates.
const mt = new Maitento();
await mt.deploy("../maitento");
// Start the workbench capsule. It stays up, so it can take commands one at a time.
const capsule = await mt.capsules.start("workbench");
// Run a command as the sandbox user, and wait for it to end. Each argument reaches the program as one word.
const listing = await mt.capsuleRuns.execAndWait(capsule.id, {
command: "ls",
arguments: ["-l", "/tmp/data"],
runAsUser: "sandbox",
});
console.log(`exit code ${listing.exitCode}\n${listing.outputStandard}`);
// A command that fails is a result too. The mounted file system belongs to the default user, so sandbox can't read it.
const denied = await mt.capsuleRuns.execAndWait(capsule.id, {
command: "cat",
arguments: ["/mnt/data/sales.csv"],
runAsUser: "sandbox",
});
console.log(`exit code ${denied.exitCode}\n${denied.outputError}`);
await mt.capsuleRuns.terminate(capsule.id);
exit code 0
total 12
-rw-rw-rw- 1 maitento maitento 171 Oct 27 10:31 report.md
-rw-rw-rw- 1 maitento maitento 398 Oct 27 10:31 sales.csv
-rw-rw-rw- 1 maitento maitento 915 Oct 27 10:31 summarise.py
exit code 1
cat: /mnt/data/sales.csv: Permission denied
  • execAndWait (exec_and_wait in Python, RunCommandAndWaitAsync in C#) sends a command and waits for it to end. It returns the command with its exitCode, outputStandard and outputError. With an event stream open it hears the end as it happens; without one it checks every second or two.
  • A command that fails isn’t an error. cat exited with 1 and said why in its error output. Your code reads the exit code, and so will the agent.
  • You don’t wait for the capsule to start. A command sent while it’s starting runs as soon as it’s ready.
  • If the capsule ends first, because it was stopped or ran out of time, execAndWait throws CapsuleCommandAbortedError, saying why.

Done when: you see the files in /tmp/data, then exit code 1 and Permission denied for /mnt/data.

Now let an agent drive. The question is “Which region brought in the most revenue, and how much?”, and the report doesn’t say. Nobody tells the agent how to work it out: it looks at the files, writes a command, reads what it prints, and carries on until it can answer.

The loop lives in an app. Each step, it asks the agent for the next command, runs it in a capsule, and adds the command, its exit code and its output to what the agent sees next time:

maitento/apps/analyst/analyst.mtc
// An agent works in a sandbox: it chooses each command, the app runs it in a capsule and hands
// back what it printed, until the agent can answer.
string main(string question, string namespaceName) {
guid capsuleId = CapsuleStart("workbench", namespaceName, "[]", "[]", "[]");
jsonObject capsule = CapsuleWaitUntilStarted(capsuleId);
if (JsonObjectGet(capsule, "status") != "Running") {
throw("The capsule did not start: " + JsonObjectGet(capsule, "failureDetails"));
}
string transcript = "";
string answer = "";
for (int step = 1; step <= 6; step++) {
// Ask the analyst for the next command, with everything it has run so far.
jsonObject inputs = JsonObjectCreate();
inputs = JsonObjectSetString(inputs, "question", question);
inputs = JsonObjectSetString(inputs, "transcript", transcript);
jsonObject run = InteractionAwait(InteractionStart("next-command", inputs, "[]", "[]", ""));
if (JsonObjectGet(run, "status") != "Finished") {
CapsuleTerminate(capsuleId);
throw("The analyst failed: " + JsonObjectGet(run, "failureDetails"));
}
jsonObject next = StringToJsonObject(JsonObjectGet(run, "output"));
if (JsonObjectGet(next, "done") == "true") {
answer = JsonObjectGet(next, "answer");
break;
}
// Run the command in the capsule as the sandbox user, and wait for it to end.
string command = JsonObjectGet(next, "command");
guid commandId = CapsuleExec(capsuleId, command, "[]", "sandbox", 60000, false);
jsonObject result = CapsuleCommandAwait(capsuleId, commandId);
string output = "$ " + command + "\nexit code: " + JsonObjectGet(result, "exitCode") + "\n"
+ JsonObjectGet(result, "outputStandard") + JsonObjectGet(result, "outputError");
PrintLine(output);
transcript = transcript + output + "\n";
}
CapsuleTerminate(capsuleId);
AppStateSetOutputString("answer", answer);
return(answer);
}
  • CapsuleStart starts the capsule, and CapsuleWaitUntilStarted waits until it’s running.
  • CapsuleExec queues a command and returns its id. CapsuleCommandAwait waits for it to end, and returns its exitCode, outputStandard and outputError. Together they do what execAndWait did in step 3.
  • next-command is a one-shot interaction with a typed answer: done, command and answer.

This is what you did by hand in step 3, from inside Maitento: the app uses the same workbench capsule, and the agent’s commands run as sandbox, as yours did.

The agent sees the question and the commands so far:

maitento/prompts/next-command.md
Answer this question about the files in /tmp/data: %%question%%
{{#if transcript}}
The commands you have run so far, each with its exit code and output:
%%transcript%%
{{/if}}
Give the next bash command to run. When you can answer the question, set done to true and give the answer instead.

Why an app, and not a tool the agent calls? Tools that call Maitento itself (auth = "Maitento", as in tutorial 15) can read and write files, but they can’t start capsules or run commands in them. An app can, and it also keeps the loop in one place: how many steps, which user, when to stop.

Done when: you can say who chooses each command (the agent), and who runs it (the app).

analyst.ts deploys the project and runs the app with the question. Run sandbox.ts first, because workbench mounts the file system it creates:

import { Maitento } from "@maitento/sdk";
// Run sandbox.ts first: the workbench capsule mounts the sales-data file system it creates.
const mt = new Maitento();
await mt.deploy("../maitento");
// The app starts the workbench capsule, then lets the analyst run commands in it until it can answer.
const run = await mt.apps.run("analyst", {
inputs: {
question: process.argv[2] ?? "Which region brought in the most revenue, and how much?",
namespaceName: process.env.MAITENTO_NAMESPACE ?? "tutorials",
},
});
for (const line of run.consoleOutputs) console.log(line.text.trimEnd());
console.log(`\nAnswer: ${run.returnValue}`);
$ ls -la /tmp/data
exit code: 0
total 20
drwxrwxrwx 2 maitento maitento 4096 Oct 26 20:31 .
drwxrwxrwt 1 root root 4096 Oct 26 20:31 ..
-rw-rw-rw- 1 maitento maitento 171 Oct 26 20:31 report.md
-rw-rw-rw- 1 maitento maitento 398 Oct 26 20:31 sales.csv
-rw-rw-rw- 1 maitento maitento 915 Oct 26 20:31 summarise.py
$ cat /tmp/data/sales.csv; echo '---'; cat /tmp/data/report.md
exit code: 0
date,region,product,units,unit_price
2026-09-01,North,Blue mug,12,8.50
…
$ awk -F, 'NR>1{r[$2]+=$4*$5} END{for(k in r) printf "%s %.2f\n", k, r[k]}' /tmp/data/sales.csv
exit code: 0
West 527.00
South 415.20
North 546.80
Answer: The North region brought in the most revenue, at 546.80 (West: 527.00, South: 415.20).

It takes a minute or so, most of it the agent choosing each command: a command’s result is back moments after it ends. Every run takes its own path, with different commands, but the same answer.

Done when: the answer is North, 546.80.

6. Break it on purpose: a command that fails

Section titled “6. Break it on purpose: a command that fails”

Ask for something the machine can’t do. The sqlite3 command-line tool isn’t installed:

node analyst.ts "Use the sqlite3 command-line tool to find which product sold the most units."
$ cd /tmp/data && sqlite3 :memory: ".mode csv" ".import sales.csv sales" "SELECT product, SUM(units) AS total FROM sales GROUP BY product ORDER BY total DESC LIMIT 3;"
exit code: 127
-bash: line 1: sqlite3: command not found
$ cd /tmp/data && python3 -c "import sqlite3,csv;c=sqlite3.connect(':memory:');c.execute('CREATE TABLE sales(date,region,product,units,unit_price)');r=csv.reader(open('sales.csv'));next(r);c.executemany('INSERT INTO sales VALUES(?,?,?,?,?)',r);print(c.execute('SELECT product,SUM(units) t FROM sales GROUP BY product ORDER BY t DESC').fetchall())"
exit code: 0
[('Blue mug', 44), ('Tea sampler', 43), ('Desk lamp', 10)]
Answer: The sqlite3 command-line tool is not installed in this environment, so the query was executed via Python's built-in sqlite3 module instead. The product that sold the most units is the Blue mug, with 44 units. (Tea sampler: 43, Desk lamp: 10.)

The command failed with exit code 127, the shell’s “command not found”, and its error output said why. A failed command isn’t a failed run: the app passed the exit code and the error back like any other output, and the agent read them and found another way, Python’s own sqlite3 module.

Now fix it the proper way. Add a line to workbench.mtr, after maxDuration, and ask again:

packages = ["sqlite3"]

This time sqlite3 runs: the capsule installs Debian packages as it starts. Exit codes keep helping, too. In our run the agent’s first query failed with exit code 1 and table sales already exists; it read the error and fixed the query on the next step.

Done when: you’ve seen exit code 127 in the transcript, and after adding the package, an answer that used sqlite3 itself.

When a capsule starts, Maitento signs its default user, maitento, in to the mt command line as the user who started the capsule, with the capsule’s namespace already set. A script in the sandbox can call Maitento without a key of its own: read a file system, start an interaction, start another capsule. mt is on the default user’s PATH, for example:

mt interaction-run <interaction> --input-<name> "<value>"

The sign-in is made for that capsule, and is switched off when it ends. While it runs, though, it can do anything you can. That’s why the analyst’s commands run as sandbox, a user the capsule creates, which isn’t signed in: an agent choosing commands shouldn’t be able to act as you. The price is that a mounted file system belongs to the default user (mode 770), so workbench’s start-up command copies the data to /tmp/data for sandbox to read.

Every file in this tutorial’s project:

On GitHub: examples/21-sandboxes · TypeScript · Python · C#

All the files in this project (10)
model = @ref("deepseek.deepseek-v4-flash")
prompt = "You answer questions about data by running bash commands in a Linux sandbox, one at a time, and reading what they print. Python 3 is installed. Keep each command's output short."
summary = "Answers questions about data by running commands in a sandbox"
  • The deploy fails, saying sales-data can’t be found: the file system must exist before a capsule that mounts it is deployed. Run sandbox.ts first: it creates it.
  • The capsule ends Error with “Startup command failed: python3 /mnt/data/summarise.py - …”: the script exited with an error, or isn’t in the file system. The message ends with what it printed to its error output.
  • The analyst’s commands fail with Permission denied on /mnt/data: a mounted file system belongs to the capsule’s default user. Read the copy in /tmp/data, or run the command as the default user.
  • commands.ts fails with CapsuleCommandAbortedError: the capsule ended before the command finished, for example because its start-up command failed or its maxDuration ran out. The message says why; the capsule’s own failureDetails say more.
  • The answer is empty: the agent didn’t answer within the app’s six steps. Ask a narrower question, or raise the step limit in analyst.mtc.
  • The app fails with “The capsule did not start”: the capsule’s start-up failed, for example a package that doesn’t exist in packages. The message says why.

What you built: a capsule that turns a CSV into a report with a Python script, a script that sends commands to a capsule and reads their results, and an app that lets an agent run its own commands in a capsule until it can answer a question, failures included.

The concepts:

  • A capsule is a throwaway Linux machine, with file systems mounted under /mnt/ and packages installed as it starts.
  • Start-up commands run as it starts; with autoTerminate, the capsule ends when they finish.
  • Commands sent to a capsule that stays up come back with their exit code and output, for a script (execAndWait) or an agent to read. A failed command is a result, not an error.
  • An app runs the loop when an agent works in a sandbox: the agent chooses, the app runs and reports back.
  • Users: the default user is signed in to Maitento as you; give an agent’s commands a user of their own.

Challenge: make the report show revenue by region too, and check it against the analyst’s answer. Change only the Python script. A solution in TypeScript, Python and C# is in examples/21-sandboxes/solution:

| Region | Revenue |
|---|---:|
| North | 546.80 |
| West | 527.00 |
| South | 415.20 |

Next: 22. Code pipelines puts AI coding agents to work on a git repository, in a sandbox of their own.