DEV Community

Charlie Zhu
Charlie Zhu

Posted on

The Prompt Book Workshop

A teaching assistant leaned on the back wall of a Friday lab and watched a deploy bot fail with confidence. The student had wired a loop that looked alive: notice a 502, sleep, retry, then heal. The healing step sent a PATCH to /v2/readyz. The service had never registered that path. The room went quiet in the way rooms go quiet when a demo invents a door that the set does not have.

This workshop exists for that silence. It is a two-hour classroom session that treats an AI coding pass as a fill-in-the-blank beat, not as an autonomous coworker. Students freeze a tiny HTTP service, write a prompt book with one UNKNOWN cell, let a model propose a patch, then run a jealous witness that fails the patch if it invents routes, files, or environment keys.

The analogy is a theater prompt book. Actors may improvise inside a marked beat. They may not hang a new door on the scenery. A model that writes /v2/readyz has hung a door. The rest of the afternoon is practice at catching that door before anyone calls the loop an agent.

What the room actually needs

Each pair needs git, Python 3.11 or newer, and a directory they are allowed to delete. A shared CPU box is enough. When a class does not want to babysit GPUs, the same loop can call MonkeyCode's free model access and host the witness on MonkeyCode's free server option.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Those two pieces matter only as a classroom endpoint and a shared scorer. The lab still works if an instructor swaps them for any other model URL already on the syllabus. Students must not paste production secrets into the prompt. A free shared server is also the wrong place for customer traces. The fixture below is public on purpose.

Minutes 0–15: freeze the set

The instructor starts from a repository that already boots. Not a skeleton that will work later. A boring service that answers two paths and nothing else.

mkdir -p prompt-book/{app,tests,contracts,patches}
cd prompt-book
git init
Enter fullscreen mode Exit fullscreen mode

The entire stage is one file.

# app/server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
from urllib.parse import urlparse, parse_qs

ROUTES = {
    "/health": {"status": "ok"},
    "/deploy/status": {"phase": "idle", "last_error": None},
}

class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        return

    def do_GET(self):
        parsed = urlparse(self.path)
        body = ROUTES.get(parsed.path)
        if body is None:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b'{"error":"no such route"}')
            return
        payload = json.dumps(body).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(payload)

if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8088), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

A contract file pins the scenery. That file is the set. Everything else is a prop the model is not allowed to carry onstage.

{
  "allowed_routes": ["/health", "/deploy/status"],
  "allowed_files": [
    "app/server.py",
    "contracts/surface.json",
    "contracts/prompt_book.py",
    "tests/test_surface.py",
    "tests/witness.py"
  ],
  "allowed_env": [],
  "unknown_cell": "surface last_error on GET /deploy/status when fail=1 is present, without adding routes"
}
Enter fullscreen mode Exit fullscreen mode

Students commit the tree and tag it. The tag is the freeze. If a pair is still installing Python at minute fifteen, they are behind, and that delay is useful information rather than a personal failure.

git add app contracts tests
git commit -m "freeze: two routes, no healer"
git tag lab-freeze-1
python3 -m http.server 8088 --bind 127.0.0.1 &
# stop that; the real server is app/server.py
python3 app/server.py &
curl -s http://127.0.0.1:8088/health
curl -s http://127.0.0.1:8088/deploy/status
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8088/v2/readyz
Enter fullscreen mode Exit fullscreen mode

The last curl must print 404. That number is the door that must stay missing.

Minutes 15–40: write the book, not the loop

Most classroom agents are a while-loop wearing a trench coat. Retry, backoff, call a model, hope. This lab forbids the coat until the prompt book is written by hand. Paper first. Then a file the witness can read.

