Run your agent next to a computer
Your agent does not run on the Mandala computer. It runs wherever you host code, calls a model, and drives the computer over the API: a screenshot, a click, a command. The computer is the agent's screen and hands; the process that decides what to do is yours.
This guide puts a small worker on three hosts: Fly.io, DigitalOcean and Hetzner Cloud. The same program and the same rules apply anywhere else that runs a container or a long-lived process.
The worker
The program below is a loop that runs a task on a fresh computer every few minutes and deletes the computer when the task ends. The task opens a page and takes a screenshot; replace run_task with your agent, for example the loop from the Claude, OpenAI or Gemini guide.
Three things in it are about hosting rather than the task:
- Stopping. Platforms stop a process with a signal on every deploy, scale down and restart. Python's default on
SIGTERMis to exit at once, without runningwithblocks orfinally, so the computer would be left running. The handler turns the signal into a normal exit, andephemeral()deletes the computer on the way out.SIGINTalready works that way. - Leftovers. A
kill -9, an out-of-memory kill or a lost host runs no cleanup at all, and a delete can fail. Every computer the worker makes is named<AGENT_NAME>-<digits>. At start, the worker deletes every computer named exactly that way; a name with anything else after the prefix, such asagent-debugor another copy'sagent-prod-1700000000000000000, is left alone. The digits are the creation time in nanoseconds, so two copies never pick the same name. They are 19 digits long and the server cuts a name at 64 characters, soAGENT_NAMEis at most 44 characters; the worker refuses to start with a longer one, because a cut name would never match the one it tracks and its computer would never be swept. After each round of tasks it deletes only names it made itself and has not yet seen deleted, so a round never touches a computer another process is using. Still give each running copy of the worker its ownAGENT_NAME: a copy that starts while another with the same name is mid-task deletes that task's computer. - Logs. Output is flushed on every line, so the platform's log view shows it as it happens.
import os
import signal
import sys
import time
import mandala_computer
from mandala_computer import Client
PREFIX = os.environ.get("AGENT_NAME", "agent") + "-"
# The server cuts a computer name at 64 characters, and a cut name no longer
# matches the one this worker tracks, so its computer would never be swept.
if len(PREFIX) + 19 > 64: # 19 digits of time.time_ns()
sys.exit("AGENT_NAME is at most 44 characters: computer names are cut at 64")
INTERVAL = int(os.environ.get("AGENT_INTERVAL", "600"))
URLS = os.environ.get("AGENT_URLS", "https://example.com").split()
# A create whose answer was lost can still be making the computer, so a name
# that is not listed yet is kept this long before it is forgotten.
GRACE_NS = 3600 * 10**9
def stop(signum, frame):
# Docker and systemd stop a process with SIGTERM, and Python's default is to
# die on the spot, skipping every `with` and `finally`. Exiting instead lets
# ephemeral() delete the computer on the way out.
sys.exit(0)
def mine(name):
"""True for a name run_task makes: the prefix, then only digits."""
return name.startswith(PREFIX) and name[len(PREFIX):].isdigit()
def sweep(client, names=None):
"""Delete computers left behind: a kill -9 runs no cleanup, and a delete can fail.
With no names, every computer mine() matches. With names, only those.
"""
if names is not None and not names:
return
for computer in client.computers.list():
name = computer.name or ""
if mine(name) and (names is None or name in names):
print(f"deleting leftover {computer.id} ({name})", flush=True)
computer.delete()
if names is not None:
names.discard(name)
if names is not None:
# A name not listed may be a computer still being made; forget it only
# when it is older than GRACE_NS (the digits are its creation time).
now = time.time_ns()
for name in list(names):
if now - int(name[len(PREFIX):]) > GRACE_NS:
names.discard(name)
def run_task(client, url, undeleted):
"""One unit of work on a fresh computer. Replace this with your agent."""
name = f"{PREFIX}{time.time_ns()}" # unique, even beside another copy
undeleted.add(name) # until ephemeral() has deleted it
with client.computers.ephemeral(template="base", name=name) as computer:
computer.wait_for("computer.ready")
computer.open(url)
time.sleep(5)
png = computer.screenshot(fresh=True)
print(f"{url}: {len(png)} bytes of screenshot from {computer.id}", flush=True)
undeleted.discard(name)
def main():
signal.signal(signal.SIGTERM, stop)
client = Client() # reads MANDALA_API_KEY
sweep(client) # an error here exits, and the platform restarts the worker
undeleted = set() # names this process made and has not seen deleted
while True:
for url in URLS:
try:
run_task(client, url, undeleted)
except mandala_computer.MandalaError as e:
print(f"{url}: {type(e).__name__}: {e}", file=sys.stderr, flush=True)
try:
# A task's computer whose delete failed is still running and billed.
# Only this process's own names: during a deploy, a new copy of the
# worker with the same AGENT_NAME can already be running a task.
sweep(client, undeleted)
except mandala_computer.MandalaError as e:
print(f"sweep: {type(e).__name__}: {e}", file=sys.stderr, flush=True)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()Save it as agent.py and run it locally first with MANDALA_API_KEY=… python agent.py. client.computers.list() refuses to answer when part of the platform cannot be reached, rather than return a short list, so the sweep never mistakes an unanswered host for an empty one. At start the worker then exits and the platform restarts it; between tasks it logs the error and tries again next round.
For a container, this Dockerfile is enough. The CMD is in exec form, so Python is the container's first process and receives the stop signal itself rather than through a shell:
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir mandala-computer
COPY agent.py .
CMD ["python", "agent.py"]Pin mandala-computer to the version you tested with.
The API key is a secret
MANDALA_API_KEY can create and delete computers on your account. Keep it in the platform's secret store, never in the image, the repository or a command-line argument that lands in shell history. Create a key for each deployment in Settings → Credentials → API keys, so you can revoke one without touching the others. A key confined to a workspace can only act in that workspace, which also keeps the sweep away from computers elsewhere on the account.
Your model provider's key belongs in the same store, beside it.
Pick a region near the API
Each action is a round trip between your worker and the Mandala API, and a computer-use task makes hundreds of them. Host the worker in a region close to the API. To compare candidates, time a few reads from each:
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w '%{time_total}\n' \
-H "Authorization: Bearer $MANDALA_API_KEY" \
https://app.mandala.computer/api/v1/computers
doneModel calls take far longer than API calls, so the model provider's region matters too; when the two disagree, measure the whole task.
Fly.io
A Fly app with no [http_service] or [[services]] section is a background worker: it runs, and nothing on the internet can reach it. Next to agent.py and the Dockerfile, write fly.toml:
app = "my-mandala-agent"
primary_region = "iad"
kill_signal = "SIGTERM"
kill_timeout = 60
[env]
AGENT_NAME = "fly-agent"primary_region is a Fly region; choose it as described above. Fly stops a machine with SIGINT and waits 5 seconds by default. The two kill_ lines give the worker time to delete its computer. Then create the app, set the secret, and deploy:
fly apps create my-mandala-agent
fly secrets set MANDALA_API_KEY="$MANDALA_API_KEY"
fly deploy --ha=false
fly logsfly secrets set stores the value encrypted and gives it to the app's machines as an environment variable. --ha=false makes one machine and no standby copy; the worker needs only one. See Fly's app configuration and secrets docs.
DigitalOcean
App Platform runs the Dockerfile as a worker, a component with no public HTTP. Describe it in an app spec:
name: mandala-agent
workers:
- name: agent
dockerfile_path: Dockerfile
instance_count: 1
github:
repo: your-org/mandala-agent
branch: main
deploy_on_push: true
envs:
- key: AGENT_NAME
value: do-agentCreate the app with doctl apps create --spec app.yaml. Then, in the control panel, add MANDALA_API_KEY to the worker's environment variables with Encrypt selected, so the value is stored encrypted rather than in the spec you commit.
On every deploy, App Platform starts the new instance before it stops the old one, so for a moment two copies share the name do-agent. The new copy's sweep at start deletes the computer of any task the old copy is still running; that task fails with an error in the old copy's log, and the stop signal that follows would have cut it short anyway. The old copy's sweeps after a round touch only computers it made, so the new copy's task is not affected. See the app spec reference and environment variables.
A Droplet is a plain Linux server: follow the Hetzner steps below.
Hetzner Cloud
On a small Hetzner Cloud server, or any Linux server with systemd, run the worker as a service. Create the server in the Cloud Console or with hcloud server create, with your SSH key and a current Ubuntu or Debian image; see Hetzner's getting started guide. Then, as root on the server:
apt-get update && apt-get install -y python3-venv
mkdir -p /opt/mandala-agent
python3 -m venv /opt/mandala-agent/venv
/opt/mandala-agent/venv/bin/pip install mandala-computer
# copy agent.py to /opt/mandala-agent/agent.py, then:
install -m 600 /dev/null /etc/mandala-agent.envPut the key in /etc/mandala-agent.env with an editor, one NAME=value per line, so it does not land in shell history:
MANDALA_API_KEY=com_...
AGENT_NAME=hetzner-agentand write the unit to /etc/systemd/system/mandala-agent.service:
[Unit]
Description=Mandala agent worker
Wants=network-online.target
After=network-online.target
[Service]
DynamicUser=yes
EnvironmentFile=/etc/mandala-agent.env
ExecStart=/opt/mandala-agent/venv/bin/python /opt/mandala-agent/agent.py
Restart=on-failure
RestartSec=10
TimeoutStopSec=60
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable --now mandala-agent
journalctl -u mandala-agent -fsystemd reads the environment file as root, and the worker runs as a throwaway user that cannot read it back. systemctl stop sends SIGTERM and waits TimeoutStopSec for the worker to delete its computer.
Cleaning up
- On a normal stop,
ephemeral()deletes the computer. Give the platform a stop timeout of a minute rather than a few seconds. - After a crash, the sweep at the next start deletes what is left.
- When a delete fails,
ephemeral()raises, or warns if the task had already failed, and the sweep after that round deletes the computer: the worker made it, so its name is still inundeleted. - When a create's answer is lost, a timeout or a dropped connection, the computer may still appear a little later. The worker keeps the name for an hour (
GRACE_NS) and deletes the computer in whichever round first lists it. - When you tear the deployment down, delete its computers too: stop the worker, then list computers in the dashboard or with
mandala computers listand delete any named with the worker's prefix. - Revoke the API key of a deployment you retire.
See also: Troubleshooting.