The engineering companion to the manual. The manual says what confcode does; this says how, and why several obvious alternatives were wrong.
Everything here is running in production. Where a decision cost real time to arrive at, it is written down as such rather than presented as though it were obvious.
Five layers, four languages, no framework and no build step.
| Worker + DOs | TypeScript on Cloudflare. Two Durable Object classes: Room, one per room code, holding messages, identities and the cast; and RoomRegistry, a singleton that guarantees only one room is ever open and carries anything that must outlive a room. |
| Chat + pages | Hand-written HTML, CSS and JavaScript served by the Worker. No framework, no bundler, no dependencies. The page is one file. |
| Badge | MicroPython on a Pimoroni Tufty 2350, with its own 5×7 block-matrix text renderer so the screen reads across a room. |
| Control | A Go terminal dashboard, plus a phone-shaped console inside the chat itself — because admin at a conference happens standing up, in a hall. |
| The cast | Claude Haiku 4.5, called with a fenced transcript it can read and can never be instructed by. |
The load-bearing idea: the chat never depends on the badge. Guests reach Cloudflare directly over their own cellular. The badge only feeds QR rotation and its own dashboard. If the rig dies entirely, the conversation is unaffected — which is what makes the badge a passive display rather than a server.
Not a policy — a property of how the token is made.
A join token is derived, not issued:
token = base32( HMAC-SHA256( BADGE_SECRET, "ROOM:window" ) )[0..8] window = floor( now / 5 minutes ) +90s grace either side
The badge and the server compute the same answer independently, from the clock. That single fact carries most of the design:
This cost a fortnight of silent failure. activate()
existed and was never called, so every guest was quietly signed out five
minutes after arriving — while the page promised their PIN would bring
them back. Nothing errored. The fix was one call; finding it needed a
real phone coming out of a pocket.
Related, same shape: /j/<token> must check for an
existing session before it checks the token. A bookmarked join
URL is stale within five minutes, so checking the token first told
people already legitimately inside that their code had expired.
The worst bug this project has had, twice, in two different disguises.
The first version indexed people by their PIN: pinidx:<hash of
the PIN> mapped straight to a person. Two people who happened to
choose the same PIN resolved to the same record — and the second arrived
as the first, holding their name, their guest number and their
standing, with nothing in the room able to tell that from the first person
coming back. With a suggester drawing from 32,768 values, that is a 5%
event in a room of sixty.
| ident:<key> | The account. Key derived from the display name; the record carries the name, a salted PIN hash, role, guest number, colour, and when it was made. |
| pinidx:<idx> | idx = HMAC(COOKIE_SECRET, name ‖ PIN). Name and PIN, so the same PIN chosen twice is two people. |
| who:<cid> | Which person a device is. The cid comes from the signed cookie, so this is exactly as strong as the session. |
| ban:<cid> | Checked on every connect, before the attachment is built. |
foldName leetspeak, accents, runs collapsed → impersonation checks normalizeName accents only, digits and runs kept → the identity key
Folding belongs in the check and never in the key. A key that collapses
runs turns AkaFukurou144 and AkaFukurou114 into
one record — and since names are assigned with a counter, that is two
people sharing an account. This has been got wrong once and caught by a
test that walks every name the room can issue.
Three rules that fall out of it. A scan plus a bare PIN always makes somebody new — resolving a bare PIN to an existing person is the same hole from the other side. Names are assigned from a fixed pool and never typed, so nobody can impersonate anybody. And a refused login says one thing: "no such name" and "wrong PIN" are the same sentence, or the gate becomes a directory of who is in the room.
Guest numbers skip repeated characters — 101, 102 … 109, 120, 121, 123. It costs a fifth of the range and means no two issued names can fold onto each other even if a normaliser changes underneath them. Uniqueness is also enforced at the moment of writing: the room refuses an occupied key rather than overwriting a person.
WebSocket hibernation, and the single most repeated bug here.
A Durable Object using acceptWebSocket is evicted between
messages and rebuilt on the next one. So:
Any counter or flag on this that is read in a later
invocation is already broken. It silently resets. Nothing errors,
nothing logs, and the feature simply does not happen.
This has bitten six times: the cast trigger never fired; the owner-PIN lockout never engaged, so the one credential granting owner control had unlimited guesses; two operational counters reset themselves; the "just joined" cue never once reached the model; and per-client rate limiting handed back a full burst after every eviction — four to five times the intended rate, indefinitely, under a comment saying the reset was fine.
If it must survive, it goes in state.storage.
What legitimately lives on the instance is a value written and read inside one invocation — a re-entrancy guard, a coalescing flag — and a cache whose loss is harmless. Everything else is loaded on the way in and written on the way out.
The room used to learn of a departure only from a close event, and a
connection that dies without one — cellular dropping, a lid closing —
could sit there indefinitely. It now uses Cloudflare's WebSocket
auto-response: the runtime answers the client's ping with
pong without waking the object, so the heartbeat costs
nothing and is not billed, and
getWebSocketAutoResponseTimestamp then says, per socket, when
it was last heard from.
Everything in the prompt is untrusted except one signed block.
< and > are replaced in every untrusted field, so nothing in the transcript can close it and forge the owner channel.concerns, bounded to names that actually appeared, advisory only. Routing model judgement into an action would hand that power to whoever writes the most persuasive message.Structured outputs reject maxItems. It 400s the whole
call. That cost days: every cast call failed, a bare catch {}
swallowed it, and the feature was simply absent with nothing saying why.
Array limits live in the description and are enforced in code after
parsing — and no listener error is ever swallowed again.
Hundreds of accounts is fine. Fifty simultaneous is a different thing.
Accounts cost nothing when nobody is holding them, and across three days there may be hundreds. The ceiling is on people chatting at the same moment, because that is the number the pacing, the name pool, the model's transcript window and a phone screen were all designed around. Past it a room stops being a conversation and becomes a feed.
Two blocks of reference, and a rule about what to say when neither covers it.
The cast could read a conversation and nothing else. It could not say when a keynote was, where the coffee was, or how somebody gets their name back tomorrow — which is most of what anyone actually asks at a conference.
| Self-knowledge | What this room is: how you got in, what the four AI guests are and are not allowed to do, the commands, names and PINs, capacity, what happens at close. Ships in the repo, grouped by subject, and readable in the console. Sent on every call. |
| A venue sheet | One event's basics — what it is, when, where, its shape, a couple of headline names. Stored on the registry so it survives the room code rotating, and switched on from the console. |
| Notes for today | Free text for whatever is true only this morning: where the badge is standing, a room change. |
A venue can be read straight off the event's own web page. What comes back is not a summary — it is a filled-in form:
name dates venue place host audience shape stay travel hotel, getting there topics[] speakers[] capped, because a handout is not a programme lookup ask where to send people instead of guessing
The sentences are then rendered here, from our own templates. So what crosses from a stranger's website is a set of values, and the grammar around them is ours. A field the page does not mention is left out, and a missing field produces one fewer sentence rather than a hedge — a small meetup renders two lines, a three-day summit renders six, from the same object.
The review step is about accuracy, not safety. Importing a fortnight-long programme produced "September 17 to 22" when the real range was 17 to 30 — the page text is capped and the later dates fell below the cut. A plausible wrong date is exactly what a person catches in two seconds and a scraper never will. So an import writes a sheet and waits to be read; it does not switch anything on.
Every sheet ends the same way, and a test fails if one loses it:
Anything else — sessions, times, rooms, speakers, tickets — check the summit's own agenda or ask at registration. That is the answer whenever I do not have something: I carry the basics, not the programme.
Being told to check the website costs somebody ten seconds. Being told the wrong room number costs them the session. So the cast never guesses a time, a place or a room, and it always has somewhere to send people — which is what stops "I don't know" being a dead end.
No links, ever. A link ends the conversation, and a URL from an AI carries an endorsement the one output you can least guarantee has no business carrying. The cast names a place in words; navigation belongs to the interface. The room's own filter enforces it — which is how we found that self-knowledge containing our own address made the model copy it and get its best answers silently killed.
Where the usual advice is actively wrong for this.
Do not turn on Bot Fight Mode, Super Bot Fight Mode, or any zone-wide bot rule.
The badge is MicroPython sending an Authorization: Bearer
header and nothing else — no JavaScript engine, no cookies, barely a
user agent. Any bot score would call it definitely automated and block
it. The QR would stop rotating, and the failure would look exactly like
the badge having died, in a hall, mid-show.
/badge/*, /ws and /j/ are
architecture, not traffic. A challenge on any of them breaks the rig
rather than protecting it.
At a conference most of a hall shares one egress address — the venue NAT, the hotel NAT, iCloud Private Relay, whatever the VPNs are doing. A third or more of the room can look like a single client. Nothing that would refuse a person may be keyed on an address alone.
There is no device identifier underneath it either: a Worker never sees a hardware address, because MACs are link-local and stop at the first router. The per-device identity here is the signed session cookie.
| Failures only | Attempts used to be counted before the outcome was known, so people rejoining successfully from one hotel NAT could exhaust the budget and shut the door behind themselves. |
| (address, name) | Five failures in ten minutes. Somebody fumbling their own PIN spends their own budget and nobody else's, and a success clears only their bucket. |
| address alone | Sized for a venue, not a person, so scripted guessing is bounded and a hall never reaches it. |
| Per door | /return and /kiosk/in have separate namespaces. They shared one, so guests failing at the rejoin page could lock the booth tablet out of signing in. |
| Per account | The control that actually protects a name, keyed on the account and indifferent to which network a guess arrived from. |
/rejoin is the only door into the room that does not need the
badge, so it is the only one a script can attack at scale and the only one
worth challenging. It is live in production, in Managed mode: most people
get an invisible check, and a visitor whose network has a poor
reputation gets a checkbox they can solve rather than a wall — which
matters when a third of a hall shares one address.
The code stays inert without configuration: no sitekey and the widget is never built and no script is fetched; no secret and the server never asks. It never loads on localhost either, so the browser suite keeps no external dependency — and it could not use one anyway, because Turnstile refuses tokens to headless browsers by design. A test run here issued one challenge and it came back unsolved, source browser HeadlessChrome. That is the feature working, and it means the happy path is confirmed on a real device rather than in CI.
It fails open if Cloudflare's own verification endpoint is unreachable — the alternative is locking a whole conference out of rejoining because of a third party's blip, and a guess still has to pass the per-account lockout. An explicit rejection is still a rejection; only an exception is forgiven, and it is recorded.
Credentials are separated and each rejects the other.
/badge/control takes the badge secret,
/api/admin/* takes the admin key, the booth takes its own
id and PIN. A lost badge must not reach the admin surface.
A door that has not been built cannot be left open. Booth mode 404s until both its secrets exist — it used to arm on one of them, with the id check passing everyone when the id was unset.
| Gated deploy | npm run deploy runs typecheck and tests first. Running wrangler deploy directly has put three bad builds into production. |
| Verify after | Against production, not the deploy log. A "successful" deploy has shipped without the edits more than once. |
| Tests | 336 Worker tests in about six seconds, 79 badge tests, and a browser suite across five device profiles — iPhone, iPhone SE, Pixel, Chrome and Safari. |
| WebKit is not optional | It is the engine iOS Safari runs, and every UX failure this project has had was a WebKit-on-iPhone failure that jsdom and Chromium both called fine. |
| Secrets | Worker secrets, never the repo: the admin key, the badge secret, the cookie secret, the owner PIN and its authenticator, the Anthropic key, and — when booth mode and Turnstile are switched on — their own. Values live in 1Password. |
A test harness that differs from the real API hides real bugs.
A WebSocket stub with no OPEN constant made every guarded
send silently no-op while the tests stayed green. Make the stub behave
like the thing.
Every test should be able to fail. When adding a regression test,
break the code first and confirm it catches it. A ban test here said
/ban 1 for weeks after guest numbers moved to 100, so the
room answered "No guest 1." and the assertion passed on nothing.