A staging URL started returning 403s after lunch. The agent had verified the same endpoint an hour earlier. Both statements were true.
The agent had issued the call from a borrowed runtime. Staging's allowlist knew the office egress and the CI NAT. It did not know the free server.
The failure was not a model hallucination. It was an incomplete sentence: "the tool call succeeded." Succeeded from where.
Name the gap before you debug the model
Tool-calling writeups spend their pages on JSON schemas and function names. Those fields matter. They are not the full record.
An HTTP tool call inherits the execution venue. IP address, DNS resolver, TLS stack, proxy, geography, and the environment variables exported on that machine all travel with the request. Change the venue and you change the caller the API actually saw.
Treat venue as part of the result. Not as wallpaper behind a green check.
Glossary
Use these terms when a chat transcript and a production log disagree.
Execution venue. The machine that issued the side effect. A laptop, a borrowed free server, a CI runner, or a browser tab. The language model is not a venue.
Caller identity. What the callee can observe: source IP, TLS fingerprint, User-Agent, API key, and forwarded headers. Two venues that share one key are still two identities.
Egress path. The route packets take. Office VPN, residential NAT, cloud provider, or a shared free host. Allowlists key off this path. They do not key off your intent.
Venue-skewed latency. Timing numbers collected on a noisy shared host. They describe that host. They do not describe your users.
Transcript vs side effect. The model's tool-call JSON is a claim. HTTP status, response body, and server-side logs are the side effect. Claims travel in chat. Side effects land in other people's systems.
Fixture-bound call. A call that must hit recorded responses. Live network is out of scope for the assertion.
Call promotion. Re-issuing the same request from the venue that CI or production will actually use. Copying a 200 from another venue is not promotion.
Decision tree: where may this tool call run?
Walk the questions in order. Stop at the first matching leaf.
- Would a mistake spend money, mutate production, or move customer data?
- Does the callee allowlist IPs, bind sessions to a device, or key rate limits per source address?
- Are you treating latency, throughput, or "realistic" user timing as evidence?
- Is the output a mergeable assertion, or a throwaway sketch of an integration?
The four leaves are exclusive. If you want two leaves, schedule two runs.
Leaf A — Do not run it as an agent tool
When. Question 1 is yes.
Worked example. The agent proposes to warm a cache with POST /admin/reindex against production, using a personal token from a local .env file.
Do not register that tool. Do not add a confirm=true argument and call it safety. Destructive admin APIs and live customer records are not sketch input, on a laptop or on a free server.
Example policy module:
# venue_policy.py — example program, not an IAM replacement
BLOCKED_TOOLS = {
"prod_reindex",
"stripe_charge",
"delete_all_indices",
"loadtest_prod",
}
def allow_tool(name: str, venue: str) -> bool:
if name in BLOCKED_TOOLS:
return False
if venue == "borrowed" and name.startswith("staging_"):
return False
return True
Reproducible checks:
# test_venue_policy.py
from venue_policy import allow_tool
def test_blocked_tools_never_run():
for name in ("prod_reindex", "stripe_charge", "loadtest_prod"):
assert allow_tool(name, "laptop") is False
assert allow_tool(name, "borrowed") is False
def test_staging_stays_off_borrowed_runtime():
assert allow_tool("staging_search", "borrowed") is False
assert allow_tool("staging_search", "laptop") is True
def test_public_fetch_ok_on_borrowed():
assert allow_tool("fetch_public_openapi", "borrowed") is True
Run:
python -m pytest test_venue_policy.py -q
The deny-list is a seatbelt you can grep. Put the real control on the API: scoped tokens, allowlists, and no production credentials in agent sessions.
Leaf B — Laptop only
When. Question 1 is no and question 2 is yes.
Worked example. Staging allows 203.0.113.40/32 (office) plus the CI NAT. A remote agent retries a failing request from another ASN. Staging returns 403. The agent "fixes" the client by dropping the Authorization header.
The header was not the defect. Caller identity was.
Execute the live call from the machine that already passes the allowlist:
curl -sS -D - \
-H "Authorization: Bearer ${STAGING_TOKEN}" \
-H "X-Venue: laptop" \
https://staging.example.invalid/v1/health
Stamp the venue on purpose. When logs diverge later, the header tells you which caller you were.
Let the agent draft the client offline if that helps. Issue the authenticated request from the allowlisted host.
Leaf C — CI only, fixtures or a known runner
When. Questions 1 and 2 are no, and either you need a mergeable assertion or question 3 is yes.
Worked example. The agent reports GET /search?q=test at 80 ms. The sample came from a shared free host beside other workloads. That figure is venue-skewed latency. It is not an SLO observation.
Realistic API performance tests need a stable runner, a defined payload mix, and a callee that is not someone else's Saturday traffic. A borrowed sketch server fails all three. Move timing work to a dedicated job, or stop calling the number evidence.
For shape and contract tests, pin a fixture:
# tests/test_search_contract.py
# proposal: execute in CI, not on a borrowed sketch host
from pathlib import Path
import json
FIXTURE = Path(__file__).parent / "fixtures" / "search_test.json"
def test_search_shape():
payload = json.loads(FIXTURE.read_text())
assert "hits" in payload
assert isinstance(payload["hits"], list)
assert "query" in payload
# proposal: .github/workflows/api-contract.yml
name: api-contract
on: [pull_request]
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pytest tests/test_search_contract.py
Do not paste CI secrets into a chat attached to a borrowed runtime. CI is a venue with its own identity. Keep the identity intact.
Leaf D — A borrowed runtime is acceptable
When. Questions 1–3 are no, and question 4 is a throwaway sketch. Public documentation. A disposable sandbox. No customer data. No allowlist. No performance claim.
Worked example. You want an agent to fetch a public OpenAPI document and draft a client stub. The request is an unauthenticated GET. Failure is cheap. The artifact is code you will re-execute under Leaf B or Leaf C before you trust it.
This is the leaf where a free remote server is a reasonable place to spend agent turns. The model still needs a machine. That machine's network still becomes caller identity. You accept that identity because the callee is public and the output is a stub.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option, which can host Leaf D sketch work. If you use that borrowed runtime, treat every live tool call as originating from the server, not from your laptop. Do not export staging tokens into the session to force a demo to finish.
A small wrapper that refuses to pretend otherwise:
"""venue_stamp.py — example program, not a benchmark."""
import json
import os
import socket
import urllib.request
VENUE = os.environ.get("AGENT_VENUE", "unspecified")
def get_json(url: str) -> dict:
if VENUE == "borrowed" and os.environ.get("STAGING_TOKEN"):
raise RuntimeError("refuse: staging token present on borrowed venue")
req = urllib.request.Request(
url,
headers={"User-Agent": f"venue-stamp/{VENUE}"},
method="GET",
)
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read().decode("utf-8"))
return {
"venue": VENUE,
"hostname": socket.gethostname(),
"url": url,
"keys": sorted(body.keys()) if isinstance(body, dict) else type(body).__name__,
}
if __name__ == "__main__":
target = "https://httpbin.org/get"
print(json.dumps(get_json(target), indent=2))
AGENT_VENUE=borrowed python venue_stamp.py
The interesting field is not the demo URL. It is the stamp. If a transcript cannot name the venue, you cannot interpret the status code.
Apply it in one session
Numbered path for the next agent run:
- Export
AGENT_VENUE=laptop,AGENT_VENUE=ci, orAGENT_VENUE=borrowedbefore the first tool call. - Classify that call with the four questions. Write the leaf name in the session note.
- Log
venue, tool name, URL host, and whether a secret was present. Do not log the secret. - If the leaf is D, keep the artifact as a stub. Re-issue live authenticated calls on B. Re-assert contracts on C.
- If two leaves apply, stop and split the work. Mixed leaves are how 403s get "fixed" by deleting headers.
Example log line:
venue=borrowed tool=fetch_public_openapi host=openapi.example.invalid secret=no leaf=D
Grep later. Chat scroll is not an index.
Decision table
| Leaf | Secrets | Allowlist or per-IP limits | Latency as evidence | Typical venue |
|---|---|---|---|---|
| A | Production or paid side effects | Irrelevant | Irrelevant | No agent tool |
| B | Staging tokens on your box only | Yes | No | Laptop or VPN |
| C | CI secrets in CI only | CI NAT | Only on a dedicated job | CI plus fixtures |
| D | None | No | No | Borrowed free server |
If a row needs two yes answers you did not record, split the work. Draft on D. Execute on B. Assert on C. Do not merge from D because the transcript sounded sure.
What this workflow does not claim
It does not claim that borrowed runtimes are slower, faster, safer, or more accurate. No timings appear here because none were collected for this article.
It does not name model SKUs, token quotas, or hardware sizes. Those figures move. The venue questions do not.
It does not replace IAM. A Python deny-list in the agent loop is documentation. The API still has to reject the call.
Browser-only agents do not escape the tree. A tab is a venue. It carries cookies, extensions, and a residential IP. That is Leaf B or Leaf A. It is not a fifth exemption.
Who should not use this approach
Skip Leaf D if policy forbids sending prompts or source to a third-party host. The tree assumes that decision already exists.
Do not use a borrowed server to mint "realistic" API performance numbers. Shared CPU and shared egress contaminate the sample. Serious performance suites already demand dedicated runners and a defined load model.
Do not read this article as permission to place production credentials in an agent session because "the example script would have raised." The raise is a sample. Your threat model is larger.
If the task is a local script with no network tools, you do not need the tree. Keep the agent on the laptop and skip the ceremony.
Closing
Write the venue on every tool transcript you keep. Then read status codes in that light.
The model will still be wrong on some turns. That is a different defect. This one is cheaper: stop treating a 200 from the wrong ASN as a 200 from yours.
Top comments (0)