Last Tuesday a model told me it had created a file. The response looked perfect: done, exit_code: 0, a believable path. Then I opened the file. It existed, yes. It was empty. The model was not lying in a human sense—it was guessing what a successful tool result should look like. But the workspace told a different story.
So the question I kept turning over was simple: how do you turn an agent's status message into evidence? This article is a small case study. I built a gatekeeper that executes a tool call, inspects the actual side effect, and rejects the result when the model's claim and the filesystem disagree. I used MonkeyCode's free model access to generate the tool call, and its free server option as the place I would run the gatekeeper. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Background
Most tool-calling examples stop at the JSON. The model returns a function name and arguments; your code runs the function and returns whatever comes back. The hidden assumption is that the function actually did what the model claimed. That assumption breaks quietly. An LLM can say a file was saved when nothing was written. It can say a shell command exited cleanly when the process was never started. It can even describe output that was never produced because each token is a prediction, not a memory of the computer state.
Free model endpoints make this worse in a particular way. They queue, retry, and sometimes return cached responses under load. You might receive a tool call that looks complete but was generated before your current request finished. If you only check the text, you cannot tell the difference.
Goal
My goal was small enough to finish in one evening. I wanted a single Python script that takes a tool call, runs it, and returns a compact verification record: exit code, file existence, byte size, SHA-256 hash, stdout, and stderr. The script should fail when a file is empty even if the model says done. No framework, no database, no async plumbing. Standard library only.
Implementation
The model is asked to return only JSON, like this:
{"command":"printf hello > output.txt","output_path":"output.txt","expected_exit":0,"min_bytes":1}
Then the gatekeeper takes that spec and checks the real filesystem:
import hashlib
import json
import subprocess
import sys
from pathlib import Path
def run_and_verify(spec):
proc = subprocess.run(
spec['command'],
shell=True,
text=True,
capture_output=True,
)
output = Path(spec['output_path'])
exists = output.exists()
size = output.stat().st_size if exists else 0
digest = None
if exists:
digest = hashlib.sha256(output.read_bytes()).hexdigest()
passed = (
proc.returncode == spec.get('expected_exit', 0)
and exists
and size >= spec.get('min_bytes', 1)
and (
spec.get('expected_sha256') is None
or digest == spec['expected_sha256']
)
)
return {
'exit_code': proc.returncode,
'exists': exists,
'size_bytes': size,
'sha256': digest,
'stdout': proc.stdout,
'stderr': proc.stderr,
'passed': passed,
}
if __name__ == '__main__':
spec = json.loads(sys.argv[1])
result = run_and_verify(spec)
print(json.dumps(result, indent=2))
sys.exit(0 if result['passed'] else 1)
shell=True is deliberate for this local experiment because the tool call is a shell command. Do not copy that choice for a server that accepts commands from strangers. In the server version, I would use a list of arguments and an allowlist of executable paths.
Test fixtures
Two fixtures made the failure visible. The good fixture wrote five bytes:
$ cat > good.json <<'EOF'
{"command":"printf hello > output.txt","output_path":"output.txt","expected_exit":0,"min_bytes":1}
EOF
$ python verify_tool_call.py "$(cat good.json)"
It passed with exit_code: 0, exists: true, size_bytes: 5, and passed: true.
The bad fixture was the one that taught me the most. The model claimed success but never created a file:
$ cat > bad.json <<'EOF'
{"command":"exit 0","output_path":"output.txt","expected_exit":0,"min_bytes":1}
EOF
$ python verify_tool_call.py "$(cat bad.json)"
The gatekeeper printed exit_code: 0 but exists: false, size_bytes: 0, and passed: false. A typical agent loop would have accepted exit 0 as success. Mine did not. That single failed assertion is the whole lesson.
Adding a hash
For the good fixture, I added expected_sha256. After printf hello wrote the file, the gatekeeper computed the digest and confirmed the bytes matched. If a future model claims a different hash, the result fails even when the file exists. This catches a different class of lie: the file is present, but it is not the file the model said it wrote.
Why the free server option matters
The free server option is where I would run this in a safer arrangement. Instead of letting a generation endpoint trigger commands on my laptop, the verifier can live on a small server, accept an allowlisted command, and return the verification record. The model never gets a shell directly. It only gets a yes or no. I kept the first draft local because that made debugging easier, but the server path is the one I would use for anything shared.
MonkeyCode's open-source angle matters here too. I can read the client code that sends the model request and see how the tool-call JSON is assembled, rather than treating the endpoint as a black box. When I checked the signup page this week, it listed a free tier with 30 million tokens and a free server option. I am not treating that number as permanent; limits change, so check it before you depend on it.
Limitations
This approach verifies side effects, not correctness. A file can be the right size and hash but still contain the wrong content. shell=True is a known danger with untrusted input. And a single hash check will not catch semantic mistakes. Who should not use this? If you are running tool calls from completely untrusted users, do not use shell=True; use an isolated runtime. If you need semantic verification, this script is only one layer, not a replacement for review.
Lessons
Three lessons stick with me. First, a status field is a claim, not evidence. Second, a side effect can be checked cheaply: exit code, file size, and hash are already enough to catch the most common lies. Third, separating model generation from execution makes the failure observable. The model can still fail. It just can no longer fail silently.
If you want to try the same flow, MonkeyCode's signup page is where I started. I used the free model access for the tool-call JSON and the free server option for the execution boundary; if the limits have changed, the verifier approach still works with any endpoint that returns tool-call JSON.
Top comments (0)