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

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

Source: https://sandboxer.com/guides/always-on-agent-with-sandboxes-for-risky-tasks. Updated 2026-10-31.

**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](/guides/home-server-vs-cloud-vm-for-coding-agents) for where the agent itself lives, [a fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call) for fresh versus reused sandboxes and templates, [keep API keys out of an agent sandbox](/guides/keep-api-keys-out-of-an-agent-sandbox) for third-party keys, and [require human approval](/guides/require-human-approval-before-an-agent-runs-a-command) 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](#if-the-always-on-side-is-a-sandbox)). 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:

```bash
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"
```

```json
{"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:

```bash
SANDBOXER_API_KEY="$RUNNER_KEY" sbx api-key create escalate --preset admin --ttl 1d
```

```json
{"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](/docs/api-keys#scopes) 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.

```python
#!/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:

```bash
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=')"
```

```text
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:**

```python
import platform
print("sum of squares:", sum(i * i for i in range(10**6)))
print("ran on", platform.node(), platform.release())
```

```json
{"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:**

```python
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)
```

```json
{"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](/docs/api-keys#the-in-sandbox-key), 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](/guides/require-human-approval-before-an-agent-runs-a-command), where the same token got `403` on exec.

**3. A fork bomb:**

```python
import os
print("starting a fork bomb", flush=True)
os.execvp("bash", ["bash", "-c", ":(){ :|:& };:; sleep 60"])
```

```json
{"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](/guides/limit-cpu-and-memory-for-untrusted-code-inside-a-sandbox).

**And the always-on side afterward:**

```json
{"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](/docs/limits) 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](/guides/how-fast-does-a-cloud-sandbox-start-resume-and-fork). Billing is per second while a sandbox runs; see [pricing](/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](/docs/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](/docs/long-running) and [how long an agent sandbox can run](/guides/how-long-can-an-agent-sandbox-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](/guides/a-fresh-sandbox-per-tool-call).

## Related

- [Should each agent tool call get a fresh sandbox?](/guides/a-fresh-sandbox-per-tool-call)
- [Keep API keys out of an agent sandbox](/guides/keep-api-keys-out-of-an-agent-sandbox)
- [Require human approval before an agent runs a command](/guides/require-human-approval-before-an-agent-runs-a-command)
- [API keys](/docs/api-keys)
