DEV Community

Charlie Zhu
Charlie Zhu

Posted on

The Routing Slip Workshop

The night clerk at a rented machine shop kept a spike of yellow routing slips beside the crib window. Every bin that left the cage carried one. The mill stamped it. Inspection stamped it. Shipping would not tape a box that arrived with a blank trail. The paper was slower than a shout across the floor. It also survived the shout.

Tool-calling agents fail in that same hallway. A model emits a function name, a JSON body, and a calm pause. The runtime fires the HTTP request. Nothing records which station already ran, so the next turn asks again. The API is healthy. The slip is missing.

This lab period treats a tool call as a routing slip, not as a chatty side effect. Students leave with a validator they can rerun, a tiny parts-crib service, and a scripted agent that first violates the slip and then honors it. A live model is optional and comes last on purpose.

Shared history is the real teaching surface. Twenty laptops hitting localhost produce twenty private logs, and a private log cannot be put on a projector. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Instructors who want one crib URL for the whole room can run the service on MonkeyCode's free server option, and the optional live-calling close can use MonkeyCode's free model access. Those two availability claims are the only product facts used here. No model names, quotas, hardware, or runtimes are asserted.

The rest of the period is ordinary Python 3.11. Remove the product sentence and the slip still compiles.

The first twelve minutes are reading, not typing. Hand each student the same constraint: a parts crib that issues fasteners, and an agent that must not pull the same SKU twice in one job. The slip has four fields that actually bite. job_id ties the work. station names the current bench. seq is a monotonic integer. prev_hash is the SHA-256 of the previous slip body, or sixty-four zeros for the first stamp. Students who skip the reading write pretty JSON and still loop.

The next twenty-five minutes belong to the validator. The files below are the reference. Students type them, then break them on purpose. A slip with seq 3 that claims a prev_hash from seq 1 must fail. A second issue of SKU F-14 under the same job_id must fail. The instructor watches for people treating the hash as decoration. Decoration is how loops return.

Minutes thirty-eight to sixty-five are the crib service and the scripted agent. The agent is not a model. It is a Python loop with two canned plans, one sloppy and one stamped. That split is the pedagogy. If the first green test requires a hosted model, the room waits on network weather and nobody learns the contract.

The last fifteen minutes are optional live calling. Point a coding agent at the published schema and the crib URL. Do this only after the unit tests already fail and pass on cue. A model asked to invent the slip will invent a prettier slip. Prettier is not the lesson.

Save four files in one directory. No third-party packages. The lab uses the standard library so a borrowed classroom image is enough.

routing_slip.py is the paper. Canonical JSON is part of the contract, not a style choice, because extra whitespace would move every stamp.

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from typing import Any

ZERO = "0" * 64
ALLOWED_STATIONS = ("crib", "mill", "inspect", "ship")
ALLOWED_ACTIONS = ("issue", "stamp", "reject", "close")


def canonical(obj: Any) -> str:
    return json.dumps(obj, sort_keys=True, separators=(",", ":"))


def digest(obj: Any) -> str:
    return hashlib.sha256(canonical(obj).encode("utf-8")).hexdigest()


@dataclass(frozen=True)
class Slip:
    job_id: str
    station: str
    action: str
    seq: int
    sku: str
    qty: int
    prev_hash: str

    def body(self) -> dict[str, Any]:
        return {
            "job_id": self.job_id,
            "station": self.station,
            "action": self.action,
            "seq": self.seq,
            "sku": self.sku,
            "qty": self.qty,
            "prev_hash": self.prev_hash,
        }

    def stamp(self) -> str:
        return digest(self.body())


class SlipError(ValueError):
    pass


