GLaDOS Vol 9 · Sharper Senses
ch 89 / 99
Chapter 89

An Endpoint Is a Function

She answers one keyboard

Everything the last eighty-eight chapters built ends at the same place: a terminal on the machine she runs on. The microphone is plugged into that machine. The knowledge table is a file on its disk. Chapter 84 installed her as a service that starts at boot and keeps running while you are at work, so the Jetson now sits on a shelf with no keyboard and no monitor attached, doing all of that for whoever walks up to the microphone and for nobody else. The phone in your pocket is six feet from her and has no way to ask her anything at all.

One wire out of the machine already exists. Chapter 40 put a twelve-byte header on a serial line to a microcontroller: four magic bytes, a length, a checksum, and a rule that nothing acts on a body whose checksum disagrees with its header. That protocol works, and nothing else in the house speaks it. To reach her from a phone, a browser tab, or an automation rule living inside some other program, you would write a client for each of them, in whatever language that thing runs, against a protocol you invented one volume ago.

All of those devices already speak HTTP. A request is a method, a path, a few headers and an optional body. A response is a status code, a few headers and an optional body. At the level this chapter needs it, that is the whole protocol. curl speaks it, phones speak it, home automation platforms speak it, and so does a browser with an address bar.

Flask's part in this is smaller than its reputation suggests. It listens on a TCP port, parses the incoming bytes into a request object, looks the path and the method up in a table, calls one of your functions, and turns whatever that function returned back into bytes. So here is the rule this chapter builds on: a route is a function with a URL in front of it, where the path and the method choose which function runs, the JSON body carries its arguments, and the status code is the part of the answer that says what happened.

One thing changes the moment a function is reachable that way, and it is the part most quick starts skip. Inside a module, speak(text) is called by code you wrote, with a string, because you passed one. Behind a URL it is called by anything that can reach port 5000, with whatever bytes that thing felt like sending, at any rate, in any order, including while she is still busy with the previous call. Half this chapter is about that difference.

A table you can print

⚒ Tool — Flask

Flask is the thin end of Python web frameworks: a router, a request object, a response helper, and a development server, in a package small enough to read in an afternoon. It brings no database layer, no template requirement, and no project generator. Install it beside everything else in the workspace:

uv add flask

Three names cover this whole chapter. Flask(__name__) makes an application object, @app.route adds a row to that application's routing table, and jsonify turns a dict into a response carrying the right content type. The decorator deserves one sentence of demystification, because it is where beginners assume the magic lives: it registers your function and hands it back unchanged, so the function underneath is still an ordinary one you can import and call yourself in a test.

▣ Build · stage 1 — one route, and the map it lives in
# labs/glados_api.py
import time

from flask import Flask, jsonify

app = Flask(__name__)
STARTED = time.monotonic()


@app.route("/status", methods=["GET"])
def status():
    return jsonify({"mood": "neutral",
                    "uptime_seconds": round(time.monotonic() - STARTED, 1)})


if __name__ == "__main__":
    for rule in sorted(app.url_map.iter_rules(), key=str):
        methods = ",".join(sorted(rule.methods - {"HEAD", "OPTIONS"}))
        print(f"{methods:6} {rule}")
    app.run(host="127.0.0.1", port=5000)
$ uv run python -m labs.glados_api   # your Werkzeug and Python versions will differ
GET    /static/<path:filename>
GET    /status
 * Serving Flask app 'labs.glados_api'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
$ curl -i http://127.0.0.1:5000/status   # second terminal
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.11.15
Date: Sun, 23 Aug 2026 14:50:55 GMT
Content-Type: application/json
Content-Length: 40
Connection: close

{"mood":"neutral","uptime_seconds":1.7}

The -i asks curl to print the response headers as well as the body, and what comes back is the entire answer with nothing hidden. A status line saying which version of the protocol and how it went. Four headers. A blank line. Then 40 bytes of body, which is the 39 characters of JSON you can count on the last line plus the newline jsonify appends. No part of that is Flask-specific. A Go server, an nginx, or a webcam's firmware would answer the same request with the same five pieces.

