The projector still held a stack trace when the student hit send. Ninety minutes remained on the lab clock. The brief had asked for a refund endpoint that returned four fields. The model answered with a cache, a worker queue, and a table the syllabus had never named.
That scene now repeats in intro AI-coding rooms. A loop without a ticket wanders. Invented infrastructure fills the repo. The tests never get an honest first failure, and the clock dies on setup the assignment did not request.
This workshop treats the missing ticket as the lesson. It runs in two hours. Students leave with a stub file, a failing test, and a loop that may edit one path only. The same directory can be unpacked later on another laptop and rerun without the original chat.
A refund is a boring object on purpose. It carries an id, a status, an integer amount, and a currency. When the object is that small, a model that “helps” by adding Redis is easy to catch. The ticket stub is a JSON file that names the object and nothing else. It behaves like a coat-check tag. The coat can be elaborate. The tag is not.
Hour one belongs to the contract, not the model. The first twenty minutes are for copying the stub and changing one field so the file is the student’s. The next thirty minutes are for a test that must fail against an empty handler. A ten-minute break follows. After the break, forty minutes go to a bounded patch loop with a hard stop. The last twenty minutes are a scorecard: did the handler match the stub, and did the loop touch any other file.
Instructors who need a spare box for that loop can use a coding assistant that already offers free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is one such option. The lab files do not depend on it. A laptop and pytest are enough, and the scorecard still works if the class never leaves localhost.
The worked example below is a teaching fixture, not a payment system. Save the four files in an empty directory. Students should run the test once before any model is invited in. The red result is the starting gun, not a defect in the harness.
ticket_stub.json is the coat-check tag. Keep it tiny. If a later prompt tries to grow the object, the test will say no.
{
"name": "refund_ticket",
"response_keys": ["ticket_id", "status", "amount_cents", "currency"],
"allowed_statuses": ["pending", "approved", "denied"],
"example_input": {
"ticket_id": "r-1042",
"amount_cents": 2500,
"currency": "USD"
}
}
app.py starts as a locked door. The handler exists so imports succeed. It must not pass the test yet.
# Teaching fixture: empty handler for The Ticket Stub Workshop.
def refund_handler(payload: dict) -> dict:
raise NotImplementedError("workshop start: implement against ticket_stub.json")
test_refund.py is the only grader. It does not inspect style, comments, or extra helpers. It checks shape, allowed status, and the example amounts from the stub.
import json
from pathlib import Path
from app import refund_handler
def test_handler_matches_ticket_stub():
stub = json.loads(Path("ticket_stub.json").read_text(encoding="utf-8"))
sample = stub["example_input"]
got = refund_handler({"ticket_id": sample["ticket_id"]})
assert set(got.keys()) == set(stub["response_keys"])
assert got["ticket_id"] == sample["ticket_id"]
assert got["status"] in stub["allowed_statuses"]
assert got["amount_cents"] == sample["amount_cents"]
assert got["currency"] == sample["currency"]
Install the test runner once, then take the first snapshot. That snapshot is what students compare against after the loop.
python -m pip install pytest
pytest -q test_refund.py
The expected first run is a single failure from NotImplementedError. If the test is green at this point, the fixture was edited too early. Restore app.py and start again. Green too soon teaches the wrong reflex.
hourglass.py is the bounded loop. It is a proposal students can execute locally. It does not call a vendor API. It writes a prompt file, waits for a unified diff in patch.diff, and applies that diff only when every touched path is app.py. Three turns is the teaching cap. The cap is a kitchen timer, not a claim about model quality.
#!/usr/bin/env python3
"""Bounded patch loop for the Ticket Stub workshop (local harness)."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
ALLOWED = {"app.py"}
MAX_TURNS = 3
PROMPT = ROOT / "PROMPT.md"
PATCH = ROOT / "patch.diff"
def run_tests() -> int:
proc = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "test_refund.py"],
cwd=ROOT,
)
return proc.returncode
def write_prompt(turn: int) -> None:
stub = (ROOT / "ticket_stub.json").read_text(encoding="utf-8")
app = (ROOT / "app.py").read_text(encoding="utf-8")
PROMPT.write_text(
"Turn {turn} of {max_turns}. Edit app.py only.\n"
"Honor ticket_stub.json. Do not add caches, queues, or files.\n\n"
"ticket_stub.json:\n{stub}\n\n"
"app.py:\n{app}\n".format(
turn=turn, max_turns=MAX_TURNS, stub=stub, app=app
),
encoding="utf-8",
)
def paths_in_diff(text: str) -> set[str]:
names: set[str] = set()
for line in text.splitlines():
if line.startswith(("--- ", "+++ ")):
raw = line[4:].split("\t", 1)[0].strip()
if raw in {"/dev/null", "a/dev/null", "b/dev/null"}:
continue
if raw.startswith(("a/", "b/")):
raw = raw[2:]
names.add(Path(raw).name if "/" not in raw else raw.split("/", 1)[-1] if raw.startswith(("a/", "b/")) else raw)
names.add(raw)
return {n for n in names if n not in {"/dev/null"}}
def apply_patch() -> None:
text = PATCH.read_text(encoding="utf-8")
if not text.strip():
raise SystemExit("patch.diff is empty")
touched = paths_in_diff(text)
illegal = {p for p in touched if Path(p).name not in ALLOWED and p not in ALLOWED}
if illegal:
raise SystemExit(f"refusing patch; illegal paths: {sorted(illegal)}")
subprocess.run(["git", "apply", "--unsafe-paths", str(PATCH)], cwd=ROOT, check=True)
def main() -> None:
if not (ROOT / ".git").exists():
subprocess.run(["git", "init"], cwd=ROOT, check=True)
subprocess.run(["git", "add", "app.py", "ticket_stub.json", "test_refund.py"], cwd=ROOT, check=True)
subprocess.run(["git", "commit", "-m", "workshop start"], cwd=ROOT, check=True)
for turn in range(1, MAX_TURNS + 1):
if run_tests() == 0:
print(f"green on turn {turn - 1 or 'preflight'}")
return
write_prompt(turn)
print(f"wrote {PROMPT.name}; save a unified diff as {PATCH.name}, then press Enter")
input()
apply_patch()
if run_tests() != 0:
raise SystemExit("still red after hourglass stop")
print("green under the turn cap")
if __name__ == "__main__":
main()
The path check above is deliberately strict and a bit blunt. Workshop diffs are small. Students who paste a patch that creates cache.py should see a refusal, not a silent extra module. If git apply is unavailable, instructors can replace that line with a manual copy of app.py after a visual review. The rule does not change. Only app.py moves.
A sample legal patch, for instructors who want a worked answer after the room has struggled, looks like this. Label it as a key. Hand it out only in the debrief.
--- a/app.py
+++ b/app.py
@@ -1,4 +1,14 @@
-# Teaching fixture: empty handler for The Ticket Stub Workshop.
-
def refund_handler(payload: dict) -> dict:
- raise NotImplementedError("workshop start: implement against ticket_stub.json")
+ ticket_id = payload["ticket_id"]
+ return {
+ "ticket_id": ticket_id,
+ "status": "pending",
+ "amount_cents": 2500,
+ "currency": "USD",
+ }
That key is intentionally dull. It hard-codes the example amounts from the stub. Dull is a feature. Students who chase a general ledger in forty minutes are practicing a different course. This one rewards a handler that can pass a frozen example without growing a platform.
The scorecard is a spoken pass at the end of hour two. A table keeps the room honest when demos get theatrical.
| Check | Pass | Fail |
|---|---|---|
| First pytest run was red | Empty handler raised | Handler was prefilled |
ticket_stub.json unchanged after the loop |
File hash matches start | Keys or amounts drifted |
Only app.py changed |
Harness accepted the diff | Extra files or lockfiles appeared |
| Final pytest run is green | Shape and example match | Status outside the allow-list |
| Turn count ≤ 3 | Hourglass stopped the chat | Students kept pasting after the cap |
The analogy holds if someone tries to grade “effort.” A coat-check clerk does not score the embroidery on the coat. The clerk matches the tag. Workshops that praise a model for adding retries, metrics, and a Dockerfile are scoring embroidery. Those extras can be a later lab. They do not belong inside the two-hour stub.
Limitations are part of the method. The harness does not prove the handler would survive a second example. The hard-coded key above would fail a second ticket id if the test ever grew. Three turns do not measure intelligence. They measure whether the room can stop. Free model access and a free server option, when a class uses them, are availability notes rather than an uptime contract. This article does not assign model names, quotas, hardware, or duration to that option, because those figures change and were not verified here against a primary source.
Several groups should skip this pattern. Payment teams must not drop real payer data into a classroom prompt. Anyone who needs a service-level agreement should not treat a free server as production. Instructors who grade by whether the chat “finished” will fight the hourglass and should pick a different assignment. Students who have not yet seen a JSON object can still run the fixture, but they will need a five-minute tour of keys before the first pytest.
The portable result is the directory, not the transcript. A student can zip the stub, the test, the handler, and hourglass.py, open them on a bus, and press the same commands. If a class later wants a shared box so everyone reruns that zip after the local test is green, the free server option mentioned above is there to try. The ticket still has to match. The coat still stays at the door.
Top comments (0)