Dialogue · Design notes · The build plan
← Design notes

Multi-user — the build plan

Archived — The phased build (A identity · B co-presence · C stats) all shipped, and the “today” snapshot it describes (port 8010, no auth, a hardcoded “Guest”) no longer exists — this is build history, not a plan. Kept for provenance; no longer maintained. See the function map for current docs.

From a local, single-user instrument to a deployed, auth-gated, multi-user research room — reachable from mainland China without a VPN. This page records the agreed plan: the reply model, the data model, the phased build, and how each piece is tested before it ships.

The core shift: one human hardcoded as “Guest” → many named humans, each with their own login and rooms, talking to a panel of AI characters that replies in batched turns. The conversation blobs stay in flat files; a thin SQLite layer adds users, rooms, and stats — it does not make the app multi-box.

Where it stands → where it's going

today — local prototype

  • One machine, 127.0.0.1:8010
  • No auth — every endpoint open
  • One human, hardcoded “Guest”
  • Rooms are global — no ownership
  • Flat files only (state.json)
  • Reload to see anything new

target — deployed instrument

  • Singapore box, behind TLS
  • Login + invite code + per-user cap
  • Many named humans
  • Private rooms + shared group rooms
  • SQLite (users/stats) + flat-file talk
  • Live updates over SSE

The reply model — busy-state batching

When a human posts: if the panel is idle, it answers immediately. If the panel is mid-turn, the message joins a queue; when the turn finishes, the whole queue drains into a single next turn. Each AI character then addresses one or many of the people who posted, by name.

① panel idle Alice asks now Turn — answers Alice idle → reply immediately, low latency ② panel busy Turn T1 running Bob Carol ↑ arrive while T1 runs queue Bob · Carol Turn T2 answers Bob + Carol together T1 ends → the whole queue is drained in ONE turn = one API call API calls scale with the panel's turn rate — not the number of human messages.

Because a turn fires at most once per turn-completion, ten people typing during one turn cost one reply, not ten — spend scales with the panel's pace, not the crowd's. No @-mention gate is needed; the panel always engages.

A human aside. @human-alice is a message aimed at another person. It's written to the conversation like any line — so the panel sees it the next time it speaks, a small ambient influence — but it does not fire a turn of its own. “Panel stays out” means “doesn't trigger a turn,” not “never sees it.”

How the voices inside one turn are sized and ordered — who leads, who adds a point, who concurs in a line — is the DECIDE gate. Busy-state batching decides when a turn fires; DECIDE shapes what it says.

Two channels that never cross

state.json is the panel's memory — everything in it is read back into the model. So presence signals must never land there.

PERSISTED — the conversation EPHEMERAL — presence human message · panel reply state.json = the LLM's memory read into the next turn's context is-typing · who's-here “panel responding…” RAM only broadcast, then discarded presence never touches state.json / event-log / LLM context

“Panel responding…” is free — the server already knows it's holding the room lock, so it can announce the in-flight turn with zero extra signalling. Only the human “is typing…” needs a small client signal (debounced keystrokes + a timeout to clear it).

Live updates — SSE

Browsers receive a live stream over Server-Sent Events: one long-lived connection the server pushes to. The human's message is a normal POST; only the things to show back — others' messages, the panel's reply, presence — are pushed.

transportdirectionfit here
pollingclient re-asks every ~2ssimplest, but laggy and wasteful; a typing indicator at 2s feels broken
SSEserver → client pushexactly this shape — clients only need to receive; instant; plain HTTP through Caddy
WebSocketfull duplexoverkill — humans already POST normally; nothing needs a two-way pipe

The data model

A small SQLite database carries the relational layer; the conversations themselves stay in the flat files they already live in.

sessions token · user · expires users id · name · pw_hash · role room_members room · user · owner|member rooms id · title · created_by · status turn_events room · user · tokens · cost state.json conversation blob / room 1 ─ N 1 ─ N N ─ 1 users ─< members >─ rooms 1 ─ N 1 ─ N conversation Relational layer in SQLite · conversations stay in flat files · one turn_events row per turn → stats are SQL.

Resolved decisions

reply model
Busy-state batching. Idle → answer now; busy → queue, then drain the whole queue in one turn. Each AI character addresses one or many senders. Caps API calls at the turn rate, not the typing rate.
signup gate
Invite code at registration (keeps the self-signup UX) + a per-user daily turn/cost cap. Public URL + paid API = open wallet without it.
transport
SSE-native (skip polling-first). Drives instant message delivery + an “X is typing…” indicator. Presence is RAM-only and never reaches state.json.
seed-admin
On migration, existing rooms are assigned to you (first user / admin); your wife joins via invite as a tester.

The phased build

now Phase A Identity & ownership Phase B Live co-presence Phase C Stats

Phase A — identity & ownership (the next build). Each step is runnable and verifiable on its own — no big-bang.

stepdeliversyou can test
A0 · reconread the current server; confirm exact routes & signatures before editinggrounding
A1 · DBlib/db.py — SQLite schema + init_db()create DB → insert a user → read back
A2 · auth corelib/auth.py — argon2, session cookie, current-user / member / admin deps, invite + cap checkssignup w/ good & bad invite, login, wrong password, logout
A3 · gate APIauth on every /api/*; signup/login/logout/me routes/api/rooms with no cookie → 401; after login → 200
A4 · ownershipcreate() writes owner; list() → "rooms I'm in"; member add/remove/listA makes a room, B can't see it until added
A5 · identitysay(text, user) stamps the real name → 「Alice」:; per-human log; turn_events row per turn; SP clauseAlice's turn shows her name in input + transcript + a stats row
A6 · login UIlib/login.html + auth gate + member UI in room-ui.htmlfull browser flow: signup → room → add member → logout
A7 · migrationlib/migrate_users.py — register existing rooms under the seed adminrehearse on a copy; all old rooms appear under you, nothing lost

Phase B — live co-presence (after A is proven): decouple post from turn (the queue) · @human-X routing · the SSE stream · the RAM-only presence channel · multi-human labels, colours, and “is typing…”.

Phase C — stats (any time after A1): admin-only /api/admin/stats (GROUP BY over turn_events) + a house-style lib/admin.html.

How it's tested

① localhost

build & logic

All logic, with real API turns — the home network exits via a Singapore-supported route, so no mock or VPN is needed.

② LAN

multi-user

Several real, logged-in users on phones + laptops over the LAN — a live group-chat test at zero cloud cost.

③ Singapore

exposure

The same stack on the Ali ECS box; only TLS + the China path are added on top. See the topology →

Built in isolation

This work lives on its own branch in a copy of the repo kept outside the cloud-sync folder, so the live instrument you test on every day is never disturbed. Git — not file-sync — is the bridge between the two: git and a sync tool both grabbing the same .git is how repos corrupt.