A Protocol of Her Own
One mind, one process, and the wall it just hit
Everything she does today happens inside a single Python process. The event bus from chapter 15 feels like modules talking to each other, but every message on it is a function call: publisher and subscriber share one interpreter, one memory space, one crash. That was the right first architecture, and it has quietly become the ceiling. The language model wants the machine with the GPU. The body she is getting runs on a microcontroller bolted to an arm. Two programs on two machines cannot share a Python dict. They can only share a wire.
The wire is nearly free: a TCP socket, the reliable pipe every higher protocol is
built on. TCP promises that the bytes you send arrive, in order, without gaps or
duplicates. What it does not promise (and this trips nearly everyone exactly once)
is anything about messages. TCP is a byte stream. Send
{"command": "status"} and the receiver's recv(4096) may
hand back the whole thing, half of it, or all of it plus the front of the next
message; the operating system batches and splits traffic however it likes. Call
json.loads() on half an object and the error reads like corrupt data,
but nothing is corrupt. You assumed a boundary the protocol never drew.
Drawing that boundary yourself is called framing, and the volume closes by doing it in the simplest form that works: one message is one line — serialize the dict to JSON, append a newline, and on the other side refuse to parse until that newline has arrived. Held on both ends, that one-sentence contract turns a stream of bytes into a sequence of dicts her modules can trust.
An echo first, then a protocol
# labs/wire.py
import json
import socket
import threading
import time
HOST = "localhost"
PORT = 9999
def serve_raw() -> None:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((HOST, PORT))
server.listen(1)
conn, _addr = server.accept()
with conn:
raw = conn.recv(4096) # one read
conn.sendall(raw) # echo the exact bytes back
server.close()
if __name__ == "__main__":
threading.Thread(target=serve_raw, daemon=True).start()
time.sleep(0.1)
with socket.create_connection((HOST, PORT)) as sock:
sock.sendall(json.dumps({"command": "status"}).encode() + b"\n")
reply = sock.recv(4096)
print(f"Raw reply: {reply!r}")
$ uv run python labs/wire.py
Raw reply: b'{"command": "status"}\n'
The server blocks on accept(), so it runs on a daemon thread while the
main thread plays client; the time.sleep(0.1) gives it a moment to
bind and listen, and skipping that pause earns a
ConnectionRefusedError. Two conversions bracket the trip:
json.dumps produces a str, sockets only carry
bytes, so .encode() goes out and .decode()
will come back. And look at what returned: raw bytes, trailing \n
still attached. We have a working pipe. We do not yet have messages.
def recv_message(sock: socket.socket) -> dict:
data = b""
while True:
chunk = sock.recv(4096)
if not chunk: # empty bytes: the peer closed
break
data += chunk
if data.endswith(b"\n"): # the frame is complete
break
return json.loads(data.decode().strip())
def serve_once() -> None:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((HOST, PORT))
server.listen(1)
conn, _addr = server.accept()
with conn:
msg = recv_message(conn)
response = {"status": "ok", "echo": msg}
conn.sendall((json.dumps(response) + "\n").encode())
server.close()
$ uv run python labs/wire.py
Parsed reply: {'status': 'ok', 'echo': {'command': 'status', 'source': 'glados'}}
recv_message is the framing rule made executable: keep appending
chunks until the buffer ends with the delimiter, or until an empty chunk says the
peer hung up. The loop cannot collapse to a single recv, because a
single recv makes a promise TCP never made. And the same function
serves both sides of the wire, client reading the reply, server reading the
request. One helper, one contract, no drift.
# labs/wire.py — full file
import json
import socket
import threading
import time
HOST = "localhost"
PORT = 9999
def recv_message(sock: socket.socket) -> dict:
data = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
return json.loads(data.decode().strip())
def send_message(host: str, port: int, message: dict) -> dict:
with socket.create_connection((host, port), timeout=5) as sock:
sock.sendall((json.dumps(message) + "\n").encode())
return recv_message(sock)
def handle_client(conn: socket.socket) -> None:
with conn:
msg = recv_message(conn)
response = {"status": "ok", "echo": msg}
conn.sendall((json.dumps(response) + "\n").encode())
def serve_once(host: str, port: int) -> None:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
server.listen(1)
conn, _addr = server.accept()
handle_client(conn)
server.close()
def main() -> None:
threading.Thread(target=serve_once, args=(HOST, PORT), daemon=True).start()
time.sleep(0.1)
response = send_message(HOST, PORT, {"command": "status", "source": "glados"})
print(f"Response: {response}")
if __name__ == "__main__":
main()
$ uv run python labs/wire.py
Response: {'status': 'ok', 'echo': {'command': 'status', 'source': 'glados'}}
Two additions make the demo livable. SO_REUSEADDR: without it, killing
the server parks the port in TIME_WAIT for up to two minutes, and
every restart during development greets you with Address already in use.
And timeout=5 on the client connection, so send_message
fails fast against a machine that is down instead of blocking forever.
main owns the demo, but send_message and
recv_message are the actual deliverable, the pair volume 4 will import
every time two of her processes need a word.
Line-delimited text buys you something binary protocols charge extra for:
inspectability. With the server running, printf '{"command": "status"}\n' | nc
localhost 9999 prints the framed reply straight to your terminal; no client
code, just a wire you can question by hand. SMTP and Redis made the same trade for
the same reason. Debuggability is a feature you design in, and this chapter just
designed some in.
Why this works: a contract about endings
A TCP connection gives you ordering and delivery but no record boundaries; imposing
structure on the stream is always the receiver's job. The delimiter makes that job
finite, and given it, recv_message is a three-case machine. Buffer ends
with \n: the frame is complete, parse it. Chunk is non-empty but no
delimiter yet: a partial frame, loop and append. Chunk is empty bytes: EOF, the peer
closed, stop and parse what arrived. Only one combination hangs it: a peer that
sends no newline and holds the connection open.
One worry to retire early: what if a message contains a newline, a
transcript with line breaks, say? It cannot break the frame, because
json.dumps never emits a raw newline inside a string value; it escapes
it to a backslash followed by an n, two ordinary characters. The first
actual 0x0A byte on the wire is provably the one we appended: the
delimiter is safe because the serializer promises never to use it.
Newlines are one answer to the question every protocol must answer: where does a
message end? The other classic answer is a length prefix, where a few bytes announce
the payload size and the receiver reads exactly that many. You have met it without
the name: the WAV files of chapter 3 open every chunk with a declared size,
length-prefix framing on disk. Prefixes win for binary payloads; newlines win for
JSON, being trivial to implement and readable in a terminal. What never works is the
third option people ship by accident, hoping one recv equals one
message — the failure below runs it so you never have to.
You test the framed server, it works, and you simplify: one recv, no
loop, parse immediately. Every small message survives. Then a real one arrives,
larger than a single TCP segment:
def serve_once() -> None:
...
conn, _addr = server.accept()
with conn:
raw = conn.recv(4096) # one read, assumed complete
msg = json.loads(raw.decode()) # BUG: raw may be half a frame
conn.sendall((json.dumps({"echo": msg}) + "\n").encode())
# and the client sends a payload bigger than one segment:
big = {"command": "log", "text": "x" * 8000}
$ uv run python labs/wire.py
Exception in thread Thread-1 (serve_once):
Traceback (most recent call last):
...
msg = json.loads(raw.decode()) # BUG: raw may be half a frame
File ".../json/decoder.py", line 361, in raw_decode
obj, end = self.scan_once(s, idx)
json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 28 (char 27)
Read the message the traceback is actually sending. "Unterminated string" points a
finger at the JSON, and the sender will swear the JSON was whole when it left,
because it was. The column number is the tell: 28 is exactly where the long
"text" value begins, so the decoder ran off the end of the buffer
mid-string. A single recv returned the first chunk of an 8,000-byte
frame and the parser was handed a truncated object. The bug lives two lines above
the line that raised, in an assumption, not an expression: one read is not one
message. Framing bugs surrender to this move — stop asking what is wrong with the
data and ask who decided the data was complete.
Checkpoint at the edge of the volume
- I can list the three things a single
recv(4096)may legally return for one sent message, and say why the OS is allowed all three. - I can state the framing contract in one sentence and point to the two lines that enforce it, one on each side of the wire.
- I can trace
recv_message's exits (delimiter seen, empty chunk) and name the one peer behavior that would hang it. - I know why a newline inside a message value cannot break the frame:
json.dumpsships it as two characters, backslash thenn. - Handed a
JSONDecodeErrorfrom a socket, I check who decided the buffer was complete before I suspect the data.
Exercise 1 — a server that answers differently. Make
handle_client dispatch on msg.get("command"): reply
pong to "ping", an uptime dict to "status",
and a structured error to anything else. Send all three and watch the
replies.
Branch on the command and build a different response dict per case, the
unknown branch returning something like {"status": "error", "reason":
"unknown command: dance"} instead of raising. This is chapter 20's
routing, one wire lower: an unknown command is the fallback agent's problem
again, and the answer is again a reply that names the failure, never a crash
that ends the conversation.
Exercise 2 — surviving a dead server. Wrap
send_message so a missing server logs a warning and returns
None instead of crashing the caller. Prove it by sending to a port
nothing listens on.
Catch ConnectionRefusedError around the connection, print
something like [warn] no server listening on localhost:65000, and
return None; against the dead port the demo now warns and keeps
running. Add socket.timeout to the same except clause and the
machine-is-down case degrades just as politely. In volume 4 her modules start
and stop independently, so "the other side is not there right now" is a normal
Tuesday, not an exception worth dying for.
Exercise 3 — a server that stays up.
serve_once serves one client and exits. Turn it into a persistent
server: loop on accept() and hand each connection to
handle_client on its own daemon thread. Test with three clients in
a row.
Replace the single accept with while True, raise the backlog to
listen(5), and spawn
threading.Thread(target=handle_client, args=(conn,),
daemon=True).start() per connection; three sequential clients each get
their echo without a restart. Daemon threads keep a stuck client from pinning
the whole process. This persistent server is the seed of every module's front
door in the next volume.
Stand back and look at what this volume added. She curates her own few-shot examples from the exchanges you rated well. Her preferences overlay your choices onto safe defaults. Her speech is normalized before the voice reads it, and she reads your mood back, letting it fade honestly with time. The lab notebook records every experiment with the hypothesis written before the result. Her plans are data you can query. Her body already exists as a simulated interface that clamps what you ask of it. The pipeline reports where its milliseconds go, her sensor logs bound their own growth, and as of today her processes own a wire protocol with framing they can trust. Volume 2 gave her a mind; this volume gave her a craft: habits of measurement, honesty, and repair that a growing system needs more than any single ability.
Volume 4 is called One System, and it earns the name: the pile of working scripts becomes an architecture drawn on purpose, modules composed behind explicit seams, with a startup sequence that validates every dependency before she says a word. Then the protocol you built today gets its first real job: a wire out of the computer entirely, to an ESP32 microcontroller listening on the far end. Her body's nervous system starts there, and it speaks in framed messages because you decided, this chapter, where a message ends.