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.
# /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.
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.
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.
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(name)s %(message)s", # no timestamp: journald adds one
)
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.
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:
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:
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:
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
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,
640root:agent. Restart=on-failurewith aStartLimitBurstthat bounds the loop.- systemd hardening, with
ReadWritePathscut to the minimum. python -ufor real-time logs.- Errors separated: retryable, deferred, fatal.
- Token cap, pre-send counting, and
effortmatched to the task. NRestartsmonitored.