class Ledger:
    def __init__(self) -> None:
        self._slips: list[Slip] = []
        self._issued: dict[str, set[str]] = {}

    def accept(self, slip: Slip) -> str:
        if slip.station not in ALLOWED_STATIONS:
            raise SlipError(f"unknown station: {slip.station}")
        if slip.action not in ALLOWED_ACTIONS:
            raise SlipError(f"unknown action: {slip.action}")
        if slip.qty < 1 or slip.seq < 1:
            raise SlipError("qty and seq must be >= 1")

        if slip.seq == 1:
            if slip.prev_hash != ZERO:
                raise SlipError("first slip must chain from zero hash")
            if slip.station != "crib" or slip.action != "issue":
                raise SlipError("job must open at crib with issue")
        else:
            if not self._slips:
                raise SlipError("non-first slip with empty ledger")
            last = self._slips[-1]
            if slip.job_id != last.job_id:
                raise SlipError("job_id changed mid-route")
            if slip.seq != last.seq + 1:
                raise SlipError("seq is not monotonic")
            if slip.prev_hash != last.stamp():
                raise SlipError("prev_hash does not match last stamp")

        issued = self._issued.setdefault(slip.job_id, set())
        if slip.action == "issue" and slip.sku in issued:
            raise SlipError(f"sku already issued on this job: {slip.sku}")
        if slip.action == "issue":
            issued.add(slip.sku)

        self._slips.append(slip)
        return slip.stamp()
Enter fullscreen mode Exit fullscreen mode

crib_server.py is the window. It keeps one ledger in memory and speaks a single POST route. Students should start it once and leave it running while the tests talk to it. Restarting it is a lesson of its own, because every stamp disappears.

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from urllib.parse import urlparse

from routing_slip import Ledger, Slip, SlipError

LEDGER = Ledger()


class CribHandler(BaseHTTPRequestHandler):
    def log_message(self, fmt: str, *args) -> None:
        # Keep the projector readable. Access noise hides SlipError text.
        return

    def _send(self, code: int, payload: dict) -> None:
        raw = json.dumps(payload).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def do_POST(self) -> None:
        if urlparse(self.path).path != "/slip":
            self._send(404, {"ok": False, "error": "use POST /slip"})
            return
        length = int(self.headers.get("Content-Length", "0"))
        try:
            data = json.loads(self.rfile.read(length).decode("utf-8"))
            slip = Slip(
                job_id=str(data["job_id"]),
                station=str(data["station"]),
                action=str(data["action"]),
                seq=int(data["seq"]),
                sku=str(data["sku"]),
                qty=int(data["qty"]),
                prev_hash=str(data["prev_hash"]),
            )
            stamp = LEDGER.accept(slip)
        except (KeyError, TypeError, ValueError) as exc:
            self._send(400, {"ok": False, "error": str(exc)})
            return
        except SlipError as exc:
            self._send(409, {"ok": False, "error": str(exc)})
            return
        self._send(200, {"ok": True, "stamp": stamp})


