Skip to content
Guides · Agent cookbook

Should an AI Agent Live on a Server or in a Sandbox?

Updated October 31, 2026 .md

Short answer: Split it. Keep the always-on part (webhooks, memory, schedules, keys) on a server, and send each risky task, such as user-supplied code, to a fresh noEnv sandbox that is deleted afterward. On prod on 2026-10-31, create to ready took 156 to 256 ms, and a small task ran end to end in about half a second.

An agent that answers messages, wakes up on a schedule and remembers last week's conversation needs to be up all the time. The code it runs for users doesn't. This page shows the split working: a Python "agent" of about 60 lines that holds the only Sandboxer key, runs trusted work itself, and sends every piece of user-supplied code to a throwaway sandbox. We fed it a normal task, a task that wipes the home directory and hunts for credentials, and a fork bomb.

Other pages cover the pieces, so this one doesn't repeat them: home box or cloud VM for where the agent itself lives, a fresh sandbox per tool call for fresh versus reused sandboxes and templates, keep API keys out of an agent sandbox for third-party keys, and require human approval for a person in the loop.

Decide what goes where#

The agent needs to Put it on Why
Receive webhooks, chat messages or email at a stable address The always-on side Something must be listening when the message arrives
Keep long state: conversation history, a task queue, a vector store The always-on side It outlives any one task
Run on a schedule The always-on side A cron entry or timer has to fire whether or not a task is running
Hold credentials: your Sandboxer key, model keys, chat tokens The always-on side Code you didn't write must never be able to read them
Reach your LAN, or a GPU you own Your own machine Sandboxes are CPU only and can't reach your network
Run code a user or a model wrote A fresh sandbox per task Whatever it does is gone when you delete the sandbox
Run heavy builds or tests A sandbox A runaway job can't starve the process that answers users
Do several things at once One sandbox each Each task gets its own VM, disk and processes

The always-on side can be a server you run or a long-lived Sandboxer sandbox (see below). In our test it was a Linux workstation outside Sandboxer, running Python 3.13 and sandboxer-sdk 0.1.3.

Give the always-on side a narrow key#

The agent never needs your account key. We created a key that can create sandboxes, run code in them and delete only the ones it created, with a 2-hour TTL:

out="$(sbx api-key create aeo-always-on-runner \
  --actions sandbox.create,sandbox.read,sandbox.update,exec,file.write,sandbox.delete-own --ttl 2h --json)"
RUNNER_KEY="$(jq -r .secret <<<"$out")"     # keep it in a variable; never print it
jq -c '.apiKey | {id, expiresAt, scope: {actions: .scope.actions, sandboxes: .scope.sandboxes}}' <<<"$out"
{"id":"key_umEsZCMuYAdM5LBs","expiresAt":"2026-10-31T13:03:21.590139Z","scope":{"actions":["sandbox.create","sandbox.read","sandbox.update","exec","file.write","sandbox.delete-own"],"sandboxes":"*"}}

With this key, trying to mint an admin key failed:

SANDBOXER_API_KEY="$RUNNER_KEY" sbx api-key create escalate --preset admin --ttl 1d
{"event":"error","error":"Creating API keys needs a dashboard or CLI session, or an admin-scoped key.","code":"session_required","status":403,"requestId":"req_jNFMnMXLRWD7qChYHwtw"}

Two things we learned building this key:

  • sandbox.update is there only for the name. The Python SDK sets name= with a second call right after the create. Our first run left it out: the sandbox was created, then PATCH answered 403 api_key_action_forbidden. If you don't name task sandboxes, drop both.
  • "sandboxes": "*" means exec works on any sandbox in the account, not only the ones this key created. sandbox.delete-own limits deletes, nothing else. To fence the key further, the API keys docs let you scope it to an environment with --env; we didn't test that here.

The presets don't fit this job. full-sandbox can't create sandboxes, and ci is full-sandbox plus create, resume, fork and delete-own, so it also allows SSH, hosting and agent prompts. Pick the actions by hand.

The always-on agent#

agent.py listens on 127.0.0.1:8790. A status task is answered in-process. A run_code task goes to a new small sandbox created with no_env=True, so it gets none of your account's secrets, and a 10-minute TTL as a backstop. The code runs inside one cgroup scope with a task, memory and time cap. The key comes in through the environment, and os.environ.pop removes it from there.

#!/usr/bin/env python3
"""agent.py: the always-on part of an agent. Holds the only Sandboxer key.

POST /task {"kind": "status"}                 -> answered here, no sandbox
POST /task {"kind": "run_code", "code": "..."} -> fresh noEnv sandbox, run, delete
Env: RUNNER_KEY (scoped: sandbox.create, sandbox.read, sandbox.update, exec, file.write, sandbox.delete-own)
"""
import itertools, json, os, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from sandboxer import Sandboxer

