group conversations with models and local files
0

Configure Feed

Select the types of activity you want to include in your feed.

hezo / NEXT-PLAN.md
16 kB

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) → X25519 identity 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 only identity_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 / .get with the prf extension. 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. createdBy is 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. See src/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.mjsdeleted. 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 streamText with @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.