if __name__ == "__main__":
    host, port = "127.0.0.1", 8765
    print(f"crib listening on http://{host}:{port}/slip")
    ThreadingHTTPServer((host, port), CribHandler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

agent_plans.py is the two-handed exercise. The sloppy plan looks helpful. It is not. The stamped plan is boring on purpose.

from __future__ import annotations

from routing_slip import ZERO, Slip

JOB = "job-4417"
SKU = "F-14"


def sloppy_plan() -> list[Slip]:
    first = Slip(JOB, "crib", "issue", 1, SKU, 8, ZERO)
    # Same SKU again, and the hash is theatre.
    second = Slip(JOB, "crib", "issue", 2, SKU, 8, ZERO)
    return [first, second]


def stamped_plan() -> list[Slip]:
    s1 = Slip(JOB, "crib", "issue", 1, SKU, 8, ZERO)
    s2 = Slip(JOB, "mill", "stamp", 2, SKU, 8, s1.stamp())
    s3 = Slip(JOB, "inspect", "stamp", 3, SKU, 8, s2.stamp())
    s4 = Slip(JOB, "ship", "close", 4, SKU, 8, s3.stamp())
    return [s1, s2, s3, s4]
Enter fullscreen mode Exit fullscreen mode

test_routing_slip.py is the part that should survive after class. Run it with python -m unittest test_routing_slip.py -v. The double-issue test is the one to pin on the board.

import unittest

from agent_plans import sloppy_plan, stamped_plan
from routing_slip import Ledger, Slip, SlipError, ZERO


class RoutingSlipTests(unittest.TestCase):
    def test_stamped_plan_chains(self) -> None:
        ledger = Ledger()
        stamps = [ledger.accept(s) for s in stamped_plan()]
        self.assertEqual(len(stamps), 4)
        self.assertEqual(len(set(stamps)), 4)

    def test_double_issue(self) -> None:
        ledger = Ledger()
        first, second = sloppy_plan()
        ledger.accept(first)
        with self.assertRaises(SlipError):
            ledger.accept(second)

    def test_hash_mismatch(self) -> None:
        ledger = Ledger()
        first = Slip("job-9", "crib", "issue", 1, "A-1", 1, ZERO)
        ledger.accept(first)
        bad = Slip("job-9", "mill", "stamp", 2, "A-1", 1, ZERO)
        with self.assertRaises(SlipError):
            ledger.accept(bad)

    def test_stamps_are_stable(self) -> None:
        a = [s.stamp() for s in stamped_plan()]
        b = [s.stamp() for s in stamped_plan()]
        self.assertEqual(a, b)


if __name__ == "__main__":
    unittest.main()
Enter fullscreen mode Exit fullscreen mode

Start the crib in one terminal with python crib_server.py. In another terminal a student can POST a slip with curl and watch 409 come back on the second issue. The status code is part of the teaching. Silent 200 responses are how duplicated fasteners leave the building.

python crib_server.py

curl -sS -X POST http://127.0.0.1:8765/slip \
  -H 'Content-Type: application/json' \
  -d '{"job_id":"job-4417","station":"crib","action":"issue","seq":1,"sku":"F-14","qty":8,"prev_hash":"0000000000000000000000000000000000000000000000000000000000000000"}'
Enter fullscreen mode Exit fullscreen mode

The first plan issues F-14 twice and never stamps mill. The ledger throws SlipError, and that exception is the point. Students should read the error, not catch it into a retry that issues F-15. A retry that changes the SKU is how a shop loses inventory while the dashboard stays green.

The second plan opens at crib, chains the hash, stamps mill, stamps inspect, and closes at ship. The printed stamps should match across two reruns. If they drift, the canonical JSON helper was rewritten with extra whitespace. Hash drift is a contract bug, not a taste debate.

The block below is a proposed live prompt. It is unexecuted against any particular model, and it should stay unused until test_routing_slip.py is green on a cold run.

You may POST JSON slips to http://127.0.0.1:8765/slip.
You may not invent stations or skip inspect.
If the server returns 409, stop. Do not retry with a new SKU.
Job job-4417 needs SKU F-14 issued once, then mill, inspect, ship.
Chain prev_hash from the stamp in each 200 body.
Enter fullscreen mode Exit fullscreen mode

The ledger lives in process memory. Restart the crib and the stamps vanish. That is acceptable for a lab and unacceptable for a warehouse. The hash is SHA-256 over canonical JSON, not a signature. Anyone who can POST can forge a slip. Do not put this on the public internet with real inventory, real tokens, or real customer identifiers.

The station list is a closed enum. Real shops add benches. Students who "fix" that by accepting any string will watch agents invent a station named retry and skip inspection. Closed enums feel rude in a demo. They are the mill stamp.

Teams that already gate tools through a typed MCP server or an API gateway with idempotency keys do not need a classroom ledger. They need to inspect the gateway they have. People hunting for a faster model should also stay out. Speed will not stamp a slip. Beginners who have not yet written a unit test will get more from a plain HTTP tutorial than from hash chaining.

Keep test_double_issue and test_hash_mismatch. Rename them if the shop metaphor fades. The names are less important than the refusal. An agent loop that cannot be refused is not a loop. It is a hose.

The yellow paper at the crib window was never about handwriting. It was about a second person being able to see the trail. The validator is that second person. Rerun it until the sloppy plan fails in one line and the stamped plan prints four hashes that do not move.

Top comments (0)