DEV Community

Dakota Wu
Dakota Wu

Posted on

Freeze the Agent Tool Catalog at Cost Class Zero

A solo founder can ship an agent-backed indie API the same day if every tool the model is allowed to invoke is classified as local or free before the process starts. Vendor SDKs, managed queues, and “temporary” paid search calls do not belong in that catalog. The workflow below freezes the catalog in a file the merge cannot ignore, dispatches every call through a dry-run ledger, and rejects a build when a new tool arrives without a zero-cost class.

This is not a host allowlist and not a socket probe. It is a catalog contract for tool calling: names, cost classes, call budgets, and a local timing bound. The agent may propose helpers. The process only runs the ones that already sit in the freeze file.

The constraint an indie agent actually has

Tool calling is how an AI-drafted handler reaches the rest of the world. A model that can register charge_card, embed_via_vendor, or publish_to_managed_queue will do so with confident names and tidy type hints. The founder who needs a bill of zero cannot review that as style. The founder has to treat the tool list as a billing surface.

Local functions are cheap. In-process SQLite is cheap. A static JSON file on disk is cheap. A second cloud account is not. The catalog freeze makes that ranking explicit so an agent patch cannot grow a side effect by adding one more @tool decorator.

Accept the limits up front. A frozen catalog will refuse useful paid APIs. That is the point for a same-day indie ship. Teams whose product is the paid vendor should not use this pattern.

Cost classes, not vibes

Three classes are enough for a laptop-scale API.

  1. local — pure Python or SQLite, no outbound network, no subprocess.
  2. free_net — an operator-named endpoint that the founder has already confirmed carries no invoice. The name is allowlisted in the freeze file, not inferred from a hostname heuristic at runtime.
  3. forbidden — anything else, including “we will swap it later.”

A fourth informal class, unknown, is treated as forbidden. Silence is not a discount.

The freeze file is the source of truth. Code that registers a tool missing from the file is a failed build, even if the function body looks harmless.

# tools.freeze.toml — commit this; do not generate it in CI
[meta]
api = "indie-agent-api"
max_tools = 12
max_calls_per_request = 8

[tools.now_iso]
cost_class = "local"
max_calls = 4
max_ms = 20

[tools.lookup_plan]
cost_class = "local"
max_calls = 2
max_ms = 40

[tools.fetch_public_status]
cost_class = "free_net"
allow_url = "https://status.example.invalid/health"
max_calls = 1
max_ms = 200
Enter fullscreen mode Exit fullscreen mode

Keep max_tools small. An indie agent that needs thirty tools is usually a disguised integration platform. Ship the twelve that close the demo. Leave the rest unregistered.

Step 1 — Load the freeze before the app object exists

The catalog must load in a module that imports no vendor SDK. Parse TOML, then refuse to boot if the file is missing, if two tools share a name, or if any free_net row lacks an exact allow_url.

# catalog.py
from __future__ import annotations

import tomllib
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Mapping

class CostClass(str, Enum):
    LOCAL = "local"
    FREE_NET = "free_net"
    FORBIDDEN = "forbidden"

@dataclass(frozen=True)
class ToolRow:
    name: str
    cost_class: CostClass
    max_calls: int
    max_ms: int
    allow_url: str | None

class CatalogError(RuntimeError):
    pass

def load_freeze(path: Path) -> Mapping[str, ToolRow]:
    raw = tomllib.loads(path.read_text(encoding="utf-8"))
    rows: dict[str, ToolRow] = {}
    for name, spec in raw["tools"].items():
        cost = CostClass(spec["cost_class"])
        if cost is CostClass.FORBIDDEN:
            raise CatalogError(f"{name} is frozen as forbidden")
        url = spec.get("allow_url")
        if cost is CostClass.FREE_NET and not url:
            raise CatalogError(f"{name} needs an exact allow_url")
        if cost is CostClass.LOCAL and url:
            raise CatalogError(f"{name} is local and must not carry a URL")
        rows[name] = ToolRow(
            name=name,
            cost_class=cost,
            max_calls=int(spec["max_calls"]),
            max_ms=int(spec["max_ms"]),
            allow_url=url,
        )
    if len(rows) > int(raw["meta"]["max_tools"]):
        raise CatalogError("freeze exceeds max_tools")
    return rows
Enter fullscreen mode Exit fullscreen mode

Boot order matters. Load the freeze, then import handlers. The reverse order lets a decorator register a tool the freeze never saw.

Step 2 — Register implementations against named rows only

Handlers do not self-register. A tiny registry maps freeze names to callables. An extra function on disk is fine. An extra name in the running map is not.

# registry.py
from collections.abc import Callable
from typing import Any

from catalog import CatalogError, ToolRow

Impl = Callable[..., Any]

class Registry:
    def __init__(self, freeze: dict[str, ToolRow]) -> None:
        self.freeze = freeze
        self._impls: dict[str, Impl] = {}

    def bind(self, name: str, fn: Impl) -> None:
        if name not in self.freeze:
            raise CatalogError(f"refusing unbound tool {name!r}")
        if name in self._impls:
            raise CatalogError(f"duplicate bind for {name!r}")
        self._impls[name] = fn

    def ready(self) -> None:
        missing = sorted(set(self.freeze) - set(self._impls))
        extra = sorted(set(self._impls) - set(self.freeze))
        if missing or extra:
            raise CatalogError(f"catalog skew missing={missing} extra={extra}")
Enter fullscreen mode Exit fullscreen mode

The ready() call sits next to app = FastAPI(...) or the Flask factory. If it raises, the process does not listen. A half-wired agent is worse than a crash at import time.

Step 3 — Dispatch through a dry-run ledger

Live tool calling burns retries. Models repeat a failing call with a slightly different payload. An indie process needs a per-request ledger: which tool, how many times, whether the call was a dry run, and whether the timing bound broke.

Label the following as a proposed in-process dispatcher, not a measured production runtime.

# dispatch.py
from __future__ import annotations

import time
from dataclasses import dataclass, field
from typing import Any

from catalog import CostClass, ToolRow
from registry import Registry

@dataclass
class LedgerEntry:
    name: str
    dry_run: bool
    elapsed_ms: float
    ok: bool
    detail: str

@dataclass
class RequestLedger:
    max_calls: int
    entries: list[LedgerEntry] = field(default_factory=list)

    def count(self, name: str) -> int:
        return sum(1 for e in self.entries if e.name == name)

class DispatchError(RuntimeError):
    pass

class Dispatcher:
    def __init__(self, registry: Registry, *, dry_run: bool) -> None:
        self.registry = registry
        self.dry_run = dry_run

    def call(self, ledger: RequestLedger, name: str, **kwargs: Any) -> Any:
        if len(ledger.entries) >= ledger.max_calls:
            raise DispatchError("request call budget exhausted")
        row: ToolRow = self.registry.freeze[name]
        if ledger.count(name) >= row.max_calls:
            raise DispatchError(f"{name} exceeded max_calls")
        if self.dry_run:
            ledger.entries.append(
                LedgerEntry(name, True, 0.0, True, "dry-run skip")
            )
            return {"dry_run": True, "tool": name, "kwargs": kwargs}
        started = time.perf_counter()
        try:
            result = self.registry._impls[name](**kwargs)
        except Exception as exc:  # local tools must fail visible
            elapsed = (time.perf_counter() - started) * 1000
            ledger.entries.append(
                LedgerEntry(name, False, elapsed, False, type(exc).__name__)
            )
            raise
        elapsed = (time.perf_counter() - started) * 1000
        ok = elapsed <= row.max_ms
        ledger.entries.append(
            LedgerEntry(name, False, elapsed, ok, "slow" if not ok else "ok")
        )
        if not ok:
            raise DispatchError(f"{name} took {elapsed:.1f}ms > {row.max_ms}ms")
        if row.cost_class is CostClass.FREE_NET:
            url = kwargs.get("url") or kwargs.get("endpoint")
            if url != row.allow_url:
                raise DispatchError(f"{name} url is not the frozen allow_url")
        return result
Enter fullscreen mode Exit fullscreen mode

Dry-run is the default in unit tests and in any “agent proposed a plan” path. Live dispatch is opt-in behind an environment flag the founder sets on the laptop, not a flag an agent patch can flip in source.

export INDIE_TOOL_LIVE=0   # dry-run ledger only
# export INDIE_TOOL_LIVE=1  # set by the founder, never by a generated .env
Enter fullscreen mode Exit fullscreen mode

Step 4 — Keep implementations boring

Local tools should look like this: no httpx, no SDK import, no subprocess.

# tools_local.py
from datetime import datetime, timezone
from pathlib import Path
import json

PLANS = Path(__file__).with_name("plans.json")

def now_iso() -> dict[str, str]:
    return {"now": datetime.now(timezone.utc).isoformat()}

def lookup_plan(plan_id: str) -> dict[str, object]:
    rows = json.loads(PLANS.read_text(encoding="utf-8"))
    for row in rows:
        if row["id"] == plan_id:
            return row
    return {"id": plan_id, "missing": True}
Enter fullscreen mode Exit fullscreen mode

A free_net tool, if it exists at all, takes no URL from the model. It uses the frozen URL as a constant.

# tools_net.py — optional; omit entirely for a stricter ship
from catalog import ToolRow

def make_status_fetcher(row: ToolRow):
    allowed = row.allow_url

    def fetch_public_status() -> dict[str, object]:
        # urllib is used only against the freeze-file constant.
        from urllib.request import urlopen
        with urlopen(allowed, timeout=1.5) as resp:  # labeled example
            return {"status": resp.status, "url": allowed}

    return fetch_public_status
Enter fullscreen mode Exit fullscreen mode

