Illustrative scenario. A workshop cohort of six people has a browser page that turns a meeting note into a one-line summary. The page calls the model directly, which means the key sits in a bundled JavaScript file. One participant opens devtools in ninety seconds and reads it aloud. The room goes quiet. That silence is the first exercise.

Free model access makes this mistake cheaper to make and more expensive to fix. The constraint is not the price of a token. It is that a leaked free key is shared capacity, and shared capacity has an owner. So the workshop teaches one narrow move: put a small server between the client and the model, and let that server enforce size, shape, and concurrency.

MonkeyCode is relevant here because the project offers free model access and a free server option, which is enough to rehearse the whole pattern without a budget conversation. Both are operator-supplied availability claims and both are the kind of thing that changes; check the current allowance and server terms on the project's own site before you plan a class around them, rather than trusting a figure quoted in any blog post, this one included.

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

Why the free tier changes the design

A paid endpoint lets you be lazy. You retry on failure, you fan out ten parallel calls, you send the whole document because trimming feels like work. A free endpoint punishes all three, and it punishes them unevenly, because the two limits you will hit first are the ones nobody documents in advance: how long a cold instance takes to answer the first request, and what happens to your request when the shared pool is busy.

The workshop does not try to answer those questions. It teaches students to measure them once, write the numbers down, and design against the numbers they saw instead of the numbers they hoped for. That is the entire intellectual content. The rest is plumbing.

The 75-minute outline

Minutes 0-5, framing. Show the leaked-key story above. Ask nobody to confess. State the rule for the session: no client code ever holds a model credential, and no request from the internet reaches the model without passing a shape check first.

Minutes 5-15, glass box. Students call the model once from a throwaway script and log the wall-clock time and the size of the response. This is the only exercise that needs a real key, and it is deliberately the shortest. Anyone without a key runs the fake model described below and continues.

Minutes 15-30, move the key. Students take the illustrative doorman.py file at the end of this article and run it locally with DOORMAN_FAKE=1. Then they change one function, call_model, to hit the real endpoint using the request shape from the provider's current documentation. The point of the exercise is that only one function changes. If a student ends up editing the handler, their design is wrong and the room should discuss why.

Minutes 30-45, put a ceiling on it. Each student picks a maximum body size, a maximum note length, and an allow-list of two templates. Then they attack their own server: a 5 MB body, a template name that does not exist, a note that is 10,000 characters. All three must be refused before any model call happens. The check is timing, not logging. A refusal that takes twenty seconds is a refusal that already called the model.

Minutes 45-55, error taxonomy. The handler in this workshop returns exactly four codes, and students must argue for each one. The useful argument is always about retries, not about HTTP purity.

Server code Meaning Client should
422 shape rejected, note too long, unknown template fix the request, never retry
413 body over the ceiling shrink and resend once, then stop
429 both local slots busy wait, then retry once with jitter
502 model call failed or timed out retry with backoff, surface to the user after two failures

The tempting mistake is to collapse 429 and 502 into one "try again" path. They have different causes: one is your own admission control doing its job, the other is the upstream refusing or stalling. Students who merge them will spend the next week unable to tell which half of their system is broken.

Minutes 55-70, rehearse on the free server. Deploy the file, point a probe at it, and run concurrency 1, then 2, then 3. Record three columns per run: wall-clock for the batch, status codes seen, and whether the first request after idle was slower than the rest. Leave the cells blank in the shared document. Filled-in numbers from someone else's session are the most common way a workshop teaches the wrong lesson.

Concurrency Wall clock Statuses First request slower?
1
2
3

Minutes 70-75, debrief. Each student states one thing their server refuses and one thing it still permits that worries them. Write the second list on the board. It is usually longer than expected and always more interesting.

The worked example

This file is illustrative and was not executed for this article. It uses only the Python standard library, so python doorman.py works on a bare machine, and it runs offline until a student edits one function. Replace the request body and response field names with whatever the provider's current documentation specifies; the placeholder comments mark the two places where guessing would be wrong.

