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"}}
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
# deny_origins.txt
api.stripe.com
api.openai.com
api.sendgrid.com
hooks.slack.com
*.amazonaws.com
*.googleapis.com
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.
-
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. -
Freeze the two lists. Commit
allow_origins.txtanddeny_origins.txtnext to the API. A later agent patch that adds a hostname must also change a list, which makes the change visible in review. -
Run the ledger.
python3 toolcall_ledger.py --dump agent_tools.jsonl --allow allow_origins.txt --deny deny_origins.txt. Exit0means every extracted origin is on the allow list. Exit2means deny or unknown. -
Read the table, not the prose. The script prints
call_id,origin, andclass. The founder decides whether an unknown origin is a missed allow entry or a stub candidate. Guessing is not a class. -
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 exit0. - 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())
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:$?"
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()
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 '{}'
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.
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)