Running an AI agent 24/7 on a VPS

An agent looping on your laptop is not a service. Here is what to add so it survives a reboot, an API outage, and your invoice.

Agent loop connecting the model, its tools, and its observations

An agent is a loop: the model thinks, calls a tool, reads the result, and repeats until the task is done. Writing that loop has become easy, the libraries provide it. What is left is everything separating a script that works on your laptop from a service that runs unattended: restarts, secrets, logs, recovery, and a spend ceiling.

A VPS is the right substrate for that: the agent runs continuously, on a stable IP address and a fixed bill, independent of your laptop.

1. The agent itself

Here is a complete loop in about thirty lines. Tools are plain Python functions: the decorator derives the schema from the signature, and the tool runner chains calls and results through to the final answer.

agent.py
# /srv/agent/agent.py
import logging
import anthropic
from anthropic import beta_tool

log = logging.getLogger("agent")
client = anthropic.Anthropic()   # reads ANTHROPIC_API_KEY from the environment


@beta_tool
def disk_usage(path: str = "/") -> str:
    """Report disk usage for a mount point.

    Args:
        path: The mount point to inspect, for example "/" or "/var".
    """
    import shutil
    total, used, free = shutil.disk_usage(path)
    return f"{path}: {used // 2**30} GiB used of {total // 2**30} GiB"


def run(question: str) -> str:
    runner = client.beta.messages.tool_runner(
        model="claude-opus-5",
        max_tokens=8000,
        tools=[disk_usage],
        messages=[{"role": "user", "content": question}],
    )
    final = None
    for message in runner:
        final = message
        log.info("turn complete, output tokens: %s", message.usage.output_tokens)
    return next(b.text for b in final.content if b.type == "text")

A tool's description matters as much as its code: it is what the model uses to decide whether to call it. Describe when to use it, not only what it computes.

2. Get secrets out of the repository

An API key in the code ends up in Git history, in logs, and in backups. systemd can load an environment file whose permissions keep other accounts out.

bash
sudo useradd --system --home /srv/agent --shell /usr/sbin/nologin agent
sudo install -d -o agent -g agent -m 750 /srv/agent /var/lib/agent

sudo install -o root -g agent -m 640 /dev/null /etc/agent.env
sudo tee /etc/agent.env <<'EOF'
ANTHROPIC_API_KEY=sk-ant-...
EOF

Mode 640 with group agent lets the service read the file while closing it to every other account on the machine. The file lives outside the repository, which is the whole point.

3. The systemd unit, hardened

The unit file does two things: restart the service when it dies, and shrink what it can reach if it is compromised.

bash
sudo tee /etc/systemd/system/agent.service <<'EOF'
[Unit]
Description=AI agent
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=agent
Group=agent
WorkingDirectory=/srv/agent
EnvironmentFile=/etc/agent.env
ExecStart=/srv/agent/.venv/bin/python -u agent.py

Restart=on-failure
RestartSec=10s
StartLimitBurst=5
StartLimitIntervalSec=300

# hardening: the service sees almost nothing of the system
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
ReadWritePaths=/var/lib/agent

SyslogIdentifier=agent

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now agent

The StartLimitBurst / StartLimitIntervalSec pair is the guard rail people most often skip. Without it, an agent that crashes at startup, bad key, missing dependency, is restarted forever, fills the logs, and, if it reaches the API before dying, spends budget on every attempt. With it, systemd gives up after five failures in five minutes and leaves the service in a visible failed state.

ProtectSystem=strict mounts the whole system read-only; ReadWritePaths reopens the one tree the agent needs. If the service stops starting after this hardening, it is almost always a missing writable path, journalctl -u agent names it.

4. Logs you can actually use

The -u in ExecStart disables Python's output buffering: without it, messages reach journald in blocks, sometimes minutes late. On the application side, write to standard output and let systemd handle the rest.

python
logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s %(name)s %(message)s",   # no timestamp: journald adds one
)
bash
journalctl -u agent -f                    # follow live
journalctl -u agent --since "1 hour ago"  # recent window
journalctl -u agent -p err                # errors only

Always log three things: the name of every tool called with its arguments, the tokens spent per turn, and each response's stop reason. The first two explain the bill; the third explains strange behaviour.

5. Error recovery

The SDK already retries 429 and 5xx errors with exponential backoff, two attempts by default. What it does not do is separate what deserves another attempt from what deserves none.

python
import time
import anthropic

client = anthropic.Anthropic(max_retries=5)   # instead of 2

def ask(question: str) -> str | None:
    try:
        return run(question)
    except anthropic.RateLimitError as exc:
        delay = int(exc.response.headers.get("retry-after", "60"))
        log.warning("rate limited, sleeping %ss", delay)
        time.sleep(delay)
        return None
    except anthropic.APIConnectionError:
        log.warning("network unavailable, will retry next cycle")
        return None
    except anthropic.BadRequestError:
        log.exception("invalid request, retrying will not change anything")
        raise

That last branch matters most: an invalid request is a bug on your side. Retrying it in a loop hides the problem and burns budget. Let the service fail, let StartLimitBurst stop it, and fix the cause.

6. Cap the spend

An agent in a loop can consume far more than expected, especially if it loops on a failing tool. Three guard rails, from blunt to precise.

Count first. Accumulate tokens per turn and stop past a daily threshold:

python
DAILY_CAP = 2_000_000   # output tokens

spent = 0
for message in runner:
    spent += message.usage.output_tokens
    if spent > DAILY_CAP:
        log.error("daily cap reached, stopping")
        break

Estimate before sending. On variable-size input, a document, a log, a web page, count tokens before the call rather than discovering the bill after:

python
count = client.messages.count_tokens(
    model="claude-opus-5",
    messages=[{"role": "user", "content": content}],
)
if count.input_tokens > 200_000:
    log.warning("input too large (%s tokens), chunking", count.input_tokens)

Tune the effort. The effort parameter governs reasoning depth and therefore cost. A mechanical task does not need the top setting:

python
client.messages.create(
    model="claude-opus-5",
    max_tokens=4000,
    output_config={"effort": "low"},    # low | medium | high | xhigh | max
    messages=[{"role": "user", "content": "Classify this ticket: bug, question, or request."}],
)

Prompt caching. If your agent resends the same system prompt every turn, cache it: later reads cost a fraction of full price. A cache_control={"type": "ephemeral"} at request level is enough, provided the prefix is byte-identical between calls. A timestamp slipped into the system prompt invalidates the cache every single time.

7. Check that the service holds

bash
systemctl status agent --no-pager
systemctl show agent -p NRestarts        # restarts since boot
journalctl -u agent -p err --since today
sudo -u agent env | grep -c ANTHROPIC    # is the key really absent from the shell?

The NRestarts counter is the best health signal: if it climbs, something is failing silently and being restarted. A stable service shows zero.

Checklist

  • Dedicated system account with no login shell.
  • Keys in a file outside the repository, 640 root:agent.
  • Restart=on-failure with a StartLimitBurst that bounds the loop.
  • systemd hardening, with ReadWritePaths cut to the minimum.
  • python -u for real-time logs.
  • Errors separated: retryable, deferred, fatal.
  • Token cap, pre-send counting, and effort matched to the task.
  • NRestarts monitored.