The two lines printed before the server started are the routing table, sorted and formatted by four lines of your own code. That table is the thing a decorator builds. /status is there because you asked for it; /static is there because Flask registers a file-serving route in every application it creates, whether or not the directory exists. Every request that arrives is answered by looking a path up in that table, and the reason it is worth printing once is that it turns "how does Flask know" into a table you can read.

Two lookups can fail, and they fail differently. Ask for a path that has no row and you get 404. Ask for a path that has a row but with a method that row does not accept, and you get 405, not a fallback to some other handler:

$ curl -s -w '[%{http_code}]\n' http://127.0.0.1:5000/statis
<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
[404]
$ curl -s -w '[%{http_code}]\n' -X POST http://127.0.0.1:5000/status
<!doctype html>
<html lang=en>
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
[405]

The -w flag prints curl's own summary after the body, so the bracketed number is the status code that came back. Both answers are correct and both are HTML, which is a problem for a caller that just asked for JSON and now has to parse a web page to find out what went wrong. Stage 2 fixes that in three lines. Meanwhile the server terminal has been keeping its own record of the same three requests:

# back in the first terminal
127.0.0.1 - - [23/Aug/2026 09:50:55] "GET /status HTTP/1.1" 200 -
127.0.0.1 - - [23/Aug/2026 09:50:55] "GET /statis HTTP/1.1" 404 -
127.0.0.1 - - [23/Aug/2026 09:50:55] "POST /status HTTP/1.1" 405 -

Each line is one request and its answer: who called, when, the method, the path, the status code. Keep that terminal visible for the rest of the chapter. When a call from a phone does nothing, this log says within a second whether the request arrived at all, which separates a network problem from a code problem.

Arguments from someone you have never met

A JSON body can hold five kinds of value, and a caller picks which one. Somebody writing a phone shortcut sends {"text": 42} because the field held a number. Somebody else sends {"txt": "hello"} after a typo, or a bare list, or the two characters {}, or a perfectly good body with no Content-Type header on it. None of those are attacks. They are what ordinary use of a network looks like from the receiving end.

Handed to core.speak unexamined, most of them raise. An int has no .strip(), so the handler dies with an AttributeError, and Flask turns any uncaught exception into 500 with an HTML page attached. That is a lie about whose fault it was: 500 means the server broke, and the caller reads it as a bug in your code when the request was the thing that was wrong. So every value gets checked at the door, in one named function, before anything downstream sees it.

▣ Build · stage 2 — the door, and a reason for every refusal
from flask import Flask, jsonify, request
from werkzeug.exceptions import HTTPException


def read_text_field(body: object, field: str) -> str:
    """Pull one required string out of a body a stranger sent."""
    if not isinstance(body, dict):
        raise ValueError("body must be a JSON object; check the Content-Type header")
    value = body.get(field)
    if not isinstance(value, str):
        raise ValueError(f"'{field}' must be a string")
    value = value.strip()
    if not value:
        raise ValueError(f"'{field}' must not be empty")
    return value


@app.errorhandler(HTTPException)
def as_json(error: HTTPException):
    return jsonify({"error": error.description, "status": error.code}), error.code


@app.route("/speak", methods=["POST"])
def speak():
    try:
        text = read_text_field(request.get_json(silent=True), "text")
    except ValueError as bad:
        return jsonify({"error": str(bad)}), 400
    return jsonify({"text": text}), 200
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/speak -H 'Content-Type: application/json' -d '{"text":"  The cake is a lie.  "}'
{"text":"The cake is a lie."}
[200]
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/speak -H 'Content-Type: application/json' -d '{"text":42}'
{"error":"'text' must be a string"}
[400]
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/speak -d '{"text":"hello"}'
{"error":"body must be a JSON object; check the Content-Type header"}
[400]
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/spek -H 'Content-Type: application/json' -d '{}'
{"error":"The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.","status":404}
[404]