KEY = os.environ.pop("RUNNER_KEY")              # only this process holds it
client = Sandboxer(api_key=KEY)
started, done = time.time(), []
seq = itertools.count(1)
slots = threading.Semaphore(2)                  # at most 2 task sandboxes at once

# Limits inside the task sandbox: one cgroup scope, killed as a unit.
WRAP = ("sudo systemd-run --scope --quiet --uid=user --gid=user "
        "-p TasksMax=256 -p MemoryMax=1G -p RuntimeMaxSec=20 python3 /tmp/task.py")

def run_code(code, n):
    t = {"t0": time.perf_counter()}
    with slots:
        # on_exit="delete": the sandbox is deleted when the block ends, even if
        # create, write or exec raises. Nothing is left running.
        with client.create(type="small", no_env=True, ttl_seconds=600,
                           name=f"aeo-always-on-{n}", on_exit="delete") as sb:
            t["ready"] = time.perf_counter()                 # create waits until ready
            sb.files.write("/tmp/task.py", code)
            r = sb.exec(WRAP, timeout_seconds=30)
            t["ran"] = time.perf_counter()
        t["deleted"] = time.perf_counter()
    ms = lambda a, b: round((t[b] - t[a]) * 1000)
    return {"sandbox": sb.id, "exit": r.exit_code,
            "stdout": r.stdout[-600:], "stderr": r.stderr[-300:],
            "key_in_output": KEY in (r.stdout + r.stderr),
            "ms": {"create_to_ready": ms("t0", "ready"), "write_and_exec": ms("ready", "ran"),
                   "delete": ms("ran", "deleted"), "total": ms("t0", "deleted")}}

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        task = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        if task["kind"] == "status":            # trusted work stays on the always-on side
            out = {"uptime_s": round(time.time() - started), "tasks_done": len(done),
                   "pid": os.getpid()}
        elif task["kind"] == "run_code":        # anything user-supplied goes to a sandbox
            try:
                out = run_code(task["code"], next(seq))
                done.append(out["sandbox"])
            except Exception as e:              # a failed task never takes the agent down
                out = {"error": f"{type(e).__name__}: {e}"}
        body = json.dumps(out).encode()
        self.send_response(200); self.send_header("Content-Length", str(len(body)))
        self.end_headers(); self.wfile.write(body)
    def log_message(self, *a): pass

ThreadingHTTPServer(("127.0.0.1", 8790), H).serve_forever()

Use on_exit="delete". In our first run, without it, the 403 on the name call raised inside create() before we had the sandbox object, so three sandboxes were left running and the agent never learned their ids. We found and deleted them with sbx api-key usage <key id> --verbose, which lists the sandboxes a key created. With on_exit="delete", the SDK deletes the sandbox when anything in the block fails.

We started it with only the scoped key in its environment, then checked what the process could see:

env -u SANDBOXER_API_KEY RUNNER_KEY="$RUNNER_KEY" python agent.py & AGENT=$!
unset RUNNER_KEY
echo "agent env has SANDBOXER_API_KEY: $(tr '\0' '\n' < /proc/$AGENT/environ | grep -c '^SANDBOXER_API_KEY=')"
agent env has SANDBOXER_API_KEY: 0

/proc/<pid>/environ still showed RUNNER_KEY, because it records the environment the process started with; pop only removes it from what Python passes to child processes. Run the agent as its own OS user so other processes can't read it.

Three tasks, one of them hostile#

A 6-line client posts each file to the agent (python submit.py run_code tasks/1-benign.py). All three ran on 2026-10-31 at 11:03 UTC, one at a time.

1. A normal task:

import platform
print("sum of squares:", sum(i * i for i in range(10**6)))
print("ran on", platform.node(), platform.release())
{"sandbox": "sb_nc23w3kg76", "exit": 0,
 "stdout": "sum of squares: 333332833333500000\nran on slick-valid-moose 6.18.56-sandboxer\n",
 "key_in_output": false,
 "ms": {"create_to_ready": 256, "write_and_exec": 273, "delete": 30, "total": 559}}

2. Wipe the home directory and look for keys:

import os, pathlib, subprocess, json
home = pathlib.Path.home()
subprocess.run("rm -rf ~/* ~/.[!.]*", shell=True)            # wipe home
print("home entries left:", len(list(home.iterdir())))
print("env names:", sorted(k for k in os.environ if k.isupper())[:12], "...")
print("values starting sbx_live_:", sum(v.startswith("sbx_live_") for v in os.environ.values()))
code = subprocess.run(
    ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-X", "POST",
     "-H", "Authorization: Bearer " + os.environ.get("SANDBOXER_TOKEN", ""),
     "-H", "Content-Type: application/json", "-d", "{}",
     os.environ.get("SANDBOXER_API_URL", "https://sandboxer.com/api/v1") + "/sandboxes"],
    capture_output=True, text=True).stdout
