SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-host the SandBase agent runtime

Run SandBase Harness v0.3.2 on your own VPS: tagged install, agent YAML, MCP servers, sandbox modes, and pointing the Anthropic SDK at your box.

What you get when you self-host the SandBase agent runtime

Self-hosting the SandBase agent runtime means running SandBase Harness on a server you own, so sessions, credentials, memory and audit trails sit on your disk instead of someone else's. It is a Node service. It listens on 127.0.0.1:3000, serves a /v1 HTTP API and a web console, and keeps its state in SQLite next to your agent files.

The /v1 API is shaped after Claude Managed Agents (CMA), the hosted managed-agent API. That is what makes this runtime interesting in both directions: you can write code against the Anthropic SDK and point its baseURL at your own box, then move the same code to a hosted deployment later.

SandBase Harness does not ship a model. It calls one. As of August 2026 it supports OpenAI, Anthropic, and OpenAI-compatible endpoints, which covers self-hosted gateways and providers such as DeepSeek V4. You still bring an API key, or a local server that speaks the OpenAI API.

What you need before you start

  • A VPS running Ubuntu 24.04 with at least 2 GB of RAM. The TypeScript build is the heaviest step of the install.
  • Node.js 22 or newer, and npm 10 or newer. Both are hard minimums stated by the project.
  • git, plus an API key for whichever model provider you plan to use.
  • Docker, but only if you want per-session container sandboxes.

Ubuntu 24.04 ships Node 18.19 in its own repository, which is below the minimum, so take Node from NodeSource instead.

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs git
node -v
npm -v

node -v should print v22 or higher and npm -v should print 10 or higher. If node -v still prints v18.19.1, the distribution package is still installed and winning on PATH. Remove it before you continue, because the build runs against whichever node the shell finds.

Install SandBase from the v0.3.2 tag

Install from a tag, never from a moving branch. A bare clone of main gives you whatever landed an hour ago, and the config keys below may not match it. v0.3.2 is the current tag as of 16 August 2026.

sudo install -d -o "$USER" -g "$USER" /opt/sandbase
cd /opt/sandbase
git clone --branch v0.3.2 --depth 1 https://github.com/sandbaseai/sandbase-harness.git
cd sandbase-harness
npm ci
npm run build

Use npm ci, not npm install. ci installs the exact versions recorded in the committed lockfile, so your tree matches the tree the maintainers tested. npm install is allowed to resolve newer versions, which is how a pinned tag quietly stops being pinned.

Now create a workspace. The workspace is a separate directory that holds your agent files and all runtime state, and keeping it outside the source checkout means you can pull a newer tag without touching your data.

mkdir -p /opt/sandbase/workspace
cd /opt/sandbase/workspace
node /opt/sandbase/sandbase-harness/dist/index.js init
node /opt/sandbase/sandbase-harness/dist/index.js start

init writes a .managed-agents/ directory into the workspace. start brings up the console at http://127.0.0.1:3000/dashboard and the API at http://127.0.0.1:3000/v1. Neither is reachable from your laptop yet, which is correct and covered further down. Reach the console over SSH for now:

ssh -N -L 3000:127.0.0.1:3000 you@your-server

That long node .../dist/index.js path gets tiring, so give it a name.

alias sandbase='node /opt/sandbase/sandbase-harness/dist/index.js'

The commands below are written as sandbase <command> on that basis.

Do not install it from npm

The project says this in its own installation doc: the unscoped managed-agents package visible on npm is not this project. So npx managed-agents and npm install -g managed-agents fetch something unrelated to the runtime you want. Install from the tagged GitHub source until the maintainers announce an official scoped package. This is not a small footnote in the project's history: v0.3.1 exists mainly to replace the old npm quick start with the pinned tagged-source path.

Point the workspace at a model provider

init writes .managed-agents/config.yaml. One provider is configured for the whole workspace, and individual agents then choose concrete model IDs.

model:
  provider: openai
  api_key: ${OPENAI_API_KEY}
storage:
  metadata:
    provider: sqlite
    options: {}
  artifacts:
    provider: local
    options:
      base_path: files

The ${OPENAI_API_KEY} form takes the value from the process environment, so the key stays out of the config file and out of every backup you take of that file. Put it in an environment file that only root can read, since systemd reads EnvironmentFile= as root before it drops privileges.

