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:

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
done

Model 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 logs

fly 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-agent

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

Put 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-agent

and 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.target
systemctl daemon-reload
systemctl enable --now mandala-agent
journalctl -u mandala-agent -f

systemd 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

See also: Troubleshooting.