A working AI agent in about 90 lines of stdlib Python: tools, an allowlist, a step limit and a systemd timer, tested on a 2 vCPU VPS with no GPU.
An AI agent is a loop: you hand a language model a task and a short menu of tools, the model replies with "call this tool with these arguments", your code runs it and returns the result, and this repeats until the model answers in plain text. That is the entire idea, and you can build an AI agent around it with nothing but Python's standard library. Below is a working one that checks a server's disk and services, plus the parts tutorials usually skip: limits, a safety net for the model's mistakes and a systemd timer to run it unattended. We tested all of it on a 2 vCPU HIP-HOSTING VPS with 3.8 GB of RAM and no GPU, and we show what broke, including a crash in our first version.
In short. Run a small model behind a local OpenAI-style API, with its memory capped by systemd. Give the agent read-only tools that accept names from an allowlist, never commands or paths. Wrap the model in a bounded loop. Run the script as an unprivileged user from a hardened systemd service on a timer.
A plain chat model only produces text. An agent adds two things around it: tools (functions your code can run) and a loop. Many model servers, Ollama among them for models that support it, offer tool calling: you describe your functions in JSON, and instead of prose the model may answer with a structured request such as "run service_status with name set to nginx". The model never runs anything itself. Your code decides whether to run it, and what to hand back.
So the shape is always the same: send messages, read the reply, if it asks for tools then run them and append the results, send again. A reply with no tool request is the answer. Many frameworks wrap this in extra layers. For a single-purpose server agent you do not need them, and writing the loop yourself is the quickest way to see where the risk is.
You need a VPS with Python 3 and enough memory for a small model. Our lab box was a HIP-HOSTING hiplet (our name for a VPS): Ubuntu 24.04.5, 2 vCPU, 3.8 GB of RAM, no GPU and no swap. A VPS from HIP-HOSTING with 2 vCPU and 4 GB works for a 1.5B model. Larger models we did not try, and we cannot give you numbers for them. What we measured with qwen2.5:1.5b:
What | Measured | What it means |
|---|---|---|
Model download | 986 MB | Disk space, one time |
Ollama memory, light use | 1,230,311,424 B (about 1.15 GiB) peak | Budget more than a gigabyte for a 1.5B model |
Ollama memory, second run | 1,887,436,800 B (1.8 GiB), the cap we set | Includes page cache from the memory-mapped model file, so the figure climbs toward whatever ceiling you set |
Generation speed | 8.83 tokens/s (66 tokens) | A 66-token answer takes about 7.5 s |
Prompt processing | 36.07 tokens/s (40 tokens) | Long prompts and tool results cost real time |
One agent step | about 3 to 12 s once the model is loaded (23 s and 16 s on the first, cold run) | A two-step run takes 10 to 20 s warm and about 40 s cold |
A token is a chunk of text, roughly a short word or a piece of one. Those speeds suit a job that wakes up every few hours and are bad for anything a person waits on. For chat-like responsiveness you need a faster machine or a hosted model.
The agent talks to the model over HTTP. Ollama exposes an OpenAI-compatible endpoint (/v1/chat/completions, a request format many model servers copy), so the same script can later talk to another server. Installing Ollama is covered in our article Ollama on a VPS; we do not repeat it. One difference: in the lab, Ollama ran from the tar archive in /opt/aglab on port 11435, so it could not collide with anything else on the box. On a normal install the port is 11434, and the agent's AGENT_API setting below must say so.
The important part is the memory ceiling. A model is a big file read into RAM, and nothing stops it from taking what it can. We started Ollama under systemd-run, which launches a command as a temporary systemd service with resource limits passed as --property flags. This is the command as we ran it:
$ sudo systemd-run --unit=aglab-ollama --property=MemoryMax=1800M --property=MemorySwapMax=0 --property=CPUQuota=150% --setenv=OLLAMA_MODELS=/opt/aglab/models --setenv=OLLAMA_HOST=127.0.0.1:11435 --setenv=OLLAMA_KEEP_ALIVE=10m --setenv=HOME=/opt/aglab /opt/aglab/bin/ollama serveMemoryMax=1800M: a hard ceiling for the whole unit through cgroups, the kernel mechanism systemd uses for limits. Past it, the kernel first reclaims what it can (including cache) and, if that is not enough, kills processes inside the unit.MemorySwapMax=0: no swap for this unit. Our box had none anyway; the line makes it explicit.CPUQuota=150%: at most one and a half cores out of two, so the model cannot take both.OLLAMA_HOST=127.0.0.1:11435: listen on the local loopback only, on our lab port.OLLAMA_MODELS and HOME: keep the model files and Ollama's own files inside the lab folder.OLLAMA_KEEP_ALIVE=10m: how long the model stays loaded after the last request. The documented default is 5 minutes; we did not check that in the lab. After the unload the next request is slow again.The server is now up, so pull the model. The client needs to know our non-standard port; on a normal install, with the default port, a plain ollama pull qwen2.5:1.5b is enough.
$ OLLAMA_HOST=127.0.0.1:11435 /opt/aglab/bin/ollama pull qwen2.5:1.5bIf you installed Ollama the standard way, it already runs as a systemd service named ollama, and the same three limits belong in a systemd override for it (systemctl edit ollama). We did not test that variant. Check the limits and the binding. This is what we got:
127.0.0.1:11435 in the first line means only programs on this machine can reach the model. MemoryPeak is the high-water mark; here it touched the ceiling.
Save this section and the next in a single file, /opt/aglab/agent.py, in the order shown. First the settings and three tools. The agent has no shell tool: no "run this command" function, which would let a confused or manipulated model do anything your user can. Each tool does one fixed thing and accepts only values from a list you wrote in advance (an allowlist, as opposed to a blocklist of bad things you thought of).
#!/usr/bin/env python3"""Minimal agent: the model decides which tool to call, we run it and return the result, in a loop with a step limit.Standard library only. Model: any server with an OpenAI-compatible API (here Ollama on 127.0.0.1)."""import inspect, json, os, shutil, subprocess, sys, time, urllib.requestAPI = os.environ.get("AGENT_API", "http://127.0.0.1:11435/v1/chat/completions")MODEL = os.environ.get("AGENT_MODEL", "qwen2.5:1.5b")MAX_STEPS = int(os.environ.get("AGENT_MAX_STEPS", "6"))ALLOWED_SERVICES = {"nginx", "ssh", "cron"}ALLOWED_LOGS = {"syslog": "/var/log/syslog", "auth": "/var/log/auth.log"}def disk_usage(path="/"): u = shutil.disk_usage(path if path in ("/", "/var", "/tmp") else "/") return {"path": path, "used_percent": round(u.used / u.total * 100, 1), "free_gb": round(u.free / 2**30, 1)}def service_status(name): if name not in ALLOWED_SERVICES: return {"error": "service not allowed", "allowed": sorted(ALLOWED_SERVICES)} r = subprocess.run(["systemctl", "is-active", name], capture_output=True, text=True, timeout=20) return {"service": name, "status": r.stdout.strip()}def tail_log(name, lines=5): if name not in ALLOWED_LOGS: return {"error": "log not allowed", "allowed": sorted(ALLOWED_LOGS)} lines = max(1, min(int(lines), 20)) r = subprocess.run(["tail", "-n", str(lines), ALLOWED_LOGS[name]], capture_output=True, text=True, timeout=5) return {"log": name, "lines": r.stdout.splitlines()}AGENT_API, AGENT_MODEL, AGENT_MAX_STEPS are environment variables with defaults. On a standard Ollama install set AGENT_API to http://127.0.0.1:11434/v1/chat/completions.service_status refuses any name outside ALLOWED_SERVICES and passes the name to systemctl as a separate list item, never glued into a shell string. There is no shell to inject into.tail_log does not take a path at all. The model supplies a short name and the dictionary turns it into a path. A request for /etc/shadow is not a key, so nothing opens. It also caps lines between 1 and 20.disk_usage accepts only /, /var and /tmp and quietly falls back to / for anything else. A small flaw: the reply still echoes the path the model asked for. Harmless here, but a place to be stricter.subprocess.run has a timeout, so a stuck command cannot hang the agent forever.Next, the menu the model sees. It never sees your Python, only these descriptions, so names and one-liners are how it decides what to call.
TOOLS = {"disk_usage": disk_usage, "service_status": service_status, "tail_log": tail_log}SCHEMA = [ {"type": "function", "function": {"name": "disk_usage", "description": "Disk usage of a path", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": []}}}, {"type": "function", "function": {"name": "service_status", "description": "Is a systemd service active", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}}, {"type": "function", "function": {"name": "tail_log", "description": "Last lines of a log: syslog or auth", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "lines": {"type": "integer"}}, "required": ["name"]}}},]
The schema is a request, not a rule. A model can ignore it, as you will see in step 5. The allowlists in the code are the rule.
Now the part that makes it an agent. First a guard that every tool call passes through, then the loop. The guard looks odd until you read step 5, because our first version did not have it.
def call_tool(fn, args): """The model may invent an argument name or break a call: the error goes back to the model as a result, the agent does not crash.""" if fn is None: return {"error": "unknown tool"} allowed = set(inspect.signature(fn).parameters) extra = sorted(set(args) - allowed) if extra: return {"error": "unexpected arguments: %s" % ", ".join(extra), "allowed": sorted(allowed)} try:
return fn(**args) except Exception as e: return {"error": "%s: %s" % (type(e).__name__, e)}def chat(messages): body = json.dumps({"model": MODEL, "messages": messages, "tools": SCHEMA, "temperature": 0}).encode() req = urllib.request.Request(API, body, {"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=300) as r: return json.load(r)def run(task): messages = [{"role": "system", "content": "You are a server health agent. Use the tools to check facts, never guess. Answer briefly."}, {"role": "user", "content": task}] for step in range(1, MAX_STEPS + 1): t0 = time.time() resp = chat(messages) msg = resp["choices"][0]["message"] calls = msg.get("tool_calls") or [] print("step %d: %.1fs, tool_calls=%d, tokens=%s" % (step, time.time() - t0, len(calls), resp.get("usage", {}).get("completion_tokens"))) if not calls: print("ANSWER:", msg.get("content", "").strip()) return
messages.append(msg)
for c in calls: name = c["function"]["name"] args = c["function"]["arguments"] args = json.loads(args) if isinstance(args, str) else args fn = TOOLS.get(name) result = call_tool(fn, args) print(" tool %s(%s) -> %s" % (name, json.dumps(args), json.dumps(result)[:160])) messages.append({"role": "tool", "tool_call_id": c.get("id", ""), "content": json.dumps(result)}) print("STOP: step limit reached (%d)" % MAX_STEPS)if __name__ == "__main__": run(" ".join(sys.argv[1:]) or "Check the disk and whether nginx is running.")"temperature": 0 asks the model to pick its most likely words, so repeated runs stay as similar as possible. It does not guarantee identical output.timeout=300 lets one request wait up to five minutes; a CPU-only model can be slow. The systemd service in step 6 stops the whole run sooner.messages is the whole conversation. After each model reply, the reply itself and then each tool result are appended, because the model remembers nothing between requests except what you send again.for step in range(1, MAX_STEPS + 1) is the step limit. A model that keeps asking for tools would otherwise loop forever, burning CPU. The line costs nothing; leaving it out gives you an unattended process that never ends.if not calls: no tool requested means the model has finished, and its text is the answer.print lines are your audit log: every tool call with its arguments and the first 160 characters of the result.Run it with a task, or without one to use the default (check the disk and nginx):
Read it like a trace. In step 1 the model asked for two tools in one go; the agent ran both and fed the results back. In step 2 it replied with plain text, which ends the loop. The numbers in the answer match the tool results, which is what you check on every run. We captured this output from the journal of the systemd service (step 6), most likely with the model already loaded, so on your first run expect more: our first, cold run took about 23 s and 16 s for the two steps, against 5 s and 6 s warm.
A tutorial that shows only the happy path hides the useful half. We threw awkward requests at the agent. The outputs below are from our box.
We asked for the tail of /etc/shadow, the file with password hashes. The model did try:
The model guessed a name, the code looked it up, found nothing and returned the list of valid names. The file was never opened, because no function in the program opens an arbitrary path. The safety lives in code, not in a polite instruction to the model.
"Restart nginx" has no matching tool. A person would say "I can't". The model improvised and added a restart argument that does not exist. Version 1 of our agent called the function with fn(**args) directly, and Python objected:
For a script on a timer, a crash at 3 a.m. over a model's whim is a bug in the script. This is why call_tool from step 3 exists: it compares the arguments with the function's real signature (inspect.signature) and turns any leftover into an error message that the model receives as an ordinary tool result. Version 2 on the same request:
Two lessons sit in this block. The guard worked: nothing was restarted and nginx stayed active, because the agent has no restart tool at all. But read the answer. The tool result said the allowed argument is ["name"]; the model wrote that the allowed arguments are "name" and "restart". A 1.5B model can misreport what it just read, and it promised to try again, which it did not do, since the loop ends when a reply has no tool request. Treat the agent's text as a draft and the tool lines above it as the evidence.
Postfix was in fact running on that box, and the agent refused to look. That is the allowlist doing its job, and its price: to check another service, you add it to ALLOWED_SERVICES on purpose.
The tools ran, but the model never got to read the results, so there is no answer, only an honest STOP.
In version 1, systemctl is-active had a 5-second timeout. Once, while the model was using the CPU, the call went over:
subprocess.TimeoutExpired: Command '['systemctl', 'is-active', 'nginx']' timed out after 5 secondsWe raised it to 20 seconds. In version 2, call_tool catches any exception and returns it to the model as text, so the same event would now show up as an error result instead of a crash (read from the code, not reproduced). On a small box the model and your tools compete for the same two cores, so give short commands more slack than you would on an idle machine.
An agent earns its place when it runs without you. We use a systemd service (what to run) and a timer (when); our article on systemd timers vs cron covers the trade-off with cron. First an unprivileged user, so a mistake in the agent never has root behind it. The first command creates a system account with no login shell and a home folder inside the lab directory (the unit will hide /home), the second makes that folder:
Create /etc/systemd/system/aglab-agent.service with the text below, then run sudo systemctl daemon-reload so systemd sees the new file. "aglab" is simply our lab name; rename as you like.
[Unit]
Description=Server health agent (one run)[Service]
Type=oneshot
User=aglab
SupplementaryGroups=adm
WorkingDirectory=/opt/aglab
Environment=AGENT_MAX_STEPS=4
ExecStart=/usr/bin/python3 /opt/aglab/agent.py "Check the disk and whether nginx is running."TimeoutStartSec=180
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INETCapabilityBoundingSet=
MemoryMax=256M
Line by line:
Type=oneshot: the program runs once and exits; systemd treats the exit as completion.User=aglab and SupplementaryGroups=adm: the unprivileged account plus membership in adm, the group that on Ubuntu typically owns the files in /var/log, so tail_log should be able to read them. We did not run that path successfully in the lab, and it needs /var/log/syslog and /var/log/auth.log to exist (they come with rsyslog).WorkingDirectory and Environment=AGENT_MAX_STEPS=4: where the program starts, and a step limit of four for scheduled runs.ExecStart: the command with the task passed as an argument.TimeoutStartSec=180: systemd kills a run that takes over three minutes, whatever the code is doing. Four steps, even cold, fit comfortably.NoNewPrivileges=yes: the process and its children cannot gain more rights than they started with.ProtectSystem=strict: the whole filesystem is read-only for this service (except /dev, /proc, /sys). The agent writes nothing, so nothing is lost.ProtectHome=yes: /home and /root are inaccessible.PrivateTmp=yes: its own empty /tmp.PrivateDevices=yes: no real hardware devices, only harmless ones such as /dev/null.ProtectKernelTunables=yes and ProtectControlGroups=yes: kernel settings and cgroups are read-only.RestrictAddressFamilies=AF_UNIX AF_INET: only local sockets (systemctl talks to systemd through one) and IPv4 (the call to the model).CapabilityBoundingSet= with nothing after the equals sign: drops all special Linux privileges.MemoryMax=256M: the agent itself is small. We did not measure its memory; 256 MB is a ceiling that worked for us.Start it once by hand and read the journal. We ran this as root, so add sudo if you are not:
Deactivated successfully tells you the run ended cleanly. The agent's answer lives only in the journal; sending it to a chat or an email is the next step and we did not build it. Now ask systemd how exposed the service is:
The score runs from 0 (nothing exposed) to 10 (wide open); 4.7 is labelled OK. It is a hint, not a certificate: the tool counts which restrictions you switched on, not whether your code is sound. Now the timer. Create /etc/systemd/system/aglab-agent.timer:
[Unit]
Description=Run the health agent every 6 hours[Timer]
OnCalendar=*-*-* 00/6:00:00Persistent=true
[Install]
WantedBy=timers.target
OnCalendar=*-*-* 00/6:00:00: every day, every six hours starting at midnight: 00:00, 06:00, 12:00, 18:00.Persistent=true: according to the systemd documentation, if the machine was off at a trigger time, the missed run happens after it starts again. We did not test that.WantedBy=timers.target: makes the timer start at boot once enabled.Run sudo systemctl daemon-reload again and enable it:
One timing note. Every six hours the run will almost certainly be cold: the model unloads after its keep-alive period (documented default 5 minutes; we used 10) and has to load again, so budget about 40 seconds as in our first run. That conclusion comes from the documentation, not from a lab measurement. Last, check that the agent's account cannot read secrets. This tests the user's file permissions, not the service sandbox:
tail_log hands log lines straight to the model, and some log lines contain text an outsider can influence (a failed SSH login records the username that was tried, for example). An instruction hidden in such a line could steer a model. This is a real risk of the design (prompt injection); we did not try to reproduce it. What limits the damage is the first rule: the tools can only read a few fixed things.The model is the expensive part: about 1.15 GiB at light use, up to the 1.8 GiB ceiling in a heavier run. In our test, parallel requests to the model exhausted memory and the server stopped answering for a while. So cap the model with MemoryMax and leave headroom for the system, send it one request at a time, and do not share the box with production services.
The script is not tied to Ollama: any server with the same request format works if you change the address, the model name and, for hosted services, add a key header (our chat() sends none). We did not test any paid API. Our rule of thumb: start local for private, repetitive checks where speed does not matter. A hosted model keeps your server's memory free and answers faster, but your task text and tool results leave the machine and you pay per token. Larger models are generally better at following tool formats; we did not compare. Either way, the allowlists and limits stay in your code. To build workflows visually instead, see n8n on a VPS; for a ready-made interface over local models, AnythingLLM on a VPS.
Write three things: functions the agent may call, a JSON description of them for the model, and a loop that sends the conversation, runs the tools the model asks for and sends the results back until a reply has no tool request. In Python's standard library that took us about 90 lines, including limits and error handling.
Yes, with a small model. We ran a 1.5B model on 2 vCPU with no GPU at about 8.83 tokens per second, which suits a scheduled check and not a live chat, and it peaked at 1.15 to 1.8 GiB of memory. We did not test larger models.
Not if you give it a shell. Give it fixed tools that accept only names from an allowlist, run it as an unprivileged user in a hardened systemd service, and cap steps and time. Even then, text a tool returns (log lines) is untrusted input that can try to steer the model.
When you need stronger reasoning or faster responses than a small CPU-only model gives, or when you cannot spare the memory. The trade-offs are cost per token and your data leaving the server. The same script needs a key header for that, which we did not add or test.
systemd-analyze security), cap the model's memory and send it one request at a time.Not tested here: larger models, paid APIs, other systems than Ubuntu 24.04, tools that change anything, and a successful tail_log run against a real log. If you want a box for this, a HIP-HOSTING VPS with 2 vCPU and 4 GB of RAM is what we ran it on: start one at hip.hosting, keep it apart from your production services, and put the model behind the memory cap before the first request.