DEV Community

Morgan Xu
Morgan Xu

Posted on

Hang a Rehearsal Gate Before Agent Tool Calls

Shared agents should not call live APIs first. They should walk a rehearsal gate instead. The gate is a one-page wiki run with named roles.

Live tool calling looks cheap until a retry storm starts. One wrong schema can fan out across every checkout. A rehearsal gate catches that blast before customers feel it.

Think of the gate as a fire drill for tools. The drill uses a stub wall, not the real warehouse. Agents practice the motion without moving real inventory.

This playbook stays useful without any vendor in the loop. It asks for a contract, a stub, and a human closer. The closer signs the wiki card before production traffic.

The failure this card exists to stop

An agent receives a tool list and a user goal. It emits a JSON call that almost matches the schema. Production still accepts the call and charges a card twice.

The on-call thread then argues about intent versus transport. Nobody can say who approved the tool shape. The rehearsal gate makes that approval visible and boring.

Roles on the floor

Three seats matter, and they do not share a keyboard. The lane owner owns the stub and the freeze window. The tool clerk owns the JSON contract and examples.

The closer is a human reviewer with merge rights. The closer never runs the agent during the drill. That split keeps the rehearsal honest when output looks fluent.

Handoffs travel only through the wiki card below. Chat messages do not count as a handoff. If the card is stale, the gate stays shut.

The one-page wiki run

Paste this block into the team wiki. Fill every field before the first agent turn. Leave the card in the ticket until the closer signs.

# Rehearsal Gate — Tool-Call Drill
Date: YYYY-MM-DD
Service: payments-api
Lane owner: @name
Tool clerk: @name
Closer: @name
Freeze window: 14:00-15:00 UTC

Contract file: /contracts/payments.create_refund.json
Stub base URL: http://127.0.0.1:8787
Forbidden hosts: api.prod.example.com, api.staging.example.com

Goal (one sentence):
Agent may draft a refund call against the stub only.

Pass bar:
- schema validate exits 0
- stub log shows one POST /refunds
- no packet to forbidden hosts
- closer initials below

Fail bar:
any extra host, extra verb, or missing field

Closer sign-off: ____  time: ____
Enter fullscreen mode Exit fullscreen mode

The wiki card stays short on purpose every time. Long runbooks tend to hide the freeze window. A one-page gate can be read in a standup.

A contract the clerk can test

The clerk checks in a JSON Schema for each tool. The file is the source of truth, not the prompt. Prompts drift while reviewed files stay pinned.

