Source: ~/.unfirehose/unfirehose.db (project_id=81, 4 sessions covering
2026-03-29 through 2026-04-05). Reconstructed via chronological replay
of Write/Edit tool_input on file_paths under /home/fox/zebra-report/.
stats:
files reconstructed: 20
writes baselined: all (zero missing)
edits applied: 68
edits unapplied: 8 (1 SKIP pre-baseline, 6 FAIL old_string drift, 1 AMBIGUOUS)
unapplied edits represent small drift in 6 files; baseline content for
each is intact. quality verification deferred to phase 2.
recovered tree:
CLAUDE.md, Makefile
src/{tx,rx,pulse,carrier,chat,bt}.c
include/{modem,zebra}.h
test/{functional,integration,unit}.c, test/test.h
web/{index,kernel}.html, web/blog/style.css
blog/build.py, blog/posts/{001-volume-modem,002-sse-chatroom}.md
report: /tmp/zebra_recover_report.txt
script: /tmp/zebra_recover.py
4.2 KiB
| Title | Date | Slug | Summary |
|---|---|---|---|
| 2026-03-29 | 002-sse-chatroom | Server-sent events as the modem relay. The server controls browser volumes. No WebSockets. No JavaScript framework. IP TCP HTTP. |
the problem with version 1
Version 1 requires two C binaries running in two terminals. You must manually copy encrypted ciphertext between them. The browser is a carrier only — it cannot send or receive on its own.
The baud ceiling is the PA server round-trip. Every symbol waits for a kernel → userland → kernel round-trip. At 10 baud each symbol is 100ms. An 80-character ciphertext takes 80 seconds.
Version 2 removes all of that.
server-sent events
SSE is HTTP. One long-lived GET request. The server writes data: ...\n\n and the browser receives it. No handshake. No upgrade header. No new protocol. Firewalls ignore it because it looks like a slow HTML page loading.
WebSockets are bidirectional but complex. SSE is unidirectional and simple. For a chatroom the server is the relay — clients POST to send, SSE to receive. That is the right split.
browser → POST /send → server → SSE → all browsers
No WebSocket. No Socket.IO. No framework. Just HTTP.
the architecture
A Python HTTP server. No dependencies.
GET / → web UI (HTML page)
GET /events → SSE stream (text/event-stream)
POST /send → accept message, relay to all SSE clients
GET /volume → current PA sink volumes (JSON)
POST /volume → set PA sink volume (pactl under the hood)
The server holds a list of open SSE connections. When a POST /send arrives it writes to all of them. Each browser receives the message instantly via its open event stream.
volume as the transport
The server calls pactl set-sink-input-volume INDEX VALUE% for each connected browser tab. It encodes the message as a UART symbol sequence and steps through it at the baud rate using a server-side timer.
Every browser tab is receiving the same symbol stream simultaneously. Any C process monitoring any tab sees the same signal. The server is the transmitter. The browsers are the antennas.
def transmit(message, sinks, baud):
period = 1.0 / baud
for byte in message.encode():
for bit in uart_frame(byte):
vol = VOL_MARK if bit else VOL_SPACE
for sink in sinks:
subprocess.run(["pactl", "set-sink-input-volume",
str(sink), f"{vol}%"])
time.sleep(period)
the chatroom UI
Plain HTML. A text input. A send button. A message list. SSE keeps it live.
const evts = new EventSource("/events");
evts.onmessage = e => {
const msg = JSON.parse(e.data);
appendMessage(msg.from, msg.text);
};
form.onsubmit = e => {
e.preventDefault();
fetch("/send", { method: "POST",
body: new FormData(form) });
input.value = "";
};
No framework. No build step. No npm. Reload the page and it reconnects.
the crypto layer
Each browser still has its ECDH key pair in localStorage. Messages are encrypted before the POST and decrypted after the SSE event arrives. The server is a blind relay — it sees only ciphertext.
The key exchange can now happen in-band. POST your public key to /keys. The server broadcasts it to all clients via SSE. Each client derives the shared key automatically.
browser A: POST /keys {"pub": "base64..."}
server: SSE → all clients data: {"type":"key","from":"A","pub":"base64..."}
browser B: deriveSharedKey(A_pub, myPrivKey)
Zero manual copy-paste.
web 1.5
HTTP forms. SSE. Static HTML. Server-side rendering where needed. The aesthetics of 2001 with the crypto of 2026.
No React. No bundler. No node_modules. One Python file. One HTML file. python3 server.py and it runs.
The web got complicated because people let it. It does not have to be. IP TCP HTTP. The protocol is fine. The software on top got weird.
Make web 1.5 sexy again.
status
Version 2 is the next build. The server is designed. The SSE relay is straightforward. The volume control is pactl subprocess calls to start — libpulse bindings later for speed.
The chatroom replaces the terminal. The crypto layer stays. The modem stays. The channel stays. Just the interface changes.