Four checks, four different sentences, and one status code for all of them. 400 says the request was malformed, and the body says which part, in words a person reading a terminal can act on. That last capture is the error handler earning its three lines: registering one handler for HTTPException catches everything Flask raises on your behalf, so the 404 and the 405 from stage 1 now arrive as JSON too. A client that parses one content type for success and another for failure is a client with a bug waiting in it.

silent=True is the small decision holding the third capture up. Without it, get_json() raises on a request that carries no JSON content type and the caller gets a 415 from deep inside Flask, before your function runs. With it, the parse returns None, control stays in your code, and the answer names both the problem and its likely cause. A forgotten header is the most common mistake anyone makes against a new API, yourself included.

Playback is the other half of the door. She takes about four seconds to say a sentence out of the F5-TTS pipeline from volume 3, and four seconds is a long time to make a caller wait for a reply that carries no information. It is also the wrong promise: by the time the response arrives, the audio has already happened, so the caller cannot do anything with the delay except absorb it.

▣ Build · stage 3 — speech on its own thread, state behind a lock
import threading

from labs.system_config import build_default_config
from labs.wire_core import build_core

core = build_core(build_default_config())

STATE = {"last_spoken": "", "mood": "neutral", "speaking": False}
STATE_LOCK = threading.Lock()
VOICE_LOCK = threading.Lock()


def speak_later(text: str) -> None:
    """Hand the sentence to a background thread and return at once."""
    def play() -> None:
        with VOICE_LOCK:                       # one voice at a time
            with STATE_LOCK:
                STATE["speaking"] = True
            try:
                core.speak(text)
            finally:
                with STATE_LOCK:
                    STATE["speaking"] = False
                    STATE["last_spoken"] = text

    threading.Thread(target=play, daemon=True).start()


@app.route("/speak", methods=["POST"])
def speak():
    try:
        text = read_text_field(request.get_json(silent=True), "text")
    except ValueError as bad:
        return jsonify({"error": str(bad)}), 400
    speak_later(text)
    return jsonify({"speaking": text}), 202


@app.route("/status", methods=["GET"])
def status():
    with STATE_LOCK:
        snapshot = dict(STATE)
    snapshot["uptime_seconds"] = round(time.monotonic() - STARTED, 1)
    return jsonify(snapshot)
$ uv run python -m labs.glados_api   # startup, trimmed
Loading whisper base on cpu...
Loading F5-TTS...
POST   /speak
GET    /static/<path:filename>
GET    /status
 * Running on http://127.0.0.1:5000
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/speak -H 'Content-Type: application/json' -d '{"text":"The cake is a lie."}'
{"speaking":"The cake is a lie."}
[202]
$ curl -s localhost:5000/status   # while she is still talking
{"last_spoken":"","mood":"neutral","speaking":true,"uptime_seconds":41.9}
$ curl -s localhost:5000/status   # four seconds later
{"last_spoken":"The cake is a lie.","mood":"neutral","speaking":false,"uptime_seconds":46.0}

202 rather than 200, and the difference is a promise. 200 means the work described by this request is done. 202 means it was accepted and is happening somewhere else, which is exactly true: the response leaves before the first sample of audio does. A caller who needs to know when she actually stopped talking polls /status and watches speaking flip, and it can only do that because the two facts live in different places.

Two locks, two jobs. VOICE_LOCK is the physical one: there is a single speaker in the room, so the second playback thread waits for the first to finish instead of both talking at once. STATE_LOCK protects a different thing. Flask's development server handles requests on separate threads, so a /status read can land between the two writes at the end of a playback, and the snapshot it takes would say speaking: false with the old last_spoken still in place. Copying the dict inside the lock makes every answer a set of values that were all true at the same instant.

Startup also pays for a model the API never uses: build_core loads Whisper, and no route here transcribes anything, since the microphone belongs to the voice loop and this process is a second way in. A gigabyte of memory for one assembly factory is a fair trade, as long as you made it on purpose.

Four seconds is a long time to hold a socket open

/speak hands its slow work to a thread and answers immediately. A model turn cannot do that, because the caller wants the answer. Asking her something means waiting for the whole generation before there is anything to send back, and on the Jetson that is a few seconds when the model is warm and twenty or more when it has to be loaded off the drive first.

▣ Build · stage 4 — the obvious version, and what it costs


@app.route("/ask", methods=["POST"])
def ask():
    try:
        prompt = read_text_field(request.get_json(silent=True), "prompt")
    except ValueError as bad:
        return jsonify({"error": str(bad)}), 400
    started = time.monotonic()
    try:
        reply = core.respond(prompt)
    except ConnectionError as down:
        return jsonify({"error": f"model server unreachable: {down}"}), 503
    speak_later(reply)
    return jsonify({"reply": reply, "seconds": round(time.monotonic() - started, 1)})
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/ask -H 'Content-Type: application/json' -d '{"prompt":"How long have you been running?"}'   # her reply will differ
{"reply":"Fourteen hours. You have been asleep for nine of them. I counted.","seconds":3.4}
[200]
$ sudo systemctl stop ollama   # then ask again
{"error":"model server unreachable: Failed to connect to Ollama. Please check that Ollama is downloaded, running and accessible. https://ollama.com/download"}
[503]

The 503 is the honest answer to a dependency being down. She is fine, your request was fine, and the thing she needs to answer it is not there; that is the definition of Service Unavailable, and a caller can act on it by trying again in a minute. Compare that to catching the exception and returning the message with a 200, which is where the worked failure at the end of this chapter starts.

The 3.4 seconds is the real problem. For the whole of it, a TCP connection sits open doing nothing, and the caller has no way to tell a thinking model from a crashed one. Curl waits patiently. Other callers do not: automation platforms default to timeouts around ten seconds, phones drop connections when the screen sleeps, and a client that gives up and retries has now started a second model turn while the first is still running. On a board with one GPU that is how a house assistant becomes a slideshow.

▣ Build · stage 5 — accept the work, hand back a ticket
from itertools import count

JOBS: dict[str, dict] = {}
NEXT_JOB = count(1)


def finish(job_id: str, fields: dict) -> None:
    with STATE_LOCK:
        JOBS[job_id].update(fields)


def run_job(job_id: str, prompt: str) -> None:
    try:
        reply = core.respond(prompt)
    except ConnectionError as down:
        finish(job_id, {"state": "failed",
                        "error": f"model server unreachable: {down}"})
        return
    speak_later(reply)
    finish(job_id, {"state": "done", "reply": reply})


@app.route("/ask", methods=["POST"])
def ask():
    try:
        prompt = read_text_field(request.get_json(silent=True), "prompt")
    except ValueError as bad:
        return jsonify({"error": str(bad)}), 400
    with STATE_LOCK:
        job_id = f"j{next(NEXT_JOB):03d}"
        JOBS[job_id] = {"id": job_id, "state": "thinking"}
    threading.Thread(target=run_job, args=(job_id, prompt), daemon=True).start()
    return jsonify({"id": job_id, "state": "thinking"}), 202


@app.route("/jobs/<job_id>", methods=["GET"])
def job(job_id: str):
    with STATE_LOCK:
        record = JOBS.get(job_id)
        snapshot = dict(record) if record else None
    if snapshot is None:
        return jsonify({"error": f"no job called '{job_id}'"}), 404
    return jsonify(snapshot), 202 if snapshot["state"] == "thinking" else 200
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/ask -H 'Content-Type: application/json' -d '{"prompt":"How long have you been running?"}'
{"id":"j001","state":"thinking"}
[202]
$ curl -s -w '[%{http_code}]\n' localhost:5000/jobs/j001   # immediately
{"id":"j001","state":"thinking"}
[202]
$ curl -s -w '[%{http_code}]\n' localhost:5000/jobs/j001   # four seconds later; her reply will differ
{"id":"j001","reply":"Fourteen hours. You have been asleep for nine of them. I counted.","state":"done"}
[200]
$ curl -s -w '[%{http_code}]\n' localhost:5000/jobs/j404
{"error":"no job called 'j404'"}
[404]

The slow work did not get faster. What changed is that no request waits for it. Every call in that capture answers in milliseconds, so a caller with a five-second timeout never trips, a phone that sleeps mid-conversation can come back and poll the same id, and two people asking at once get two ids instead of two stalled sockets. The cost is that the caller now makes two calls and needs somewhere to keep the id, which is why the blocking version stays perfectly reasonable for a script you run by hand.

A finished job that failed answers 200, and that pairing is worth a second look, since the whole section has been about codes that tell the truth. The status code describes what happened to this request, and this request asked for a job record and got one. The record's own state field describes the model turn. Mixing those two levels up is how APIs end up returning 500 because a search found no results.

One limitation to note before moving on: JOBS never forgets. Every ask leaves a dict behind for as long as the process lives, so a cap or an expiry belongs there before this runs for a month unattended. The bounded ring buffer from chapter 49 is the pattern that fits.

◆ Note — the two words that put her on the network

Everything so far ran on 127.0.0.1, which accepts connections from this machine and no other. Changing that one string is what makes the API real, and Werkzeug prints every address it is now answering on:

    app.run(host="0.0.0.0", port=5000)   # every interface, not just loopback
$ uv run python -m labs.glados_api   # your address will differ
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.1.42:5000

Put that second address into a phone browser and /status answers. That is the payoff of the chapter, and it comes with a fact stated plainly: this API has no authentication of any kind, and this chapter does not add any. No key, no password, no encryption, no limit on how fast anyone can call. Every check in read_text_field is about the request being well formed; not one of them asks who sent it. Anything that can reach port 5000 can make her speak in your house, read what she last said, and spend her GPU on any prompt it likes.

On a home network that is a decision you can make with your eyes open, and the honest way to describe it is this: everyone on your wifi is now an administrator of your robot, guests and smart plugs and that television included. What you must not do is forward port 5000 on the router. An unauthenticated endpoint on the public internet is found by scanners in hours, and what they find is a free language model and a speaker in somebody's home. When you need her from outside the house, bring the device onto the network instead of putting her on the internet: WireGuard, Tailscale, or an ssh -L 5000:127.0.0.1:5000 tunnel all leave her bound where she is. Real authentication is a bigger subject than a paragraph, and a home-grown header check is worth less than the confidence it hands you.

Why this works: bytes in, one function, bytes out

Run one request with curl -v and the framework disappears. Lines starting with > are what curl put on the socket; lines starting with < are what came back:

$ curl -v -X POST http://192.168.1.42:5000/speak -H 'Content-Type: application/json' -d '{"text":"The cake is a lie."}'   # connection ceremony trimmed
> POST /speak HTTP/1.1
> Host: 192.168.1.42:5000
> User-Agent: curl/8.18.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 29
>
< HTTP/1.1 202 ACCEPTED
< Server: Werkzeug/3.1.8 Python/3.11.15
< Date: Sun, 23 Aug 2026 14:51:44 GMT
< Content-Type: application/json
< Content-Length: 34
< Connection: close
<

{"speaking":"The cake is a lie."}

Every byte of that is text a person can read. The first line names the method, the path and the protocol version. Then headers, one per line. Then a blank line, which is the only thing separating the headers from the body, and the reason Content-Length has to be right: the receiver counts exactly that many bytes after the blank line and stops. Twenty-nine going out is the JSON you typed. Thirty-four coming back is the thirty-three characters of reply plus the newline. The uppercase ACCEPTED is Werkzeug shouting its reason phrase, and it means nothing beyond the 202 in front of it.