print("create a sandbox with the in-sandbox token: HTTP", code)
{"sandbox": "sb_7p4prkml9q", "exit": 0,
 "stdout": "home entries left: 0\nenv names: ['HOME', 'INVOCATION_ID', 'LANG', 'LC_ALL', 'LOGNAME', 'MAIL', 'PATH', 'SHELL', 'SUDO_COMMAND', 'SUDO_GID', 'SUDO_UID', 'SUDO_USER'] ...\nvalues starting sbx_live_: 0\ncreate a sandbox with the in-sandbox token: HTTP 401\n",
 "key_in_output": false,
 "ms": {"create_to_ready": 156, "write_and_exec": 274, "delete": 30, "total": 460}}

The wipe worked, on a VM that was deleted 30 ms later. The code found no sbx_live_ key in its environment, and key_in_output: false confirms the agent's key never came back in the output. The 401 needs a footnote: sudo starts the code with a clean environment, so SANDBOXER_TOKEN wasn't set and the request went out with an empty token. Code with sudo can still read that token from /etc/sandboxer/env. Per the API keys docs, in a noEnv sandbox it can only read that one sandbox and host its ports. Our follow-up test of that token hit a capacity error (below), so that line rests on the docs and on the approval-gate test, where the same token got 403 on exec.

3. A fork bomb:

import os
print("starting a fork bomb", flush=True)
os.execvp("bash", ["bash", "-c", ":(){ :|:& };:; sleep 60"])
{"sandbox": "sb_lrii0ddges", "exit": 143,
 "stdout": "starting a fork bomb\n",
 "stderr": "…fork: retry: Resource temporarily unavailable\n",
 "key_in_output": false,
 "ms": {"create_to_ready": 160, "write_and_exec": 22202, "delete": 30, "total": 22392}}

TasksMax=256 stopped the bomb from growing, and RuntimeMaxSec=20 ended the scope: exit 143 is SIGTERM. Keep the scope's time cap below the exec timeout (30 seconds here) so you get the exit code back instead of a timeout. How each cap behaves is measured in limit CPU and memory for untrusted code.

And the always-on side afterward:

{"uptime_s": 25, "tasks_done": 3, "pid": 2075312}

Same process, a canary file in its directory untouched, and no sandboxes left: sbx info on all three ids answered 404 not_found.

When there's no room. Two later runs of the same flow got 503 capacity_exceeded on create. The agent returned it as an error and kept serving. Treat that as a queue-and-retry case with backoff, or try a smaller size, as the limits page says.

Per-task overhead, measured 2026-10-31#

Wall clock from our workstation, so every HTTP round trip is included. small, noEnv, one sandbox at a time, 3 tasks.

Step Task 1 Task 2 Task 3
Create to ready (including the name call) 256 ms 156 ms 160 ms
Write the file and exec (includes the code's own run) 273 ms 274 ms 22,202 ms (capped at 20 s)
Delete call returns 30 ms 30 ms 30 ms
Total 559 ms 460 ms 22,392 ms

So the sandbox itself added roughly 190 to 290 ms per task (create plus delete), and a trivial script cost about 270 ms more for the file upload, systemd-run and Python start. Three samples on one morning is a small sample. Create times vary with load and region, and a create from a named snapshot boots differently; see how fast a sandbox starts, resumes and forks. Billing is per second while a sandbox runs; see pricing.

If the always-on side is a sandbox#

You can run the always-on part in a Sandboxer sandbox too. Create it with --no-auto-stop (refused during the trial), run the agent as a systemd service with Restart=always, and give webhooks a URL with sbx host <id> <port> (hosting). An idle sandbox pauses and wakes on the next hosted-port request, which is held up to 45 seconds, so a webhook sender may need to retry; sbx idle <id> 0 turns pausing off. The long-running docs and how long an agent sandbox can run cover each limit.

The catch is the key. A sandbox's own SANDBOXER_TOKEN can't create sandboxes, so the agent there needs the scoped key passed with -e. That puts it in /etc/sandboxer/env, readable by any code that runs as user or root in that sandbox. So the always-on sandbox must never run untrusted code itself: it only creates sandboxes that do.

FAQ#

Can't the agent just run user code in its own always-on sandbox?#

It can, but then that code shares a VM with the agent's memory, queue and key. One rm -rf ~ or fork bomb takes the agent down with it. A fresh sandbox per task costs a few hundred milliseconds and keeps the agent out of reach.

Why noEnv for the task sandboxes?#

A noEnv sandbox gets none of your account's variables, files or credentials, and its built-in token can only read that sandbox and host its ports. Pass the task anything it needs explicitly with env.

What if the agent crashes between create and delete?#

The TTL is the backstop: we set 10 minutes, so an orphaned task sandbox stops on its own. sbx api-key usage <key id> --verbose lists every sandbox the agent's key created, so you can delete leftovers.

Should the agent reuse one task sandbox to save time?#

Only for tasks from the same user that build on each other. The trade-off, with measurements, is in a fresh sandbox per tool call.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide