Last week a teammate asked me to review a new "gatekeeper" service that sat in front of every agent tool call. It was four hundred lines of clever permission logic, and it still let a malformed tool response crash the whole request. Nobody had tested the handoff between the model and the storage layer, and I closed the review with one question: what happens when the model returns the right shape but the wrong answer?
There is a popular discussion this week about stopping AI agents from using tools and building a gatekeeper instead, and the instinct is correct. Agents with tools are dangerous, and every team I know is bolting on permission checks after the first incident. But I think the architecture is backwards: a gatekeeper is middleware with a policy problem, and policy belongs in the contract, not in the request path.
What an agent feature actually needs is a boring proxy with three things: a contract test, a budget ledger, and a kill switch. Everything else is decoration. The contract test pins the response shape so a model upgrade cannot silently break the UI, and the budget ledger answers who pays before the bill arrives. The kill switch is just a flag that stops the request path when the model misbehaves, which is more useful than a permission matrix you will forget to maintain.
Here is the proxy I keep coming back to, written as a single FastAPI file:
# boring_proxy.py
import os
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
class Prompt(BaseModel):
text: str = Field(min_length=1, max_length=2000)
user_id: str
LEDGER: dict[str, int] = {} # staging-only ledger, resets on restart
DAILY_BUDGET = int(os.getenv("DAILY_BUDGET", "50000"))
MODEL_URL = os.getenv("MODEL_URL")
MODEL_KEY = os.getenv("MODEL_KEY")
KILL_SWITCH = os.getenv("KILL_SWITCH", "false").lower() == "true"
def estimate_tokens(s: str) -> int:
return max(1, len(s) // 4)
@app.post("/v1/complete")
async def complete(p: Prompt):
used = LEDGER.get(p.user_id, 0)
if KILL_SWITCH:
raise HTTPException(status_code=503, detail="route disabled")
if used >= DAILY_BUDGET:
raise HTTPException(status_code=429, detail="budget exhausted")
if not MODEL_URL or not MODEL_KEY:
raise HTTPException(status_code=503, detail="model route not configured")
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
MODEL_URL,
json={"prompt": p.text, "user_id": p.user_id},
headers={"Authorization": f"Bearer {MODEL_KEY}"},
)
r.raise_for_status()
data = r.json()
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="model timed out")
except httpx.HTTPStatusError as e:
raise HTTPException(status_code=502, detail=f"model returned {e.response.status_code}")
LEDGER[p.user_id] = used + estimate_tokens(p.text) + estimate_tokens(data.get("text", ""))
return {"text": data["text"], "user_id": p.user_id}
Now the test that makes the proxy honest:
# test_contract.py
from fastapi.testclient import TestClient
from boring_proxy import app, LEDGER
def test_response_shape_is_stable():
LEDGER.clear()
r = TestClient(app).post("/v1/complete", json={"text": "hello", "user_id": "u1"})
assert r.status_code == 200
assert set(r.json()) == {"text", "user_id"}
def test_budget_exhaustion_returns_429_not_a_crash():
LEDGER["u1"] = 10**9
r = TestClient(app).post("/v1/complete", json={"text": "hello", "user_id": "u1"})
assert r.status_code == 429
That second test is the one people skip, and it is the one that matters. A gatekeeper guards the tool layer, but the failures I actually debug are timeouts, exhausted budgets, and response shapes that drift after a provider update. The proxy turns all three into response codes a frontend can handle, which is exactly what a production incident needs.
This is where a free tier stops being a marketing line and becomes a staging environment. MonkeyCode's free model access and free server give you a place to run this proxy against a real model endpoint without opening a corporate card. Disclosure: This article was prepared as part of MonkeyCode's product outreach. At the time of writing, the free tier includes a token allowance (the current bucket is 10 million tokens) and a free server for exactly this kind of staging work, and the project itself is open source.
The workflow I actually recommend is short. Deploy the proxy to the free server with MODEL_URL and MODEL_KEY set, run the contract tests against the deployed URL, then shadow a few real requests before you enable any write path. If the provider changes the response shape, the contract test fails before a user ever sees the breakage, and the budget ledger keeps one runaway prompt from burning the whole allowance.
Who should not use this approach? If you are handling multi-tenant secrets, PHI, or PCI data, a shared free server is not your staging ground, so run the same proxy in your own VPC instead. Free tiers have rate limits and no SLA, which means the allowance is a staging resource rather than a production commitment. And the in-memory ledger resets on restart, so move it to Redis or Postgres before anything real depends on it.
The checklist I hand to every team that asks about agent safety:
- [ ] Deploy the proxy to the free server with the model route configured
- [ ] Run the contract test against the deployed URL, not just locally
- [ ] Set DAILY_BUDGET below the free allowance so the 429 path is exercised
- [ ] Shadow one real user request before enabling any write authority
- [ ] Replace the in-memory ledger before production
The gatekeeper debate is fun, but shipping is boring, and boring is what survives an incident. Build the boring proxy first, put it on the free server, and let the contract test argue with the model instead of arguing with your middleware. If you want to see the pattern in action, MonkeyCode's free server is a fine place to start, though the discipline matters more than the vendor. Which handoff is least stable in your stack right now, and what response code does it actually return when it fails?
Top comments (0)