sudo install -d -m 750 /etc/sandbase
sudo touch /etc/sandbase/runtime.env
sudo chmod 600 /etc/sandbase/runtime.env

Open that file in an editor and add one line, OPENAI_API_KEY=sk-.... Provider keys belong here. Secrets that an agent uses during a session belong in the runtime's credential vaults instead, which is a different problem with a different blast radius, and keeping secrets out of AI agents is worth reading before you paste a production token into either place.

The agent YAML: mcp_servers, tools and permission policies

Agents are defined as YAML files in the workspace agents/ directory. This is the part of the runtime you will actually spend time in.

name: Incident commander
description: Triages alerts and coordinates response.
model: gpt-4o
system: |-
  You are an on-call incident commander.
mcp_servers:
  - name: sentry
    type: url
    url: https://mcp.sentry.dev/mcp
tools:
  - type: agent_toolset_20260401
    default_config:
      permission_policy: { type: always_ask }
    configs:
      - name: bash
        permission_policy: { type: always_ask }
  - type: mcp_toolset
    mcp_server_name: sentry
metadata:
  template: incident-commander

Load it and check it landed:

sandbase reload
sandbase list
sandbase chat agent_assistant --message "hello"

reload imports the seed YAML into SQLite. list should now print the agent with an ID. If list does not show it, the file was not parsed, and .managed-agents/logs/runtime.log is where the reason is written.

mcp_servers declares MCP (model context protocol) endpoints. type: url means the runtime talks HTTP to a server that runs somewhere else, so anything you already operate works here, including MCP servers hosted on the same VPS as the runtime.

Declaring a server does not hand its tools to the agent. The tools list does that, through an mcp_toolset entry whose mcp_server_name matches the name above. If the agent behaves as though the MCP tools do not exist, compare those two strings character for character before you look anywhere else.

agent_toolset_20260401 is the built-in tool set. The dated suffix is a schema version, so an agent pinned to it keeps the tool definitions it was written against. default_config sets the policy for every tool in the set, and each entry under configs overrides one tool by name, bash in the example.

permission_policy is where a runtime earns its place over a bare model call. always_ask pauses the session and waits for a human to approve the call before it runs. always_allow lets it through. Setting bash to always_ask means the agent cannot run a shell command without you seeing the exact command first, which is the same control you would reach for when running Claude Code safely on a VPS.

The three sandbox modes, and when each one fits

Tool calls that execute code run inside a sandbox. The backend is chosen per environment, through sandbox_provider in the environment's config object, or under Settings then Sandbox in the console. Environments are created over the API at POST /v1/environments.

local runs the code as a child process of the runtime, on the host, as the runtime's own user. It is the default, and it is reasonable while you are the only user and the agent only reads files you own. It is not isolation. A tool call that deletes files deletes your files, and a tool call that reads /etc/sandbase/runtime.env reads your provider key.

docker starts one container per session.

{
  "sandbox_provider": "docker",
  "image": "node:22-slim",
  "resources": { "memory": "1g", "cpu": 1 }
}

The session gets its own filesystem, its own memory ceiling and its own CPU share, and the container is removed with the session. Switch to this the moment an agent runs code you did not write. The cost is that the runtime's user needs access to the Docker socket, and membership in the docker group is equivalent to root on the host. Per-session containers are the same shape as self-hosted agent sandboxes with one container per run, so the reasoning about what an escaped process could reach applies here unchanged.

kubernetes runs the session workload as a pod and drives it with kubectl exec and kubectl cp. The runtime image needs kubectl present, and its ServiceAccount needs RBAC (role-based access control) permission to create, delete, get, list and watch pods in the target namespace, plus the exec subresource. This mode is worth the setup only if you already run a cluster.

Why is the runtime bound to 127.0.0.1?

Because it starts with authentication off. The runtime enables bearer-token authentication when at least one API key exists, and a fresh init creates none. Binding to 0.0.0.0 on that default would put an unauthenticated agent runtime, holding shell tools and your provider key, on the public internet.

So when you want it reachable, leave the bind address alone and do two other things.

First, turn authentication on. Set MANAGED_AGENTS_API_KEY in the service environment file, or create a key with POST /v1/api-keys, which returns a secret_key field once and never shows it again. Clients then send Authorization: Bearer <key> on every request.

