DEV Community

Dakota Wu
Dakota Wu

Posted on

Score Tool-Call Origins Before the Indie API Opens a Socket

A solo founder can keep an agent-drafted API at a zero bill by scoring every host inside tool-call arguments before the process is allowed to open a socket. Function-calling JSON is not a comment. It is an origin list, and unpaid SaaS hostnames show up there first.

The rest of this article is a concrete ledger: a small classifier, a fixture, and a fail-closed exit code. Remove any later product mention and the method still works.

Tool calls leak origins

Most coding agents emit a stable shape when they call an API. A name. A JSON object. Sometimes a base_url. Sometimes a full https:// string buried in a header map.

That is enough to create a metered invoice once a demo is left running. The founder who needs to ship today should treat the dump as the source of truth. Logs after the fact are late. DNS after the fact is late. The arguments are available before the TCP handshake.

The dump shape matches common tool-calling records used by hosted model APIs. Field names name and arguments are documented in OpenAI's function calling guide. A typical two-line dump looks like this.

{"id":"call_01","name":"http.request","arguments":{"method":"POST","url":"https://api.stripe.com/v1/payment_intents","json":{"amount":499}}}
{"id":"call_02","name":"http.request","arguments":{"method":"GET","url":"http://127.0.0.1:8787/health"}}
Enter fullscreen mode Exit fullscreen mode

Call 01 is a paid origin. Call 02 is local. The ledger's only job is to say so, in a way a shell can fail.

Origin classes

Keep three classes. Do not add a fourth until a real need appears.

Class Meaning Action
allow loopback, localhost, explicit local hostname keep
deny known metered product hosts fail the session
unknown everything else fail closed, then stub

The deny list is a text file the founder edits. It is not a market map. It is the set of hostnames that have already surprised this project.

# allow_origins.txt
127.0.0.1
localhost
::1
api.local.test
Enter fullscreen mode Exit fullscreen mode
# deny_origins.txt
api.stripe.com
api.openai.com
api.sendgrid.com
hooks.slack.com
*.amazonaws.com
*.googleapis.com
Enter fullscreen mode Exit fullscreen mode

Wildcards are suffix matches. They are crude. Crude is the point. A solo ship does not need a full public-suffix parser on day one.

Workflow

Follow the steps in order. Skip none.

  1. Capture the dump. Configure the agent, or a thin proxy in front of it, to append every tool call as one JSON object per line. Save the file as agent_tools.jsonl. Keep one object per line so later diffs stay small.
  2. Freeze the two lists. Commit allow_origins.txt and deny_origins.txt next to the API. A later agent patch that adds a hostname must also change a list, which makes the change visible in review.
  3. Run the ledger. python3 toolcall_ledger.py --dump agent_tools.jsonl --allow allow_origins.txt --deny deny_origins.txt. Exit 0 means every extracted origin is on the allow list. Exit 2 means deny or unknown.
  4. Read the table, not the prose. The script prints call_id, origin, and class. The founder decides whether an unknown origin is a missed allow entry or a stub candidate. Guessing is not a class.
  5. Stub unknowns. Replace the live URL in the tool arguments with http://127.0.0.1:8787/... and implement the smallest JSON response the demo needs. Re-run the ledger. Repeat until exit 0.
  6. Only then start the API process. If a new tool call appears at runtime, append it to the dump and run the ledger again. The demo does not get a special case.

The ledger

The script below is standard-library Python 3. A founder can run it as-is on a laptop. It does not execute tool calls. It only classifies origins already present in the dump.

#!/usr/bin/env python3
"""Classify hosts found in agent tool-call arguments. Exit 2 on deny/unknown."""

from __future__ import annotations

import argparse
import ipaddress
import json
import re
import sys
from pathlib import Path
from urllib.parse import urlparse

URL_RE = re.compile(r"https?://[^\s'<>]+", re.I)
HOSTISH_KEYS = {"url", "uri", "base_url", "baseurl", "host", "hostname", "endpoint"}


def load_list(path: Path) -> list[str]:
    if not path.exists():
        return []
    lines = []
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        lines.append(line.lower())
    return lines


def walk(obj, bag: list[str]) -> None:
    if isinstance(obj, dict):
        for key, val in obj.items():
            if str(key).lower() in HOSTISH_KEYS and isinstance(val, str):
                bag.append(val)
            walk(val, bag)
    elif isinstance(obj, list):
        for item in obj:
            walk(item, bag)
    elif isinstance(obj, str):
        bag.extend(URL_RE.findall(obj))


def origin_of(value: str) -> str | None:
    text = value.strip()
    if not text:
        return None
    if "://" not in text:
        text = "http://" + text
    parsed = urlparse(text)
    host = (parsed.hostname or "").lower().rstrip(".")
    return host or None


def suffix_match(host: str, pattern: str) -> bool:
    if pattern.startswith("*"):
        root = pattern[2:] if pattern.startswith("*.") else pattern.lstrip("*")
        return host == root or host.endswith("." + root)
    return host == pattern


