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
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.
# 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.
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.
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.
@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.
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.
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.
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
- 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_fieldrefuses, and say what each one would have done downstream if it had been let through. - I can explain why
/speakanswers 202 and the blocking/askanswers 200, from what each has actually finished by the time it returns. - I can read a
curl -vtranscript 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.
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.