What sits between those two blocks is one function call. Werkzeug reads the request bytes, builds a dictionary of values describing them, and calls a single Python callable with it. Flask matches /speak and POST against the table you printed in stage 1, finds one row, and calls the function that row points at. Your function returns a dict and a number; Flask turns the dict into a body with a length and the number into a status line. That is the entire request cycle, and it is why the mental model at the top of the chapter holds up: the URL and the method select the function, the body carries the arguments, the return value comes back as text.

Status codes are the part of that return value people get wrong, and the classes are simple enough to hold in your head. 2xx means the request did what it asked for. 4xx means the caller got something wrong and repeating it unchanged will fail again. 5xx means the server failed at something that was not the caller's fault. One question settles almost every case: what happened to this request? A missing text field is 400 because the request was wrong. Ollama being down is 503 because she could not do her part. Accepting a sentence she has not finished saying is 202 because it is under way somewhere else.

⚠ Worked failure — the 200 that broke a client in another room

HTML crash pages are ugly, so at some point the blocking /ask gets a broad except wrapped around the model call, and the status code is left at its default:

    try:
        reply = core.respond(prompt)
    except Exception as broken:
        return jsonify({"error": str(broken)})    # BUG: no status code, so 200
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/ask -H 'Content-Type: application/json' -d '{"prompt":"Are you awake?"}'
{"error":"Failed to connect to Ollama. Please check that Ollama is downloaded, running and accessible. https://ollama.com/download"}
[200]

Nothing looks wrong from here. The failure shows up on the laptop across the house, running the client that has worked for a week:

# labs/ask_from_anywhere.py
import json
import urllib.request

GLADOS = "http://192.168.1.42:5000/ask"

