AI Agents·8 min read·

Tool Calling Without a Framework on Linux VPS

Call tools from a chat model with a plain Python loop on Ubuntu: JSON schemas, a dispatch table, retries, and a hard stop without a heavy agent framework.

NB

Netbay Developer Relations

Netbay Engineering

On this page

You do not need a framework to give a model tools. You need an HTTPS client, a JSON schema per tool, a dispatch table, and a loop that refuses unknown names. Frameworks add tracing dashboards, retry policies, and a pile of abstractions that fail in the same place: the model asked for a tool you did not intend to expose. On a Linux VPS that runs nginx and a few systemd units, that failure is a deleted file, not a demo gif.

This post is the minimum viable tool loop. It talks to an OpenAI-compatible Chat Completions API. The process runs as a dedicated user. The model is remote. Your box in Lucknow does the acting.

Why Skip the Framework First

A framework is a bet that you will outgrow a 120-line loop. Most ops bots never do. They have six tools, a step cap, and a JSONL log. Adding LangChain or a similar stack means you debug someone else's message-format adapter when the API adds a field.

Write the loop first. Promote it to a library when you have two agents sharing tools, not before. The skill you want is reading a tool_calls array, not memorizing a vendor's AgentExecutor class.

The other reason is blast radius. Frameworks love a generic "run shell" tool because demos look impressive. You should never ship that. Named tools with tight schemas are the whole safety story, and they are easier to keep tight when you own the dispatch dict.

Describe Tools the API Already Understands

Chat Completions tool calling expects a list of objects with type function, a name, a description, and a JSON Schema for parameters. Keep names stable. Keep descriptions operational, not marketing. The model uses the description to decide whether to call the tool; your code uses the schema to reject junk.

python
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "read_unit_status",
            "description": "Read systemd status for one unit name. No glob. No pipes.",
            "parameters": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "unit": {
                        "type": "string",
                        "pattern": "^[a-zA-Z0-9_@.-]+\.service$",
                    }
                },
                "required": ["unit"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "http_get_health",
            "description": "GET a local health URL and return status plus a 2 KB body cap.",
            "parameters": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "path": {
                        "type": "string",
                        "enum": ["/health", "/ready", "/metrics"],
                    }
                },
                "required": ["path"],
            },
        },
    },
]

Two tools, no shell. The unit pattern blocks systemctl status '*' tricks. The health path is an enum, not a URL, so the model cannot SSRF out to a metadata endpoint. That is the design: if a parameter can be an enum, it should be an enum.

Dispatch, Validate, Truncate

The API may return arguments as a JSON string. Parse it. Then validate again in your process. Do not trust the model to honor the schema. Treat additionalProperties as a hard error even if the provider claims to strip them.

python
import json
import subprocess
import urllib.request

ALLOWED_UNITS = {"nginx.service", "caddy.service", "app.service"}

def read_unit_status(args):
    unit = args["unit"]
    if unit not in ALLOWED_UNITS:
        return "error: unit not in allowlist"
    proc = subprocess.run(
        ["systemctl", "show", unit, "--property=ActiveState,SubState,MainPID"],
        capture_output=True,
        text=True,
        timeout=5,
        check=False,
    )
    out = (proc.stdout or "") + (proc.stderr or "")
    return out[:4000]

def http_get_health(args):
    path = args["path"]
    url = "http://127.0.0.1:8080" + path
    try:
        with urllib.request.urlopen(url, timeout=3) as resp:
            body = resp.read(2048).decode("utf-8", "replace")
            return "status=%s body=%s" % (resp.status, body)
    except Exception as exc:
        return "error: %s" % exc

DISPATCH = {
    "read_unit_status": read_unit_status,
    "http_get_health": http_get_health,
}

def run_tool(call):
    name = call["function"]["name"]
    if name not in DISPATCH:
        return "error: unknown tool"
    try:
        args = json.loads(call["function"]["arguments"] or "{}")
    except json.JSONDecodeError:
        return "error: arguments are not JSON"
    if not isinstance(args, dict):
        return "error: arguments must be an object"
    return DISPATCH[name](args)

Unknown tools return an error string, not an exception that kills the loop. Truncation is mandatory: a journal dump can be megabytes and will blow your next prompt. Timeouts are mandatory: a hung systemctl becomes a hung agent, and a hung agent becomes a surprise invoice.

Retries Belong Around the API, Not Around Destructive Tools

Retry the chat HTTP call on 429 and 502. Do not retry a tool that restarts a service unless the tool is idempotent and you recorded a tool_call_id you already executed. Store executed ids in a set for the run. If the model repeats the same id, return the cached observation.

Run this as a systemd service with EnvironmentFile for MODEL_API_KEY. Never put the key in the repo. The VPS already has L3/L4 DDoS filtering in front of public ports; keep the agent bound to localhost if it exposes an HTTP trigger, and put SSH keys in front of humans.

Log the tool name, the argument JSON, the duration, and the truncated result for every call. When something restarts twice, that log is the only way to see whether the model asked twice or your retry wrapper ran twice. A framework dashboard is optional. A JSONL file on High-Speed SSD is not. Intel Xeon Platinum will not notice the extra writes; you will notice the missing ones during an incident.

If a tool hangs past its timeout, kill it and return "error: timeout". Do not let urllib or subprocess inherit a 30-minute default. The model will sit on that observation and you will pay for a prompt that contains nothing useful. Lucknow DC01 latency to a public model API is already the long pole. Local hangs should never be.

Framework-free tool call path Chat API tool_calls JSON Parse + schema reject extra keys Dispatch dict name must exist Tool process timeout + truncate What you own on the VPS Allowlists, enums, subprocess timeouts, JSONL traces No generic shell tool, no framework adapter Unknown name returns an error string, never exec Model API is remote; acting stays local

Takeaway

Tool calling is a JSON schema, a dict, and a loop. Own those three and you can add tracing later without inheriting a generic shell. Spin up an Ubuntu VPS on Netbay in Lucknow and wire the loop against a real systemd unit at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance