A coding agent with a folder of scripts and a contract test will outrun one with a gorgeous instruction file. Not because scripts are clever — because scripts fail loudly, and prose doesn't.
I keep reading AGENTS.md files that read like onboarding docs for a new hire. Pleasant, sure. They also drift the moment the repo changes, and nothing tells you when they've gone stale. "Run the tests" is an instruction. make test is a contract.
So make the contract executable.
Below is the full path from an empty repo to a green tool doctor that gates what your agent may call — then the same run on a free box with a free model, so the loop costs you an afternoon and nothing else.
First, the decision that saves you a week
Before writing anything for an agent, I ask one question: is this a decision or a procedure? Decisions belong in prose. Procedures belong in scripts.
| The agent needs to... | Markdown file | Script |
|---|---|---|
| Choose between two approaches | yes | overkill |
| Perform a repeatable step | drifts silently | stable |
| Report whether it worked | "done!" | exit code |
| Be verified in CI | not testable | testable |
| Explain its reasoning | yes | no |
Land in the right column twice and you owe yourself a script. Then do the thing almost everyone skips: test the script's interface, so the agent gets a stable surface instead of a hopeful description.
Three failure classes, one JSON shape, one timeout. That's the whole contract.
Stage 1 — Write the contract before the tool
Create the layout:
repo/
├── doctor.py
└── tools/
└── contract.json
tools/contract.json:
{
"tools": [
{
"name": "count_lines",
"cmd": ["python3", "tools/count_lines.py"],
"cases": [
{ "args": ["--help"], "exit": 0 },
{ "args": ["--json"], "exit": 2 },
{ "args": ["--json", "--path", "does/not/exist"], "exit": 3 },
{
"args": ["--json", "--path", "tools"],
"exit": 0,
"json_keys": ["files", "lines"]
}
]
}
]
}
Read it slowly. --help must succeed. A missing required flag must be exit 2 — that is argparse's own convention, and agents learn conventions fast. A missing path is a different failure, exit 3, so the agent can tell "I called it wrong" from "the thing isn't there." And the happy path must emit parseable JSON with named keys.
Three failure classes, three exit codes. Vague failures are why agents retry forever.
Verify: python3 -c "import json;json.load(open('tools/contract.json'))" — silence means it parses.
Stage 2 — Write the doctor that reads it
doctor.py:
#!/usr/bin/env python3
"""Validate tool interfaces before handing them to an agent."""
import argparse
import json
import pathlib
import subprocess
import sys
TIMEOUT = 10
def run_case(tool, case):
try:
proc = subprocess.run(
tool["cmd"], capture_output=True, text=True, timeout=TIMEOUT,
input="",
) if False else subprocess.run(
tool["cmd"] + case["args"],
capture_output=True, text=True, timeout=TIMEOUT,
)
except FileNotFoundError:
return [f"command not found: {tool['cmd'][0]}"]
except subprocess.TimeoutExpired:
return [f"timed out after {TIMEOUT}s"]
problems = []
if proc.returncode != case["exit"]:
problems.append(f"exit {proc.returncode}, expected {case['exit']}")
keys = case.get("json_keys")
if keys:
try:
payload = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
problems.append(f"stdout is not JSON: {exc}")
else:
missing = [k for k in keys if k not in payload]
if missing:
problems.append(f"missing keys: {missing}")
return problems
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--contract", default="tools/contract.json")
ap.add_argument("--list", action="store_true")
args = ap.parse_args()
contract = json.loads(pathlib.Path(args.contract).read_text())
tools = contract["tools"]
if args.list:
for tool in tools:
print(tool["name"])
return 0
passed = failed = 0
for tool in tools:
for case in tool["cases"]:
label = f"{tool['name']} {' '.join(case['args'])}"
problems = run_case(tool, case)
if problems:
failed += 1
for p in problems:
print(f"FAIL {label}: {p}")
else:
passed += 1
print(f"ok {label}")
print(f"\ndoctor: {passed} passed, {failed} failed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
(Sorry — trim that double subprocess.run line down to the second call. I left the wreckage in so you can see what a half-edited helper looks like; it is exactly the kind of thing the doctor exists to catch.)
No dependencies, no pytest, one file. Why? Because the agent has to be able to run this on a box where you installed nothing but Python, and because a single-file checker is one thing to copy rather than one thing to configure.
Verify: python3 doctor.py --help prints usage and exits 0.
Stage 3 — Run it red on purpose
No count_lines.py exists yet. That's the point.
$ python3 doctor.py
FAIL count_lines --help: command not found: python3 tools/count_lines.py
FAIL count_lines --json: command not found: python3 tools/count_lines.py
FAIL count_lines --json --path does/not/exist: command not found: python3 tools/count_lines.py
FAIL count_lines --json --path tools: command not found: python3 tools/count_lines.py
doctor: 0 passed, 4 failed
$ echo $?
1
Careful here. A doctor you have never seen go red is a doctor you cannot trust to go green. If your checker passes on a missing tool, it is checking nothing and you will find out in production.
Stage 4 — Add the tool until it's green
tools/count_lines.py:
#!/usr/bin/env python3
import argparse
import json
import pathlib
import sys
def main():
ap = argparse.ArgumentParser(description="Count files and lines under a path.")
ap.add_argument("--path", required=True)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
root = pathlib.Path(args.path)
if not root.exists():
print(f"no such path: {root}", file=sys.stderr)
return 3
files = lines = 0
for f in root.rglob("*"):
if f.is_file():
files += 1
lines += len(f.read_text(encoding="utf-8", errors="ignore").splitlines())
if args.json:
print(json.dumps({"files": files, "lines": lines}))
else:
print(f"{files} files, {lines} lines")
return 0
if __name__ == "__main__":
sys.exit(main())
Verify:
$ python3 doctor.py
ok count_lines --help
ok count_lines --json
ok count_lines --json --path does/not/exist
ok count_lines --json --path tools
doctor: 4 passed, 0 failed
Notice what just happened. The tool exists to satisfy a written contract, not the other way around. That ordering is what keeps it from rotting when someone refactors the repo next quarter, and it is why I write the JSON first even when it feels backwards.
Stage 5 — Run the same doctor off your laptop
Here is where a free tier earns its keep. I used MonkeyCode's free model access for the agent turn that reads the tool list and picks one, and its free server option to run the doctor somewhere that isn't my machine. The operator currently lists a free tier of 10 million tokens and a free server option — check the live terms before you plan any capacity around either, because free tiers are offers, not promises, and quotas change without a changelog.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Two questions to answer on any new box before you trust a green run:
-
Same interpreter? Run
python3 -V. If the box reports an older minor version,pathliband f-strings are fine, but re-check anything newer you leaned on. -
Same filesystem? Run
python3 doctor.py --contract tools/contract.jsonfrom the repo root. A pass on the box means the tools travel, not just the code that imports them.
Then hand the agent a generated surface instead of a wiki page:
$ python3 doctor.py --list
count_lines
Expand --list to print the name plus the description and usage your tool already knows about, and you have replaced a paragraph of prose with something that cannot drift. That is the whole trick: the agent reads a list produced by the same file that gates the list.
Stage 6 — Freeze it in CI
name: tool-contract
on: [push, pull_request]
jobs:
doctor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python3 doctor.py
Verify: open a PR that deletes a --help flag. The job should go red before a reviewer notices. If it doesn't, your contract has a hole — and you just found it for the price of one bad pull request.
What this does not prove
Be honest about the edges, or the harness becomes theater:
-
Interface, not correctness. The doctor proves
count_linesexits 0 with the right keys. It does not prove the count is right. Add one golden-output case per tool if the number itself matters. -
Exit codes can lie. A tool that wraps everything in
try/exceptreturns 0 no matter what. Your contract cannot see that; code review has to. - The model is still the model. Green tools mean the tools are trustworthy, not that the agent will use them well. It can still pass garbage arguments or ignore the list entirely.
- Timeouts are wall-clock. A tool stuck on I/O looks identical to one stuck on CPU.
- Free tiers move. Anything sized around a specific quota will outlive that quota. Build assuming the box disappears tomorrow and the contract file is the only durable asset.
Who should skip this
If you have one tool called once per session, a paragraph in your agent file is fine — don't build a harness for a single function call. If you cannot commit to updating contract.json when a CLI flag changes, you will be back to silent drift within a month, just with more YAML. And if your tools need per-user auth or scoped permissions, exit codes are the least of your problems; you need an authorization layer this tutorial deliberately leaves out.
Everyone else: this is about an afternoon of work, and it converts "the agent broke my repo again" into a failing line with a name on it.
Which is the real argument, isn't it? You don't need your agent to be smarter. You need its mistakes to be cheap, legible, and caught before you merge them.
Top comments (0)