If the demo does not need a network check, delete free_net from the freeze. Zero network is easier to reason about than one “safe” URL.

Step 5 — CI that diffs the freeze, not the prose

Comments in a pull request will not stop a new tool. A job that compares the freeze file, the binds, and a static import scan will.

Proposed checks, to be run on the founder’s machine or any CI the founder already pays nothing for:

  1. python -c "from catalog import load_freeze; load_freeze(__import__('pathlib').Path('tools.freeze.toml'))"
  2. Bind every implementation and call registry.ready().
  3. Fail if git diff introduces openai, stripe, boto3, google.cloud, redis, or celery in files that also import the dispatcher.
  4. Run the dry-run test below.
# test_catalog.py
from pathlib import Path

from catalog import load_freeze
from dispatch import Dispatcher, RequestLedger
from registry import Registry
from tools_local import lookup_plan, now_iso

FREEZE = Path("tools.freeze.toml")

def build_registry() -> Registry:
    freeze = dict(load_freeze(FREEZE))
    freeze.pop("fetch_public_status", None)  # demo without net
    # A stricter test keeps only local rows.
    local = {k: v for k, v in freeze.items() if v.cost_class.value == "local"}
    reg = Registry(local)
    reg.bind("now_iso", now_iso)
    reg.bind("lookup_plan", lookup_plan)
    reg.ready()
    return reg

def test_dry_run_never_touches_lookup_disk(tmp_path, monkeypatch):
    reg = build_registry()
    disp = Dispatcher(reg, dry_run=True)
    ledger = RequestLedger(max_calls=8)
    out = disp.call(ledger, "lookup_plan", plan_id="pro")
    assert out["dry_run"] is True
    assert ledger.entries[0].dry_run is True

def test_live_lookup_stays_under_bound():
    reg = build_registry()
    disp = Dispatcher(reg, dry_run=False)
    ledger = RequestLedger(max_calls=8)
    row = disp.call(ledger, "lookup_plan", plan_id="missing")
    assert row["missing"] is True
    assert ledger.entries[0].ok is True

def test_budget_stops_retry_storm():
    reg = build_registry()
    disp = Dispatcher(reg, dry_run=True)
    ledger = RequestLedger(max_calls=3)
    for _ in range(3):
        disp.call(ledger, "now_iso")
    try:
        disp.call(ledger, "now_iso")
        raise AssertionError("budget should have tripped")
    except Exception as exc:
        assert "budget" in str(exc)
Enter fullscreen mode Exit fullscreen mode

The live timing assertion is a smoke bound for in-process work. It is not an API load test and not a p95 claim about production. If lookup_plan grows into a remote call, the bound should trip, and the freeze should lose that row until the work is local again.

Decision table for a same-day ship

Agent proposal Cost class Action
Read plans.json local Bind and freeze
Write SQLite row local Bind; keep WAL on the laptop
httpx.get to a URL the model supplies forbidden Leave unregistered
Managed queue publish forbidden Replace with a disk spool
Operator-confirmed zero-invoice health URL free_net Freeze the exact URL
Embedding vendor forbidden Precompute a local file
Retry the same tool 30 times n/a Ledger max_calls stops it

The table is the product decision. The code only enforces it.

Where a free coding box fits

Drafting the freeze file, the dispatcher, and the tests still costs tokens and a machine. A solo founder who wants that loop without standing up a billed cloud workspace can do the drafting on MonkeyCode’s free model access and free server option, then run the same pytest on the laptop before anything listens on a public port. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The catalog contract above does not depend on that environment; it runs as ordinary Python. Founders who already have a local toolchain can ignore the server and keep the freeze file anyway.

Do not treat “free” as unlimited, permanent, or fast enough for a public launch. Copy the artifact out. Run it where the founder can see the ledger.

Limitations

The freeze is a convention enforced by import order, a TOML file, and tests. It is not a sandbox. A local tool that calls os.system can still create a bill. A free_net URL can start charging if the operator’s assumption about the endpoint was wrong. Timing bounds on a quiet laptop say nothing about a noisy VPS.

DNS, redirects, and HTML meta refresh are out of scope. The allow_url check is string equality on the argument the dispatcher sees, not a proof of the bytes on the wire. Multi-tenant products, PCI flows, and anything that must call a paid vendor to exist should not pretend a cost class of local is a control boundary.

Retry budgets are per request object. They do not coordinate across processes. Two gunicorn workers can double the side effects unless the implementation is idempotent on disk.

Who should not use this

Skip the pattern if the API’s value is the paid integration. Skip it if a compliance team needs syscall isolation rather than a TOML file. Skip it if the founder cannot name every tool in one screen. Skip it for load testing of public endpoints; the max_ms field is a local tripwire, not a capacity plan.

Use it when the job is to ship a demo today, keep the invoice at zero, and make the next agent patch argue with a freeze file instead of with a credit card.

Top comments (0)