Hezo next-version plan — server-as-signaling, BYO-model, passkey, bots-as-participants#
Working doc. Replaces the v1 architecture documented in PORT-PLAN.md. Four interlocking changes, evaluated in session 2026-05-26. Locked decisions below; tactical details deferred to implementation.
Why#
v1 (the Centrosome port) succeeded as an architectural experiment but pinned us as a content host: chat + files + blobs live in our SQLite + volume. That's a privacy story we don't want to tell, a liability we don't want, and a cost that scales with usage. v2 walks the server out of the content path entirely: identity + signaling + ephemeral delivery, nothing else.
Simultaneously: switch from Hezo-as-Claude-app to Hezo-as-chat-with-pluggable-bots, where the model is a participant (not the centerpiece) and users bring their own keys for whatever providers they like.
Decided#
1. Server holds zero content (replaces #52 evaluation)#
- No turns table, no files table, no blobs dir. Server schema becomes identity + routing only.
- Signal-style staging outbox is THE sync mechanism. A client posts an
encrypted ciphertext addressed to a
recipient_user_id. Server stages it as an opaque blob, delivers over WebSocket when recipient is online, deletes on ack. TTL'd (30 days) regardless. No WebRTC, no peer mesh. - All inter-peer communication goes through the outbox. Live messages are small ciphertext chunks. Late-joiner archive bootstrap is one outbox entry from an online peer carrying the full CRDT state snapshot.
- CRDT in browser IndexedDB (Automerge or Yjs, TBD) holds the project state. Per-receipt merge gives near-real-time feel when peers are both online.
- Historical archive is peer-held. Brand-new invitee with no online peer sees nothing until a peer arrives and ships the archive. Outbox alone doesn't reconstitute history.
Encryption#
- Per-project group key, wrapped per member. AES-256-GCM data key. Each member's copy is wrapped to their identity public key via X25519 ECDH + HKDF.
- Add member: one wrap. Remove member: rotate key, re-seed peers (via outbox), drop the wrapped row.
- Metadata stays minimal: outbox entries are
{ id, recipient_user_id, project_id, ciphertext, created_at }. Server cannot see conv_id, parent_id, role, text, model id, token counts, file names. Clients route once decrypted.
Crypto primitives (tactical)#
- WebCrypto: AES-256-GCM (data) + X25519 (key agreement) + HKDF (KDF).
- Alternative: libsodium WASM if WebCrypto X25519 turns out fiddly across browsers.
2. BYO model via Vercel AI SDK, browser-direct (replaces #53)#
ai+@ai-sdk/*provider packages. Open-source npm, no Vercel account needed. Tools defined once via zod; SDK normalizes across providers.- Browser-direct model calls. No server proxy. Server stays out of the content path. Works for Anthropic, OpenAI, Google in the browser; works for Ollama / LM Studio against localhost.
- Providers v1: Anthropic, OpenAI, Google, OpenAI-compatible local.
- Model selection = per-bot. A project has bots; each bot is
(provider, model, system-prompt, display-name, createdBy). No per-conversation default model setting. To invoke a particular model, @mention its bot. - @pinger pays with their own provider keys. Provider keys are per-device IndexedDB only — never traverse the server. Bot config is a recipe; invoking it uses the @pinger's key for that provider. Pinger has no key for the bot's provider → friendly prompt to add one. Bot "owner" is just the creator/configurator.
3. Email + passkey auth, no recovery (replaces #54)#
- Email is identifier; passkey is credential. Single passkey per account.
- PRF-derived identity keypair. WebAuthn PRF returns a stable 32-byte
secret on the passkey.
HKDF(prf_secret) → X25519identity keypair. Synced passkeys (iCloud Keychain / Google Password Manager / 1Password) sync the credential across the user's devices; same passkey = same PRF = same identity key. Server stores onlyidentity_pub. - Single passkey = many devices via a syncing password manager. Cross ecosystems via WebAuthn cross-device QR per login. No multi-passkey / YubiKey-backup support in v1.
- No recovery. Lose all passkeys = lose account + decryption keys for old content. Documented in onboarding.
- Provider keys per-device only. Each device's IndexedDB holds its own Anthropic/OpenAI/Google/Ollama config. Server never sees them.
- Signup = invite-only after bootstrap. First-ever signup is the founder (same gating model as today). Subsequent: invite URL → passkey registration → in.
Implementation libraries (tactical)#
- Server: SimpleWebAuthn or similar for the WebAuthn ceremonies.
- Browser: native
navigator.credentials.create / .getwith theprfextension. Required support: Chrome 116+, Safari iOS 18 / macOS 15+, Firefox 119+.
4. Slack-style UX, bots as participants (replaces #55)#
- Conversation model: flat. DAG/branching collapses. Edits replace in place. Threads on a message create a sub-thread (still flat within). Consistent with the saved feedback "drop branching reflexes for multi-user."
- Addressing:
@-mention with typeahead. Type@→ popup of members (humans + bots). Pick one, mention renders as a chip. Multiple mentions in one message allowed. - Multi-bot in one message → each bot replies in parallel. Each @-mentioned bot fires its own reply independently; replies stream in timestamp-ordered. Pinger pays for each call separately.
- Any member can create / add a bot. Bots hold no secrets; cost is
borne by @pinger — no abuse vector.
createdByis attribution. - No-bot conversations are fine. Humans can chat without any bot in the room. The current "every conversation has a default model" goes away.
Shipped (2026-05-30): account-level bot-following. Beyond per-project bots, a user can configure a bot once and have it auto-propagate into every project they open — current and future, including invited ones. Recipes live in a per-device store synced to the server as a wrapped blob (
wrapped_user_bots, mirroring the provider-key sync); on project open the client seeds the recipe into the project CRDT (bot id = recipe id, so it converges across devices) and harvests the user's own existing project bots back into the library. Recipes carry no secrets — the @pinger still pays. Seesrc/lib/userbots.ts.
Server schema (v2 — slim)#
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE users (
id TEXT PRIMARY KEY, -- sha256(email).slice(0,16)
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
passkey_pub TEXT NOT NULL, -- COSE/JWK serialized
identity_pub TEXT NOT NULL, -- X25519 pub for project-key wrap
created_at INTEGER NOT NULL
);
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id)
);
CREATE TABLE project_members (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
wrapped_project_key BLOB NOT NULL, -- AES-GCM(K_proj, ECDH(member_priv, sender_pub))
joined_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE TABLE invites (
token TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
used_by TEXT REFERENCES users(id),
used_at INTEGER
);
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
-- The only content-bearing table; opaque blobs, server can't read.
CREATE TABLE outbox (
id TEXT PRIMARY KEY,
recipient_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
ciphertext BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL -- TTL'd regardless of delivery
);
CREATE INDEX idx_outbox_recipient ON outbox(recipient_user_id);
CREATE INDEX idx_outbox_expires ON outbox(expires_at);
-- WebAuthn challenge storage (short-lived).
CREATE TABLE webauthn_challenges (
user_id TEXT,
email TEXT,
challenge TEXT NOT NULL,
ceremony TEXT NOT NULL, -- 'register' | 'authenticate'
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
What's gone vs v1: turns, files, file_history, conversations, members (mirrored), per-project SQLite files entirely, blobs directory.
CRDT data model (browser-side)#
Per-project Automerge (or Yjs) document, lives in IndexedDB:
type ProjectDoc = {
meta: { name: string; description: string; createdAt: number }
bots: Record<string, {
id: string
name: string // for @mention rendering
provider: 'anthropic' | 'openai' | 'google' | 'openai-compat'
model: string
systemPrompt: string
createdBy: string // userId
createdAt: number
}>
files: Record<string, {
id: string
name: string
content: string // text files
binary?: { sha256: string; mime: string; size: number }
createdBy: string; createdAt: number
updatedBy: string; updatedAt: number
// history baked into CRDT op-log; no explicit ver column
}>
conversations: Record<string, {
id: string
title: string
createdBy: string; createdAt: number
}>
turns: Record<string, {
id: string
convId: string
threadParentId?: string // for threads (not branches)
author: string // userId or botId
paidBy?: string // userId who triggered (for bot turns)
role: 'user' | 'assistant'
text: string // marked up with mention chips
createdAt: number
modelId?: string
inputTokens?: number
outputTokens?: number
}>
}
Binary blobs: same content-addressed sha256 pattern, but the bytes themselves travel through the outbox as encrypted payloads, not as server-stored blobs. Open: do we ever store binary blobs server-side at all? Probably not for v1; "files" are text-only until proven needed.
Wire protocol (v2 — slim)#
HTTP:
POST /auth/register/begin { email } → { challenge, ... } // WebAuthn create() options + PRF eval
POST /auth/register/finish { credential, identity_pub } → { sessionToken }
POST /auth/login/begin { email } → { challenge, ... } // WebAuthn get() options
POST /auth/login/finish { credential } → { sessionToken }
POST /auth/logout → {}
GET /auth/me → { user }
GET /projects → [ { id, name, role, wrapped_project_key } ]
POST /projects { name, member_keys } → { project } // includes self-wrapped key
POST /projects/:id/invites → { token, url }
POST /invites/:token/accept { wrapped_project_key } → { project } // existing owner pre-wraps
DELETE /projects/:id → {} // owners only; cascades members + outbox
WebSocket (/ws, session-authenticated):
inbound:
{ type: 'subscribe', projectId }
{ type: 'send', recipientUserId, projectId, ciphertext }
{ type: 'ack', outboxIds: [...] } // tell server it's safe to delete
{ type: 'presence' } // heartbeat
outbound:
{ type: 'snapshot', outbox: [...] } // on subscribe — anything queued
{ type: 'deliver', outboxId, projectId, senderUserId, ciphertext, createdAt }
{ type: 'presence', userId, ts }
That's the whole wire surface. Nothing about turns, conversations, files, tools, streaming. The CRDT inside the ciphertext carries all of that.
What gets deleted#
server/db.ts— replaced by a much slimmer one.server/ws.ts— replaced by an outbox gateway.server/schemas/index.ts— fresh v2 migrations.- All of v1's per-project SQLite scaffolding (cache, eviction sweep, etc.).
src/lib/api.ts— replaced by outbox client.src/lib/store.ts— replaced by CRDT-backed store.src/lib/claudeAgent.ts— replaced by Vercel-AI-SDK-based runner.src/lib/systemPrompt.ts— kept but redefined as bot-system-prompt templates rather than a project-level constant.src/lib/keystore.ts— kept; expanded to multi-provider.scripts/import-keychain.mjs— kept; expanded to per-provider.scripts/push-project.mjs— deleted. No server upload target.scripts/import-icloud.ts— kept but redirected to "import into the current browser's CRDT," not into server SQLite.
What stays from v1#
- The Fastify scaffold (HTTP + WS, but with new routes).
index.html, the PWA manifest, the CSS theme tokens.- Most React components — layout, sidebars, file viewer, conversation list, member list. State source changes, presentation doesn't.
- The harness (
scripts/harness.mjs). - Dockerfile (only env vars + the much-smaller server).
Migration story#
Prod has one imported project (Greece Golden Visa). Prototype-mode answer: wipe + re-import. v2 doesn't have an in-place migration from v1 SQLite into peer-held CRDTs; building that is out of scope.
Phased work plan#
Phase 1 — server slim-down (½ day)#
- New schema. Migration files (start from scratch, drop v1).
- Outbox WebSocket gateway. Send/deliver/ack/TTL.
- WebAuthn register/login routes via SimpleWebAuthn.
Phase 2 — passkey + identity in browser (1 day)#
- Register flow: prompt PRF, derive identity keypair, send identity_pub with credential.
- Login flow: PRF → identity_priv → ready.
- Invite-flow integration: existing member wraps project key for the new identity_pub on accept-side.
Phase 3 — CRDT + outbox plumbing (1 day)#
- Pick Automerge or Yjs (probably Automerge given prior familiarity).
- Per-project doc in IndexedDB.
- Outbox sender + receiver. Encrypt/decrypt boundary at the WS edge.
- Late-joiner archive request → online-peer ships snapshot via outbox.
Phase 4 — bots + Vercel AI SDK (1 day)#
- Provider key UI: add/edit per-provider keys per-device.
- Bot config UI: any member adds a bot recipe.
- Mention typeahead + chip rendering.
- Invoke flow: on send, scan mentions, for each bot mention → run
streamTextwith @pinger's provider key → write turn into CRDT. - Multi-bot mentions run concurrently.
Phase 5 — Slack-style conversation UX (½ day)#
- Drop the branching DAG navigation.
- Inline edits replace text in place.
- Sub-threads from a message anchor.
- Mention-aware composer.
Phase 6 — re-deploy (½ day)#
- Wipe Railway volume (or just delete prod project entirely; bootstrap fresh).
- Redeploy with v2 server.
- Document the breaking change.
Total: ~4.5 focused days.
Open / TBD#
- CRDT choice: Automerge vs Yjs. Tactical; pick during Phase 3.
- Outbox max payload size. Snapshot deliveries can be MBs. Either chunk or accept large rows. Probably chunk above ~1MB.
- Binary file support: defer v2 to text-only? Or piggyback on the outbox for binaries too with sha256-derived chunk addressing?
- Multi-device for the same user via outbox: each device is just another recipient. Send-to-self via outbox lets devices sync. Need to prove this UX in Phase 2.
- Email visibility: members' emails aren't secret today; same in v2. If we ever want anonymous emails, that's a separate layer.
Out of scope for v2#
- Hardware-key (YubiKey) backup / multi-passkey.
- Account recovery.
- Server-side rate limiting / billing-aware caps.
- Project search (purely client-side once decrypted).
- Federation across Hezo instances.