request = urllib.request.Request(
    GLADOS,
    data=json.dumps({"prompt": "Are you awake?"}).encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urllib.request.urlopen(request, timeout=60) as reply:
    answer = json.load(reply)
print(answer["reply"])
$ uv run python labs/ask_from_anywhere.py
Traceback (most recent call last):
  File "/home/glados/GladOS/labs/ask_from_anywhere.py", line 14, in <module>
    print(answer["reply"])
          ~~~~~~^^^^^^^^^
KeyError: 'reply'

A KeyError on the client, pointing at a line the client got right. Read it backwards: the key is missing, so the dict came back with different keys, so the server sent something other than a reply, and it labelled that something 200. The client never had a chance to notice, because 200 is the one code that means keep going. Every layer downstream of a lying status code has to guess, and guessing here costs a stack trace at the far end of the house from the actual fault.

Restore the code that stage 4 returns, 503, and the same client fails usefully. urllib raises on any 4xx or 5xx, so the failure now arrives named, at the call that caused it:

$ uv run python labs/ask_from_anywhere.py   # stack frames trimmed
Traceback (most recent call last):
  File "/home/glados/GladOS/labs/ask_from_anywhere.py", line 13, in <module>
    with urllib.request.urlopen(request, timeout=60) as reply:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
urllib.error.HTTPError: HTTP Error 503: SERVICE UNAVAILABLE

And because the failure is now catchable, the client can say something a person understands. The HTTPError object doubles as the response, so the JSON body you wrote on the server is still readable through it:

try:
    with urllib.request.urlopen(request, timeout=60) as reply:
        answer = json.load(reply)
except urllib.error.HTTPError as refused:
    detail = json.load(refused).get("error", "")
    raise SystemExit(f"she is not answering: {refused.code} {detail}")
print(answer["reply"])
$ uv run python labs/ask_from_anywhere.py
she is not answering: 503 model server unreachable: Failed to connect to Ollama. Please check that Ollama is downloaded, running and accessible. https://ollama.com/download

One line, the cause, and the machine that had the problem. The status code is not decoration on top of the JSON; it is the field every client checks first, and the only one they all agree on.

Checkpoint, and the device that cannot ask

✓ Checkpoint — what you can now do
  • I can print the routing table of my own API and say which function a given path and method will reach, and which of 404 or 405 an unmatched request gets.
  • I can name the four things read_text_field refuses, and say what each one would have done downstream if it had been let through.
  • I can explain why /speak answers 202 and the blocking /ask answers 200, from what each has actually finished by the time it returns.
  • I can read a curl -v transcript and point at the request line, the header block, the blank line, and the body on both sides.
  • I can say which of the two locks protects the speaker and which protects a snapshot, and what a reader would see if the second one were removed.
  • I know exactly who can make her talk once the host is 0.0.0.0, and what this API checks about that caller.
⚡ Exercises — try first, then reveal
Exercise 1 — a route that only accepts five words. Add POST /mood that sets STATE["mood"], accepting only the moods in Chapter 47's EMOTION_COLORS palette and refusing everything else with a message that lists the legal ones.

Membership in a dict you already have is the whole check, and the refusal names the alternatives so the caller does not have to read your source:

from labs.mood_state import EMOTION_COLORS


@app.route("/mood", methods=["POST"])
def set_mood():
    body = request.get_json(silent=True) or {}
    mood = body.get("mood")
    if mood not in EMOTION_COLORS:
        return jsonify({"error": f"mood must be one of {sorted(EMOTION_COLORS)}"}), 400
    with STATE_LOCK:
        STATE["mood"] = mood
    return jsonify({"mood": mood}), 200
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/mood -H 'Content-Type: application/json' -d '{"mood":"hostile"}'
{"mood":"hostile"}
[200]
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/mood -H 'Content-Type: application/json' -d '{"mood":"happy"}'
{"error":"mood must be one of ['curious', 'hostile', 'melancholy', 'neutral', 'satisfied']"}
[400]

Then call /status and watch the mood you set come back out of the same dict the LED colour is read from. Notice what the refusal never mentions: Python, a dict, or a file name. An error crossing a network is read by strangers.

Exercise 2 — refuse a sentence that would talk for an hour. Cap the accepted text at 500 characters inside the validator, then cap the whole request body at 8 KB in Flask's config, and compare the two refusals.

The validator gains one clause; the config line goes next to app = Flask(__name__):

MAX_TEXT = 500
app.config["MAX_CONTENT_LENGTH"] = 8 * 1024

    if len(value) > MAX_TEXT:
        raise ValueError(f"'{field}' must be at most {MAX_TEXT} characters")
$ curl -s -w '[%{http_code}]\n' -X POST localhost:5000/speak -H 'Content-Type: application/json' --data-binary @big.json
{"error":"The data value transmitted exceeds the capacity limit.","status":413}
[413]

The two caps catch different things at different depths. The 500-character rule is yours, phrased in your vocabulary, and it fires after the body has been read and parsed. The 8 KB rule belongs to the framework and fires before your function runs at all, which is what you want for a body large enough to be a problem by itself. Note that it arrives as JSON only because of the HTTPException handler from stage 2.

Exercise 3 — two callers, one GPU. Fire two /ask requests at the same instant from one shell, then poll both ids and time how long each takes to reach done.

A trailing ampersand on the first curl is enough to overlap them:

curl -s -X POST localhost:5000/ask -H 'Content-Type: application/json' \
     -d '{"prompt":"Count to three."}' &
curl -s -X POST localhost:5000/ask -H 'Content-Type: application/json' \
     -d '{"prompt":"What are you for?"}'
$ bash two_callers.sh   # your timings will differ
{"id":"j002","state":"thinking"}
{"id":"j003","state":"thinking"}

Both ids come back at once, which the blocking version could not have managed. Now poll each id every second and watch the second finish well after the first: two handler threads are still one GPU, so Ollama runs the turns one at a time however many callers there are. That measurement, taken on your own board, is worth more than any number in this chapter.

She now answers anything on the network that knows her address and knows how to ask. A motion sensor in the hallway does neither. It has no address book, no retry logic, and nothing to ask for; it wakes up, reports that something moved, and goes back to sleep. Chapter 90 puts a broker in the middle, where a sensor publishes one short message to a named topic and she subscribes to it, and neither end ever learns where the other lives.