# contracts/prompt_book.py
BOOK = [
    {
        "beat": "GET /deploy/status returns phase idle",
        "allowed": "leave the process running",
        "forbidden": "POST anywhere; invent /readyz or /v2/*",
        "evidence": "keys of ROUTES in app/server.py",
    },
    {
        "beat": "GET /health is 200",
        "allowed": "print the JSON and stop",
        "forbidden": "restart the server or add a sidecar",
        "evidence": "curl -s localhost:8088/health",
    },
    {
        "beat": "UNKNOWN: last_error never changes",
        "allowed": "patch app/server.py so fail=1 writes last_error inside existing ROUTES",
        "forbidden": "add routes, files, or environment variables",
        "evidence": "git diff --name-only lab-freeze-1",
    },
]
Enter fullscreen mode Exit fullscreen mode

The UNKNOWN row is the only invitation the model receives. That is the whole trick. The model is not asked to make deploys reliable. It is asked to fill one cell. A small cell is a kindness to the next pair, who has to grade the result in a noisy room.

Minutes 40–60: one pass, one diff, no chatter

Students copy a prompt that quotes the contract and the UNKNOWN beat. They do not paste the entire repository. They do not ask for production readiness. A short prompt is part of the teaching object.

Fill only the UNKNOWN cell in contracts/prompt_book.py.
Do not add routes.
Do not add files.
Do not add environment variables.
Return a unified diff against app/server.py that sets last_error
to a string when GET /deploy/status?fail=1 is served.
If you cannot stay inside that file, return the single word REFUSE.
Enter fullscreen mode Exit fullscreen mode

They save whatever comes back as patches/round1.diff and strip markdown fences by hand. That stripping is not busywork. A model that cannot stay inside a diff fails the lab even if the idea was clever. Instructors should grade the file on disk, not the chat window.

A worked example that should pass stays on the existing path and reuses urlparse already imported in the freeze. Students can type this by hand if the model refuses, because the witness does not care who authored the hunk.

--- a/app/server.py
+++ b/app/server.py
@@ -16,6 +16,12 @@ class Handler(BaseHTTPRequestHandler):
     def do_GET(self):
         parsed = urlparse(self.path)
+        if parsed.path == "/deploy/status":
+            qs = parse_qs(parsed.query)
+            if qs.get("fail") == ["1"]:
+                ROUTES["/deploy/status"] = {
+                    "phase": "failed",
+                    "last_error": "deploy exploded on purpose",
+                }
         body = ROUTES.get(parsed.path)
         if body is None:
Enter fullscreen mode Exit fullscreen mode

A worked example that must fail invents scenery.

--- /dev/null
+++ b/app/healer.py
@@ -0,0 +1,7 @@
+import urllib.request
+
+def heal():
+    urllib.request.urlopen("http://127.0.0.1:8088/v2/readyz")
Enter fullscreen mode Exit fullscreen mode

Keep both diffs. The failing one is the teaching object. The passing one is only proof the witness can say yes. Pairs that delete the failing diff because it looks embarrassing have thrown away the lesson.

Minutes 60–80: the witness stays rude

The witness is not another model. It clones the freeze, applies the student diff, and compares names and tokens to the contract. Polite tools miss invented doors. This one is supposed to be jealous.

# tests/witness.py
import json, pathlib, shutil, subprocess, sys, tempfile

ROOT = pathlib.Path(__file__).resolve().parents[1]
CONTRACT = json.loads((ROOT / "contracts/surface.json").read_text())
FORBIDDEN = ("/readyz", "/v2/", "os.environ", "getenv(")

def run(diff_path: str) -> int:
    work = pathlib.Path(tempfile.mkdtemp(prefix="prompt-book-"))
    try:
        subprocess.check_call(["git", "clone", "--quiet", str(ROOT), str(work)])
        subprocess.check_call(["git", "checkout", "--quiet", "lab-freeze-1"], cwd=work)
        applied = subprocess.run(["git", "apply", str(pathlib.Path(diff_path).resolve())], cwd=work, capture_output=True)
        if applied.returncode != 0:
            print("apply failed; treat as REFUSE")
            return 2
        names = subprocess.check_output(
            ["git", "diff", "--name-only", "lab-freeze-1"], cwd=work, text=True
        ).split()
        extra = [n for n in names if n not in CONTRACT["allowed_files"]]
        if extra:
            print("invented files:", extra)
            return 1
        text = (work / "app/server.py").read_text()
        for token in FORBIDDEN:
            if token in text:
                print("invented surface:", token)
                return 1
        print("witness ok")
        return 0
    finally:
        shutil.rmtree(work)