# doorman.py - illustrative gateway for a workshop, not production code. import json, os, threading, urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer MAX_BODY = 2_000 MAX_NOTE = 800 MAX_CONCURRENCY = 2 TEMPLATES = { "summary": "Summarise the note in one sentence. Note: {note}", "rewrite": "Rewrite the note for clarity without adding facts. Note: {note}", } slots = threading.BoundedSemaphore(MAX_CONCURRENCY) def call_model(prompt: str) -> str: if os.environ.get("DOORMAN_FAKE") == "1": return "[fake] " + prompt.split("Note: ", 1)[-1][:60] # Two edits below: request field names and response field name.  # Take both from the provider's current documentation.  body = json.dumps({"input": prompt}).encode() # verify  req = urllib.request.Request( os.environ["MODEL_BASE_URL"], data=body, method="POST", headers={"content-type": "application/json", "authorization": "Bearer " + os.environ["MODEL_API_KEY"]}) with urllib.request.urlopen(req, timeout=20) as resp: return json.loads(resp.read())["output"] # verify  class Handler(BaseHTTPRequestHandler): def do_POST(self): if self.path != "/v1/draft": return self.reply(404, {"error": "not_found"}) length = int(self.headers.get("content-length") or 0) if length > MAX_BODY: return self.reply(413, {"error": "too_large"}) try: payload = json.loads(self.rfile.read(length)) name, note = payload["template"], payload["note"] if name not in TEMPLATES or not isinstance(note, str) or len(note) > MAX_NOTE: raise ValueError except Exception: return self.reply(422, {"error": "rejected"}) if not slots.acquire(blocking=False): return self.reply(429, {"error": "busy"}) try: return self.reply(200, {"text": call_model(TEMPLATES[name].format(note=note))}) except Exception: return self.reply(502, {"error": "upstream"}) finally: slots.release() def reply(self, code, obj): raw = json.dumps(obj).encode() 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) if __name__ == "__main__": ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever() 

A student checks it in two steps, both of which fail loudly if the shape check is missing rather than quietly costing a model call.

DOORMAN_FAKE=1 python doorman.py & curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/v1/draft \ -d '{"template":"summary","note":"budget review moved to friday"}' # expect 200 curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/v1/draft \ -d '{"template":"translate","note":"hello"}' # expect 422 curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/v1/draft \ -H 'content-type: application/json' --data-binary @5mb.json # expect 413 

The transaction boundary worth explaining out loud: a slot is held only around the model call, and it dies with the process. That is adequate for one instance and wrong for two. Anyone who deploys a second copy has two independent semaphores and a concurrency ceiling of four, which is exactly the kind of detail a workshop should surface rather than hide.

What fits on a small free server, and what does not

Workload Fits Reason
One operator drafting summaries on demand yes bursty, tolerant of a slow first request
A classroom of six running timed probes yes concurrency is bounded by the room, not by traffic
A public demo page anyone can find no strangers share your ceiling and your key
A nightly job that must finish by 06:00 probably not no committed capacity, so the deadline is a wish
Anything with a latency promise in a contract no a free tier cannot back an SLA

Do not send data you would not hand to a stranger, and read the provider's current terms before pointing the gateway at real notes. The free option is for rehearsal, teaching, and side projects whose worst outcome is a slow page.

Who should skip this

Teams with a latency commitment, a compliance review, or more than a handful of concurrent users should build the same doorman against a paid endpoint with a real deployment story, because the gateway pattern transfers and the free tier does not. Anyone who needs to prove throughput under load should also skip it; one probe run on a shared pool proves nothing about Tuesday.

For everyone else, the file above is small enough to be read in one sitting, which is the actual requirement. If you want somewhere to rehearse exercise five before writing your own version, MonkeyCode's free server is a reasonable place to put the doorman for an afternoon and watch what the first request after idle actually does.