Your laptop is not a real staging environment. A fluent local chat still proves almost nothing. Stage the agent remotely, or admit you guessed.
The claim
Local agent runs hide the failures that matter. You skip DNS, cold starts, and queue delay. You also skip boring contract breaks.
A remote canary is the cheapest honest gate. It does not need a larger model. It needs a frozen tool allowlist and a replayable fixture.
If the agent never left localhost, you did not stage it. A green demo is still a draft. Merge gates need exit codes, not screenshots.
What you should measure
Do not score the generated prose. Score the tool surface instead. Reviewers cannot replay a vibe.
A useful canary answers four questions:
- Did the agent call a tool you never allowed?
- Did a required argument key disappear?
- Did a call exceed your step budget?
- Did the remote host fail before the model did?
Those are merge questions. They are not demo questions. Chat quality can wait until the contract holds.
Freeze the contract first
Put the allowlist in git. Treat it like a public API. Prompts drift. Files get reviewed.
{
"version": "2026-09-18",
"max_steps": 8,
"allow": [
{
"name": "repo_read",
"required": ["path"],
"optional": ["start_line", "end_line"]
},
{
"name": "repo_search",
"required": ["query"],
"optional": ["globs", "max_hits"]
},
{
"name": "http_get",
"required": ["url"],
"optional": ["timeout_ms"]
}
],
"deny": ["shell_exec", "fs_write", "net_raw"]
}
Label this file as the contract. Do not bury it in a system prompt. If a tool is missing here, the agent must not call it.
Build a fixture, not a vibe
A fixture is a frozen input plus expected shape. It is not a transcript you liked last night.
{
"id": "canary-pr-summary-01",
"goal": "Summarize PR 1842 using repo_read only.",
"context": {
"pr": 1842,
"paths": [
"src/billing/invoice.py",
"tests/billing/test_invoice.py"
]
},
"expect": {
"allowed_tools": ["repo_read"],
"forbidden_tools": ["http_get", "repo_search"],
"max_steps": 4,
"must_read": ["src/billing/invoice.py"]
}
}
Write a handful of these. You do not need a hundred. Coverage beats theater. Delete any fixture that cannot fail.
Run the canary off the laptop
The runner below is a labeled example. Wire it to your own remote endpoint. Keep tokens in the environment, not in git.
#!/usr/bin/env python3
"""Labeled example: remote agent canary. Not a production SDK."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
CONTRACT = json.loads(Path("tools.allowlist.json").read_text())
FIXTURE = json.loads(Path("fixtures/canary-pr-summary-01.json").read_text())
BASE = os.environ["CANARY_BASE_URL"].rstrip("/")
TOKEN = os.environ.get("CANARY_TOKEN", "")
def post(path: str, payload: dict) -> dict:
data = json.dumps(payload).encode()
req = urllib.request.Request(
f"{BASE}{path}",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
except urllib.error.URLError as exc:
print(f"remote_error {exc}")
sys.exit(2)
def violations(trace: dict) -> list[str]:
issues: list[str] = []
allowed = {item["name"] for item in CONTRACT["allow"]}
denied = set(CONTRACT["deny"])
steps = trace.get("tool_calls", [])
if len(steps) > FIXTURE["expect"]["max_steps"]:
issues.append("step_budget")
for call in steps:
name = call.get("name")
args = call.get("args") or {}
if name in denied or name not in allowed:
issues.append(f"denied_or_unknown:{name}")
continue
spec = next(item for item in CONTRACT["allow"] if item["name"] == name)
missing = [k for k in spec["required"] if k not in args]
if missing:
issues.append(f"missing_args:{name}:{','.join(missing)}")
if name not in FIXTURE["expect"]["allowed_tools"]:
issues.append(f"fixture_forbid:{name}")
for path in FIXTURE["expect"]["must_read"]:
reads = [
c
for c in steps
if c.get("name") == "repo_read"
and (c.get("args") or {}).get("path") == path
]
if not reads:
issues.append(f"unread:{path}")
return issues
def main() -> None:
trace = post("/v1/agent/run", {"fixture": FIXTURE, "contract": CONTRACT})
issues = violations(trace)
report = {
"fixture": FIXTURE["id"],
"contract": CONTRACT["version"],
"steps": len(trace.get("tool_calls", [])),
"issues": issues,
"ok": not issues,
}
print(json.dumps(report, indent=2))
sys.exit(0 if report["ok"] else 1)
if __name__ == "__main__":
main()
Exit codes matter more than summaries.
-
0means the contract held. -
1means the agent broke the contract. -
2means the remote host failed first.
That last code is the one local demos never show you. Treat host failure as a blocked merge, not a skip.
Put it in CI, not in a screenshot
# .github/workflows/agent-canary.yml
name: agent-canary
on:
pull_request:
paths:
- "tools.allowlist.json"
- "fixtures/**"
- "agents/**"
jobs:
canary:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: run remote canary
env:
CANARY_BASE_URL: ${{ secrets.CANARY_BASE_URL }}
CANARY_TOKEN: ${{ secrets.CANARY_TOKEN }}
run: python3 canary.py
You can also run it by hand:
export CANARY_BASE_URL="https://canary.example.internal"
export CANARY_TOKEN="***"
python3 canary.py
echo $?
If you cannot point that command at a host you do not own, you are still on localhost. Rename the ritual. It is not staging.
Decision table
Use this at review time. Do not invent extra scores.
| Remote result | Tool contract | Merge action |
|---|---|---|
| Host unreachable | Unknown | Block. Fix the canary host. |
| Exit 2 | Untested | Block. You learned nothing. |
| Exit 1, denied tool | Broken | Block. Tighten the allowlist or the agent. |
| Exit 1, missing args | Broken | Block. The schema drifted. |
| Exit 1, unread path | Weak | Block. The fixture was skipped. |
| Exit 0 | Held | Allow human review to continue. |
Notice the table never asks if the summary sounded good. Sound is not a gate. Contract shape is.
Why a free remote slot changes the argument
You do not need a paid cluster to learn this. You need a host that is not your laptop. Local RAM will not emulate queueing, shared CPU, or foreign DNS.
MonkeyCode offers free model access and a free server option. That pair is useful here as a remote canary target, not as a demo coupon.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the same contract file. Keep the same fixture. Point CANARY_BASE_URL at the free server, then watch which failures appear only off-laptop.
If that path cannot host your canary, do not pretend a local chat replaced it. Find another remote. The method does not depend on one vendor.
What this will not catch
This canary does not prove correctness. It proves the tool surface stayed inside a box. Green canaries still need humans.
It will miss:
- Quiet logic errors inside an allowed tool
- Poisoned files the agent was allowed to read
- Cost blowups below the step cap
- Reviewer fatigue after a green report
It also assumes you can freeze tools. If your agent is a raw shell, stop. An allowlist of shell_exec is not a contract. It is a shrug.
Who should not use this
Skip this workflow if you are:
- Pairing on a throwaway spike
- Editing copy with no tools
- Running a model with no function calls
- Unable to send fixtures off-laptop
In those cases a remote canary is overhead. Do not cargo-cult the YAML. A spike can stay local until tools exist.
Limitations you should write down
Remote does not mean representative. A free server may differ from production in queue time and payload limits. This article will not invent numbers for those gaps.
Treat the canary as a bias detector. Localhost is optimistic. Remote is less so. Neither is production.
Pin versions of the contract. Rotate fixtures when the product changes. Delete fixtures that always pass. A canary that cannot fail is decoration.
A short review script
Paste this into the pull request template:
## Agent canary
- [ ] `tools.allowlist.json` version bumped if tools changed
- [ ] Fixture IDs listed
- [ ] Remote canary exit code pasted
- [ ] No new deny-list hits
- [ ] No localhost-only screenshot as evidence
If a reviewer sees a chat screenshot, they should ask for the exit code. If they cannot get one, the patch is not staged. No exit code, no merge.
Close
You already know localhost lies. You still present it as staging. Stop.
Freeze the tool contract. Replay a fixture on a remote host. Merge only when the canary returns zero.
A fluent local agent is a draft. A remote canary is a gate. If you already have a free remote slot, point the runner at it and paste the JSON report.
Top comments (0)