def is_loopback(host: str) -> bool:
    if host in {"localhost", "localhost.localdomain"}:
        return True
    try:
        return ipaddress.ip_address(host).is_loopback
    except ValueError:
        return False


def classify(host: str, allow: list[str], deny: list[str]) -> str:
    if is_loopback(host) or any(suffix_match(host, p) for p in allow):
        return "allow"
    if any(suffix_match(host, p) for p in deny):
        return "deny"
    return "unknown"


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--dump", required=True, type=Path)
    parser.add_argument("--allow", required=True, type=Path)
    parser.add_argument("--deny", required=True, type=Path)
    args = parser.parse_args()

    allow = load_list(args.allow)
    deny = load_list(args.deny)
    worst = 0
    print("call_id\torigin\tclass")

    for line_no, raw in enumerate(args.dump.read_text(encoding="utf-8").splitlines(), 1):
        if not raw.strip():
            continue
        rec = json.loads(raw)
        call_id = str(rec.get("id") or rec.get("tool_call_id") or f"line-{line_no}")
        bag: list[str] = []
        walk(rec.get("arguments", rec), bag)
        origins = []
        seen = set()
        for item in bag:
            host = origin_of(item)
            if host and host not in seen:
                seen.add(host)
                origins.append(host)
        if not origins:
            print(f"{call_id}\t-\tno-origin")
            continue
        for host in origins:
            klass = classify(host, allow, deny)
            print(f"{call_id}\t{host}\t{klass}")
            if klass != "allow":
                worst = 2
    return worst


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Save the earlier two-line dump as agent_tools.jsonl and run the classifier.

python3 toolcall_ledger.py \
  --dump agent_tools.jsonl \
  --allow allow_origins.txt \
  --deny deny_origins.txt
echo "ledger_exit:$?"
Enter fullscreen mode Exit fullscreen mode

Expected rows: api.stripe.com classified deny, 127.0.0.1 classified allow, process exit 2. That is the correct failure for a ship-today demo that must not touch a card network.

Replace the Stripe URL with http://127.0.0.1:8787/v1/payment_intents in the dump. Run the command again. Exit 0 is the only green light.

A local stub the demo can hit

The stub is deliberately small. It is not a processor emulator. It is a single POST that returns the JSON the UI already expects.

#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/v1/payment_intents":
            self.send_error(404)
            return
        body = json.dumps({"id": "pi_local_0", "status": "succeeded"}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        return

if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8787), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

Bind loopback only. Binding 0.0.0.0 turns a laptop stub into an accidental public service. Start it in a second terminal, then point the rewritten tool call at it.

python3 local_intent_stub.py
curl -sS -X POST http://127.0.0.1:8787/v1/payment_intents -d '{}'
Enter fullscreen mode Exit fullscreen mode

The founder who needs a shareable demo URL can put the same handler on a free server later. The ledger still has to pass against that hostname. That means the hostname must be added to allow_origins.txt on purpose, not because an agent invented it.

Optional rewrite with a free coding model

Unknown origins still need a human decision. A free coding model can propose the stub path. It cannot be trusted to classify.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. Those two facts are the only product claims used here. A founder can paste the unknown rows plus the local handler into a free model and ask for a drop-in URL rewrite. The answer is a proposal. Feed the rewritten dump through toolcall_ledger.py again. If the model invents a new hostname, the ledger fails. That second pass is the control.

The free server option is useful only after exit 0, when someone else must hit the stub without a paid VPS. It is not a substitute for the allow list. Metered origins remain deny.

Founders who already have the ledger going green on a laptop can try that rewrite-and-serve path on MonkeyCode. No other step in this article depends on it.

Labeled prompt for the rewrite step, unexecuted until a human pastes it:

The ledger printed unknown or deny rows. Rewrite each tool-call URL
to http://127.0.0.1:8787/<same-path> and keep JSON field names.
Do not add hosts. Return only the rewritten jsonl lines.
Enter fullscreen mode Exit fullscreen mode

Limits

The classifier does not see hosts hidden in base64, protobuf, or a second hop after an HTTP redirect. It does not expand environment variables. A tool argument of "url": "$BILLING_BASE" will look origin-free and print no-origin. That is a gap. Put real URLs in the dump, or extend walk() to resolve a documented env file.

Suffix rules mis-handle some public suffixes. foo.s3.amazonaws.com matches *.amazonaws.com. A research hostname that happens to share a suffix will be denied. For a solo demo that is acceptable. For a platform company it is not.

The method also does not measure token spend, latency, or correctness of the stub. It answers one question: did this tool-call dump name a host the founder did not allow.

Who should not use this

Skip the ledger if the product already has an approved vendor list and a paid egress proxy. Skip it if the traffic is regulated health, card processing, or anything that needs a real processor rather than pi_local_0. Skip it if several services must share origins. A text file per repo will drift.

The approach is for a single founder who will ship a demo today, keep the bill at zero, and accept that the payment intent is fake until a later, deliberate integration. A green ledger is not production hardening. It is a gate that stops an agent from choosing a metered origin while the founder is still on a laptop.

Top comments (0)