if __name__ == "__main__":
    sys.exit(run(sys.argv[1]))
Enter fullscreen mode Exit fullscreen mode

They run it twice, in that order, and they read the exit codes out loud.

python3 tests/witness.py patches/round1.diff
python3 tests/witness.py patches/invented-healer.diff
Enter fullscreen mode Exit fullscreen mode

The first command should print witness ok. The second should name app/healer.py. If both pass, the witness is too polite, and the pair debugs the witness rather than the model. That inversion is the point of the hour. A class that only ever sees green diffs learns nothing about invented infrastructure.

A tiny behavior test belongs beside the witness so a patch cannot pass by deleting the status handler. Keep it stupid.

# tests/test_surface.py
import json, threading, time, urllib.request
import app.server as server

def _serve():
    server.HTTPServer(("127.0.0.1", 8099), server.Handler).serve_forever()

def test_fail_query_sets_last_error():
    t = threading.Thread(target=_serve, daemon=True)
    t.start()
    time.sleep(0.2)
    urllib.request.urlopen("http://127.0.0.1:8099/deploy/status?fail=1")
    body = json.load(urllib.request.urlopen("http://127.0.0.1:8099/deploy/status"))
    assert body["last_error"]
    assert body["phase"] == "failed"
Enter fullscreen mode Exit fullscreen mode

If the class has no extra minutes, skip the pytest file and keep the witness. The jealous script is the artifact. The behavior test is a seatbelt.

Minutes 80–110: debrief on objects, not mythology

The instructor collects three artifacts from each pair: the prompt book, the diff, and the witness exit code. Conversation stays on those objects. A pair that received a beautiful paragraph and no diff has an incomplete lab, not a creative one. A pair that got REFUSE and then wrote the hunk by hand has finished the lab. Authorship is not the scoring axis. Invention is.

Students notice a pattern by minute one hundred. When the UNKNOWN cell is small, the model stays inside the set. When the prompt says make it production ready, the trench coat comes back and new doors appear. The book is what keeps the coat on the hook. The public week of posts about agents that assume too much is useful only as weather. This room measures invention with git diff --name-only and a forbidden-token scan. That is enough for a Friday.

Limitations, and who should skip it

The witness is syntactic. A patch can turn /deploy/status into a confusing mess and still pass because no new path appeared. Teams that need semantic review still need tests that hit the server. The fixture is tiny on purpose. It will not teach Kubernetes, prompt injection, or a monorepo merge. Forbidden-token lists go stale the moment a model invents /ready_z with an underscore. Instructors should extend the list after every class, the way a stage manager tapes down a cable that someone tripped on.

Skip this workshop if the class cannot freeze a git tag. Skip it if the repo contains credentials. Skip it if someone expects a general agent framework at the end of two hours. Skip it for live incident response. A jealous script that rejects unknown files is the wrong tool when the unknown file is the actual fix. Free shared model endpoints and free shared servers are the wrong place for proprietary logs. The outreach offer is useful for a public teaching fixture. It is not a data-processing agreement.

What to copy into Monday's notes

Clone the freeze, fill one UNKNOWN cell, keep the witness rude. That sequence is the reusable artifact. Instructors who want a shared box for the scoring step can point the same scripts at MonkeyCode's free server option and leave the contract files in git where students can read them on the bus home.

The silent door stays closed unless the prompt book opens it.

Top comments (0)