Second, put a reverse proxy in front and terminate TLS (transport layer security) there. The runtime serves plain HTTP by design and expects something else to handle certificates.

server {
    listen 443 ssl;
    server_name agents.example.com;

    ssl_certificate     /etc/letsencrypt/live/agents.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/agents.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection "";
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Two of those lines are not decoration. proxy_buffering off matters because sessions stream over server-sent events (SSE), and with buffering on, nginx holds the response until its buffer fills, so the console shows nothing while the agent works and then dumps everything at the end. proxy_read_timeout 3600s matters because the default is 60 seconds, so a stream that goes quiet for longer than a minute is closed by the proxy in the middle of a turn, and the failure looks like the runtime crashing.

On the firewall, open 22 and 443. Leave 3000 closed, because the proxy reaches it over loopback and nothing outside the box should.

Point the Anthropic SDK at your own box

The runtime implements a CMA-shaped /v1 surface, so an Anthropic SDK client talks to it with one field changed.

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({
  apiKey: process.env.MANAGED_AGENTS_API_KEY ?? 'local-dev-key',
  baseURL: 'http://127.0.0.1:3000'
});

It also accepts the beta headers that Claude Managed Agents clients send, anthropic-beta: managed-agents-2026-04-01 and anthropic-beta: agent-memory-2026-07-22. They are optional against a local runtime. They exist so code written for a hosted deployment runs unchanged here.

Compatibility is close, not total. Read docs/api-matrix.md in the checkout before you assume a surface exists, because the project documents its own gaps there, including client-side custom tools, which still need named registration above the current event-result protocol.

Plain HTTP works just as well, and is the quickest way to prove the runtime is alive:

curl -N -X POST http://127.0.0.1:3000/v1/sessions/SESSION_ID/messages \
  -H "Content-Type: application/json" \
  -d '{"content": "Hello", "stream": true}'

A healthy response is a stream of events that keeps arriving. If the connection drops, resume from the last event you saw instead of replaying the whole turn:

curl -N http://127.0.0.1:3000/v1/sessions/SESSION_ID/events/stream \
  -H "Last-Event-ID: EVENT_ID"

That resumable stream is why a session survives a closed laptop. The events are persisted on the server, so the client is replaying a log rather than holding the only copy.

Where credentials, memory and audit trails live on disk

Everything the runtime owns is under .managed-agents/ in the workspace.

.managed-agents/
├── config.yaml
├── data.db
├── logs/runtime.log
├── files/
├── skills/
├── snapshots/
└── sandbox/
  • data.db is the SQLite metadata: agents, sessions, credential vault entries, memory store entries and API keys.
  • files/ holds uploaded file bytes and skills/ holds uploaded skill packages.
  • snapshots/ holds session workspace snapshots, and sandbox/ holds the working directories of local-mode sessions.
  • logs/runtime.log is the first place to look whenever something silently does nothing.

Credential vaults are groups of secrets, each added with an auth_type such as environment_variable, and attached to a session through vault_ids when the session is created. Memory stores hold named entries that you mount into a session as a memory_store with its own access setting and instructions. Both live in data.db, which is exactly the difference between this and a raw model call: the runtime remembers across sessions, and it writes down what happened.

Because it is one directory, back it up as one.

sudo systemctl stop sandbase
sudo tar czf /root/sandbase-$(date +%F).tgz -C /opt/sandbase/workspace .managed-agents
sudo systemctl start sandbase

Stop the service first. Copying a SQLite database while the runtime is writing to it can capture a file that will not open on restore, and you find that out on the day you need it. If you would rather keep agent YAML in git and state elsewhere, the deployment doc supports pinning the state location with --data-dir on start.

Restoring is the reverse: check out the same tag on a fresh box, unpack the archive into the workspace, start the service. Your provider key is not in the archive if you used the ${OPENAI_API_KEY} form, so keep that somewhere you will still have it.

Run it under systemd

Give the runtime its own user so a tool call in local sandbox mode cannot act as you.

sudo adduser --system --group --no-create-home --home /opt/sandbase sandbase
sudo chown -R sandbase:sandbase /opt/sandbase

Save this as /etc/systemd/system/sandbase.service.

[Unit]
Description=SandBase Harness runtime
After=network-online.target

[Service]
User=sandbase
Group=sandbase
WorkingDirectory=/opt/sandbase/workspace
EnvironmentFile=/etc/sandbase/runtime.env
ExecStart=/usr/bin/node /opt/sandbase/sandbase-harness/dist/index.js start --host 127.0.0.1 --port 3000
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

The project's own deployment example calls a managed-agents binary on PATH. A tagged-source install does not create one, so ExecStart runs node against the built entry point instead.

sudo systemctl daemon-reload
sudo systemctl enable --now sandbase
sudo systemctl status sandbase
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/dashboard

A healthy result is active (running) from status and 200 from curl. Anything else, read journalctl -u sandbase -n 50 first and .managed-agents/logs/runtime.log second. enable --now is the half that matters, because a hand-started process is gone after the next reboot.

What breaks, and the message you will see

npm run build is killed with no error from npm. On a 1 GB VPS the TypeScript compile is stopped by the kernel out-of-memory killer, which reports it to the kernel log rather than to npm. Confirm with journalctl -k | grep -i "out of memory", which prints a line naming the killed node process. Add swap, or build on a larger instance and copy dist/ across.

Error: listen EADDRINUSE: address already in use 127.0.0.1:3000. Another process already holds the port. sudo ss -lntp | grep 3000 names it. Either stop that process or start the runtime with --port 3001 and update the proxy.

The dashboard will not load from your laptop. That is the intended behaviour, because the runtime binds to loopback. Use the SSH tunnel above, or finish the reverse proxy. Do not repair it with --host 0.0.0.0, because authentication is off until a key exists.

Docker sandboxes fail with permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock. The sandbase user is not in the docker group. Fix it with sudo usermod -aG docker sandbase and restart the service, and understand what you granted: that group is root on the host, so it undoes part of the reason you gave the runtime its own user.

Kubernetes sandboxes fail with Error from server (Forbidden). The ServiceAccount is missing pod permissions or the exec subresource. Check it directly with kubectl auth can-i create pods/exec -n <namespace>, which answers yes or no.

Every request returns 401 after you add an API key. Authentication switches on when the first key exists, and it applies to the console as well as the API. Send Authorization: Bearer <key>, and if you lost the key, create another one, because secret_key is returned once and is not stored in a readable form.

An MCP server's tools never appear in a session. Check the mcp_server_name in the tools block against the name in mcp_servers, then check the runtime can reach the URL from the server itself with curl -i <url>. A URL-type MCP server is a network dependency, and a VPS resolves names and routes traffic differently from your laptop.

FAQ

Can I run SandBase Harness without an OpenAI or Anthropic key?

Yes, if you have an OpenAI-compatible endpoint. The runtime supports OpenAI, Anthropic and OpenAI-compatible providers, so a local server that speaks the OpenAI API works. Set the workspace provider in .managed-agents/config.yaml and point api_key and the endpoint at it. The runtime includes no model of its own, so something has to answer the calls.

Is it safe to expose the runtime on a public port?

Not as installed. It binds to 127.0.0.1:3000 and starts with authentication off, and the fix is not a different bind address. Create an API key, or set MANAGED_AGENTS_API_KEY, so bearer-token authentication turns on. Then put nginx or Caddy in front for TLS, and keep port 3000 closed on the firewall so the only path in is through the proxy.

What is the difference between the local, Docker and Kubernetes sandboxes?

local runs tool code as a child process of the runtime on the host, with the runtime user's permissions and no isolation. docker gives each session its own container with its own filesystem, memory limit and CPU share, and removes it when the session ends. kubernetes runs the session as a pod and drives it with kubectl exec, which needs kubectl inside the runtime image and RBAC on pods plus the exec subresource in the target namespace.

What exactly do I need to back up?

The .managed-agents/ directory in the workspace. It holds config.yaml, the data.db SQLite database with agents, sessions, credential vault entries and memory entries, plus uploaded files, skill packages and session snapshots. Stop the service before copying it so SQLite is not written to mid-archive. Provider API keys referenced as ${OPENAI_API_KEY} are not inside the backup, so store those separately.

Why clone the v0.3.2 tag instead of main?

A tag is a fixed tree, so the config keys and CLI commands you read about are the ones you actually get. main moves, and a config key can be renamed between the time a guide is written and the time you run it. The project also warns that the unscoped managed-agents package on npm is not this project, so npx managed-agents installs something unrelated. Release v0.3.1 exists mainly to replace that npm quick start with the pinned tagged-source path.