Free model access and a free server are cheap the way a blank notebook is cheap: money is not the constraint, attribution is. When the fourth stage breaks, you want to know whether the prompt, the deploy, or the network broke — without re-running everything.
So the whole tutorial below is one sentence: every stage emits evidence, and that evidence is what lets you blame the right stage later. Six checkpoints, four files, one verifier.
Yes, a lot of this week's front-page discussion was about AI fatigue and about models that agree with everything. Fair. This post stays small on purpose: one endpoint, one JSON shape, no dashboards, no orchestration.
The slice you are building
One HTTP endpoint, GET /answer, returns one frozen object. That is all.
gate-demo/
├── contract/response.schema.json # what you will accept
├── prompt/extract.v1.txt # what you will send
├── stage1_stub.py # what you deploy first
└── verify.py # the only harness you get
Notice there is no framework in that tree. Standard library only, so when something fails it is yours and not a dependency's.
Stage 0 — Freeze the contract before anything else exists
Step 1. Write the shape you will accept. Not the shape you hope for — the shape you will reject everything else against.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["subject", "confidence"],
"properties": {
"subject": { "type": "string", "minLength": 1, "maxLength": 80 },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
}
}
Step 2. Prove the file is parseable at all:
mkdir -p contract && $EDITOR contract/response.schema.json
python -c "import json; json.load(open('contract/response.schema.json'))" && echo GATE0_OK
Gate 0 is deliberately weak. Parsing proves the file is JSON. It does not prove your service obeys it. That is Stage 1's job, and mixing the two is how you end up arguing with your own schema at midnight.
Stage 1 — Prove a stub locally, with no model and no network
Step 1. The stub returns a canned body, sorted, with a Content-Length:
# stage1_stub.py — no model, no network, one frozen response
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
BODY = json.dumps({"subject": "stub", "confidence": 1.0}, sort_keys=True).encode()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/answer":
self.send_response(404); self.end_headers(); return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)
def log_message(self, *args):
pass
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8080), Handler).serve_forever()
Step 2. Write the only harness you will use for the rest of the article:
#!/usr/bin/env python3
# verify.py <url> <stage> [--require-stable]
# Exit 0 = gate passed. 2 = schema broken. 3 = bytes unstable.
import hashlib, json, sys, urllib.request
RUNS = 3
def check(obj):
problems = []
if not isinstance(obj.get("subject"), str) or not obj.get("subject"):
problems.append("subject_invalid")
c = obj.get("confidence")
# isinstance(True, int) is True in Python; exclude bool explicitly.
if isinstance(c, bool) or not isinstance(c, (int, float)):
problems.append("confidence_not_number")
elif not 0 <= c <= 1:
problems.append("confidence_out_of_range")
extra = set(obj) - {"subject", "confidence"}
if extra:
problems.append(f"extra_keys:{sorted(extra)}")
return problems
def fetch(url):
with urllib.request.urlopen(url, timeout=10) as r:
raw = r.read()
return hashlib.sha256(raw).hexdigest()[:12], json.loads(raw)
def main():
url, stage = sys.argv[1], sys.argv[2]
require_stable = "--require-stable" in sys.argv
digests, failures = [], []
for i in range(RUNS):
digest, obj = fetch(url)
digests.append(digest)
failures += [f"run{i}:{p}" for p in check(obj)]
stable = len(set(digests)) == 1
print(f"stage={stage} digests={digests} byte_stable={stable} problems={failures}")
if failures:
return 2
if require_stable and not stable:
return 3
return 0
if __name__ == "__main__":
sys.exit(main())
Step 3. Gate it:
python stage1_stub.py &
python verify.py http://127.0.0.1:8080/answer 1 --require-stable; echo "exit=$?"
Why is byte stability a hard gate here and not later? Because a canned response must be identical three times out of three. If it is not, your harness is broken, not an AI. Finding that out on a stub costs nothing. Finding it out after you wired a model call costs an afternoon of suspicion.
Stage 2 — Version the prompt as a file, then hash it
Step 1. The prompt is an artifact, not a string in your code:
mkdir -p prompt cases
printf 'Extract the single subject of the text below.\nReturn only JSON: {"subject": string, "confidence": number}\nTEXT:\n' > prompt/extract.v1.txt
sha256sum prompt/extract.v1.txt | tee prompt/extract.v1.sha256
Step 2. Re-check it before every run:
sha256sum -c prompt/extract.v1.sha256
That is the whole gate, and it is boring on purpose. When it fails, a human edited the prompt, and you can stop pretending the service changed its mind. Put inputs in cases/, never a customer's text, and never inside prompt/.
Stage 3 — Wire one narrow model call behind the same endpoint
This is where free model access earns its place: one call, one shape, no tools, no loop, no agent. MonkeyCode is the project I used for that step here, and the free server option is what Stage 4 below uses. Both are availability claims from the project, not benchmarks from me. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Step 1. Your call wrapper — deliberately pseudocode, because the SDK differs:
# call_model.py — PSEUDOCODE. Swap `Client` for your provider's SDK.
# No model name is hardcoded: the run record names it, not this file.
import hashlib, json, os, pathlib, sys
prompt_path = pathlib.Path("prompt/extract.v1.txt")
record = {
"prompt_sha256_12": hashlib.sha256(prompt_path.read_bytes()).hexdigest()[:12],
"input_case": sys.argv[1],
}
text = pathlib.Path(sys.argv[1]).read_text()
resp = Client(api_key=os.environ["MODEL_KEY"]).complete(
prompt=prompt_path.read_text() + text,
temperature=0,
)
record["model"] = resp.model_id # recorded, never assumed
record["raw"] = resp.text
print(json.dumps(record, indent=2))
Step 2. Change one line in stage1_stub.py: the canned BODY becomes the model output. Same endpoint, same port, same verifier:
MODEL_KEY=... python stage3_app.py &
MODEL_KEY=... python verify.py http://127.0.0.1:8080/answer 3; echo "exit=$?"
Step 3. Read the gate honestly. At this stage, schema validity is 3/3 or you stop. Byte stability is printed, not gated. With temperature 0 some providers come back byte-identical and some do not; I am not going to promise what yours does. And if the call fails schema, do not retry — log the failing case, because that file is the first row of your eval set.
Stage 4 — Publish the unchanged stub to the free server
The important word is unchanged. You already know the stub works locally. If it fails remotely, the failure is the host, not the model.
Step 1. Copy only the two files that contain no secrets:
scp contract/response.schema.json stage1_stub.py user@instance:~/gate-demo/
Step 2. Start it on the instance and check it from inside the box:
cd ~/gate-demo && (nohup python3 stage1_stub.py &> stub.log &)
curl -s localhost:8080/answer
Step 3. Gate it from your laptop, with the same verifier as Stage 1:
python verify.py https://<instance-host>/answer 4 --require-stable; echo "exit=$?"
Gate 4 means: same bytes, same schema, different machine. Do not copy credentials with scp; set MODEL_KEY as an environment variable on the instance later. How you expose a port depends on the free server you picked — read that page, because nothing here assumes one.
Stage 5 — Move the model call to the remote, still one line
Now swap the stub for the app.py variant, set the key, restart, and re-run the verifier:
python verify.py https://<instance-host>/answer 5; echo "exit=$?"
Schema 3/3 is the gate. Latency is not. Time five sequential requests yourself and write down the numbers you measured on your connection — mine would say nothing about yours, so I am not handing you any.
Every gate in one table
| Stage | What it proves | Hard gate | Recorded only |
|---|---|---|---|
| 0 | the contract file parses | JSON parse OK | — |
| 1 | harness and endpoint work at all | schema 3/3, bytes stable | latency |
| 2 | the prompt is a versioned artifact | hash matches file | — |
| 3 | model output fits the contract | schema 3/3 | byte stability, model id, raw text |
| 4 | hosting is not the broken part | schema 3/3, bytes stable | cold-start time |
| 5 | both halves work together | schema 3/3 | five latency samples |
Where this breaks, and who should skip it
- Data that cannot leave your boundary. A hosted free model tier means the prompt leaves your machine. If that is a compliance problem for you, stop at Stage 2 and point the same endpoint at a local model.
- Workloads that need persistence. If state must survive a restart, a free instance is the wrong place to discover that. Read the provider's current terms for what the free tier includes on the day you deploy.
- Teams that need pinned model versions. This workflow records the model id per run; it does not guarantee tomorrow's call returns the same model.
- Anyone who wants load numbers. Three requests plus a hand-typed sample is a smoke test, not a benchmark.
- Anyone shipping this to production without an eval set. Stage 3 proves shape, not correctness.
One more: quotas and plans move. If you are budgeting, read the product's current plan page rather than any blog post, this one included.
The one thing to keep
Keep Stage 0's contract file byte-identical across all six stages. The stub, the prompt, the host, and the model are all replaceable. The contract is the part that tells you which stage broke when something does.
If you want to run Stages 3 through 5 against a free model call and a free server instead of wiring your own, that is the setup MonkeyCode offers and what I used above. Start with the contract file, and see what it says the first time something fails.
Top comments (0)