{
  "$id": "payments.create_refund",
  "type": "object",
  "additionalProperties": false,
  "required": ["order_id", "amount_cents", "reason"],
  "properties": {
    "order_id": { "type": "string", "pattern": "^ord_[a-z0-9]{8,}$" },
    "amount_cents": { "type": "integer", "minimum": 1, "maximum": 50000 },
    "reason": {
      "type": "string",
      "enum": ["duplicate", "not_received", "courtesy"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That refund contract is deliberately tight by design. Courtesy refunds still need a reason enum. Open string fields are how silent over-refunds start.

A stub the lane owner can run

The lane owner starts a local recorder, not a proxy to prod. The recorder answers 201 and writes a jsonl log. Agents must target this stub origin only.

#!/usr/bin/env python3
"""Rehearsal stub. Label: unexecuted until the lane owner starts it."""
from http.server import BaseHTTPRequestHandler, HTTPServer
import json, datetime
from pathlib import Path

LOG = Path("/tmp/rehearsal-gate.jsonl")

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        raw = self.rfile.read(length)
        try:
            body = json.loads(raw.decode() or "{}")
        except json.JSONDecodeError:
            self.send_error(400, "invalid json")
            return
        rec = {
            "ts": datetime.datetime.utcnow().isoformat() + "Z",
            "path": self.path,
            "host": self.headers.get("Host"),
            "body": body,
        }
        with LOG.open("a") as fh:
            fh.write(json.dumps(rec) + "\n")
        self.send_response(201)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"ok":true,"id":"reh_001"}')

    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

The script binds localhost on purpose for isolation. Binding 0.0.0.0 would invite other agents. Shared laptops are not a rehearsal lane.

A clerk check that fails closed

The clerk runs a validator before the agent is seated. The command must exit nonzero on extra keys. Extra keys are treated as production leaks.

#!/usr/bin/env python3
"""Validate one tool payload against the checked-in schema."""
import json, sys
from pathlib import Path

try:
    import jsonschema
except ImportError:
    sys.stderr.write("pip install jsonschema\n")
    sys.exit(2)

schema = json.loads(Path(sys.argv[1]).read_text())
payload = json.loads(Path(sys.argv[2]).read_text())
jsonschema.validate(payload, schema)
print("contract_ok")
Enter fullscreen mode Exit fullscreen mode
python3 validate_tool.py contracts/payments.create_refund.json sample.json
python3 - <<'PY'
import json, pathlib
p = pathlib.Path("/tmp/rehearsal-gate.jsonl")
rows = [json.loads(l) for l in p.read_text().splitlines() if l]
assert len(rows) == 1, rows
assert rows[0]["path"] == "/refunds"
print("stub_ok")
PY
Enter fullscreen mode Exit fullscreen mode

These commands are the pass bar on the wiki card. Green output is not a vague vibe check. It is two assertions and a schema.

Pin the allowlist in the agent runner

The runner must refuse any tool host outside the stub. Loose environment variables are not a real policy. Policy lives in a checked-in file beside the contract.

#!/usr/bin/env python3
"""Refuse tool URLs that escape the rehearsal gate."""
from urllib.parse import urlparse
import json, sys

ALLOWED = {"127.0.0.1:8787", "localhost:8787"}

def allowed(url: str) -> bool:
    parsed = urlparse(url)
    host = f"{parsed.hostname}:{parsed.port or 80}"
    return host in ALLOWED and parsed.scheme == "http"

call = json.loads(sys.stdin.read())
url = call.get("url", "")
if not allowed(url):
    sys.stderr.write(f"blocked:{url}\n")
    sys.exit(1)
print("allowlist_ok")
Enter fullscreen mode Exit fullscreen mode
echo '{"url":"http://127.0.0.1:8787/refunds"}' | python3 allowlist.py
echo '{"url":"https://api.prod.example.com/refunds"}' | python3 allowlist.py
# second command must exit 1
Enter fullscreen mode Exit fullscreen mode

The second command is the real rehearsal test. A green first command proves almost nothing alone. Blocking the production host is the rehearsal.

When the clerk hands the lane owner the baton

The clerk posts the schema hash on the wiki card. The lane owner starts the stub only after that hash appears. Starting early invites an old contract into the log.

The closer waits for both ok lines in the ticket. Silence in chat is not a passing signal. The card is the only baton the floor trusts.

sha256sum contracts/payments.create_refund.json
# paste the digest into the wiki card before the stub starts
Enter fullscreen mode Exit fullscreen mode

That digest is the handoff, not a hallway nod. A mismatched hash means the gate stays shut. The lane owner does not “just run it anyway.”

Where a free rehearsal host fits

Some teams lack a spare box for the stub. They still should not point agents at staging. Staging data is often production-shaped and legally hot.

Some teams park that rehearsal on MonkeyCode. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The project is published as open source. The project offers free model access and a free server option. Those availability notes are the only product claims in this article.

The wiki card does not require that host. Any isolated server that cannot reach forbidden hosts works. The point is isolation, not a brand.

A team already evaluating that option can paste the card first. Then they point the agent at the stub origin. They keep production hostnames out of the tool allowlist.

Limitations

This gate does not prove the model chose the right tool. It only proves the call was well-shaped and well-aimed. Semantic mistakes still need a closer who reads the goal.

The current stub always returns a 201 status. Real APIs return 409, 429, and 503. A later drill should inject those codes on purpose.

The jsonl log is not a compliance archive. It lives in /tmp and will vanish. Teams with audit needs must ship the log to their own store.

Clock drift and parallel agents will break the "one POST" assert. The freeze window on the card exists for that reason. Overlapping drills share a log and lie.

Who should not use this run

Solo developers with one local process can skip the seats. The three-role split is overhead without a shared floor. A single person can still keep the schema file.

Air-gapped teams should not use a hosted rehearsal. They should run the stub on hardware they already control. The card still works in that room.

Regulated payment teams may need a legal review of even stubbed refunds. This article is not that legal review. The closer should halt if counsel has not spoken.

Close the gate

The closer initials the card only after both commands print ok. Then the freeze window ends and the stub is killed. Production tool calling stays blocked until that signature exists.

A rehearsal gate is a seatbelt, not a destination. It makes tool calling boring enough to review. Boring reviews are how shared agents survive a week.

Top comments (0)