group conversations with models and local files
0

Configure Feed

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

TypeScript 86.8%
JavaScript 10.8%
HTML 2.1%
Dockerfile 0.3%
91 1 0

Clone this repository

https://tangled.org/burrito.space/hezo https://tangled.org/did:plc:onarb3evb2dc6coe6i6cmbzq
git@tangled.org:burrito.space/hezo git@tangled.org:did:plc:onarb3evb2dc6coe6i6cmbzq

For self-hosted knots, clone URLs may differ based on your setup.



README.md

hezo#

A small, self-hostable web app for chatting with frontier models — and sharing those conversations with a trusted group — without handing your prompts, your replies, or your files to the server.

You bring your own provider key (Anthropic, OpenAI, Google, or any OpenAI-compatible endpoint). Model traffic goes browser → provider directly. The Hezo server only relays end-to-end encrypted CRDT updates between your own devices and any collaborators you've explicitly invited to a project.

Status: prototype. Self-hostable, in daily use, but rough edges and no formal security audit. Treat it as such.

What it gives you#

  • Multi-device chat with your favourite models. Same project, same history, on any browser you sign into.
  • Group projects. Invite collaborators to a project; you all see the same conversations, files, and bots, all kept in sync.
  • Files and PDFs inside a project. Markdown/text/code/PDFs you upload ride along with the conversation history; drop a new file onto an existing one to replace its contents.
  • Multiple "bots" per project. Pin a system prompt + model to a bot, give it a handle, and @-mention it in a conversation.
  • Bots that follow you. Configure a bot once and it auto-joins every project you open — the ones you already have and any you're later invited to. The recipe (provider, model, system prompt, handle) syncs across your devices; it holds no keys, so invoking a bot always spends the @-mentioner's own provider key.
  • Works offline; syncs when it's back. Local-first; conflicts are resolved by an Automerge CRDT, not by overwrites.
  • Self-hostable from one Dockerfile. SQLite + a single Node process. See DEPLOY.md.

Architecture & why it's set up this way#

The server is a thin signaling + relay layer. It authenticates users, tracks project membership, and shuttles opaque encrypted blobs between devices. It does not see your conversations.

Minimal server storage#

The server's SQLite database holds:

  • Account directory: an HMAC of your email, your public identity key, and your WebAuthn credentials. The HMAC uses a deployment-wide secret kept in data/email_hmac.key — a DB dump alone does not let anyone recover anyone's email address. Plaintext email is sent over the request only at login/register (and immediately discarded after the in-memory HMAC). Display names live in the project CRDT, not here. There's no telemetry, no analytics, no advertiser anything.
  • Wrapped key material: per-user wrapped copies of each project's symmetric key, and (optionally) wrapped provider API keys and your wrapped bot-recipe library for cross-device portability. "Wrapped" = encrypted with a key derived client-side from your passkey PRF output or your passphrase. The server never sees the wrapping key and cannot decrypt these blobs.
  • Project membership and invites: project names (used for the project list UI) and the user IDs that can access each project. Invite tokens are one-shot.
  • The outbox: an append-only queue of end-to-end-encrypted CRDT updates with a 30-day TTL. The server cannot read these — they are AES-GCM ciphertexts under a per-project key it has never seen in the clear.

The server does not store conversations, turns, files, bots, system prompts, model responses, provider API keys (in the clear), or anything that would be useful to an attacker who walked off with the database.

End-to-end encryption#

Each project has a 256-bit AES-GCM key (K_proj) generated on the client. When you invite someone, your browser wraps K_proj with a key derived from their identity material and hands the wrapped copy to the server; only their browser can unwrap it. All CRDT updates published to the outbox are encrypted under K_proj before they leave the device.

Sign-in: both passkey and passphrase, always#

Every account enrolls both methods at signup. Same master key underneath; each method is just a different way to recover it. The passkey gives one-tap login on devices that support WebAuthn PRF; the passphrase covers iOS browsers and recovery if a device is lost.

  • WebAuthn passkey with PRF: device's secure enclave returns a 32-byte PRF output. The PRF either is the master (legacy passkey-first accounts) or unwraps a stored wrapped_master_passkey blob to recover the master (accounts that added a passkey after signing up via passphrase). Each credential carries its own wrap, so multiple devices coexist without stepping on each other.
  • Passphrase stretched with Argon2id: derives a wrap key + a verifier from (passphrase, salt). The wrap key never leaves the browser; the server stores only the verifier and the passphrase-wrapped master.

Either path lands on the same master, which HKDF-derives the identity keypair and project wrap keys. The master is never persisted to disk in the clear and never leaves the browser.

Email is stored as an HMAC, so neither method's lookup requires the server to see plaintext email beyond the in-flight login request.

Provider keys never touch the server in the clear#

When you paste your Anthropic / OpenAI / Google key into Hezo, the browser stores it locally (IndexedDB) and uses it to call the provider directly. If you opt in to cross-device sync of provider keys, the key is encrypted (with a domain-separated key derived from your master) and stored wrapped — the server still can't read it, but your other devices can.

This means: the server has no path to your model bill, and no path to your model traffic.

CRDT sync (Automerge)#

Each project is a single Automerge document containing conversations, turns, files, bot definitions, and per-member profiles (display name and similar user-facing data the server doesn't need to see). Local edits go through Automerge.change → IndexedDB → a throttled broadcast to the outbox. Peers (your other devices, your collaborators) pull from the outbox over a WebSocket, decrypt, and Automerge.merge.

A few details that matter in practice:

  • Each device tracks a per-project createdAt watermark and asks the server for everything newer on connect. Outbox entries older than 30 days are pruned by the server; the snapshot mechanism (next bullet) ensures nobody needs them.
  • Fresh devices "adopt" a peer's snapshot wholesale on first sync (Automerge.clone() to take a unique actor id) to avoid root-map conflicts. After that, deltas merge cleanly.
  • Devices that haven't received a first snapshot block writes until they have one, so a brand-new device doesn't accidentally create a divergent root document.

The upshot is that two people editing the same conversation at the same time, or one person bouncing between phone and laptop, just works — and keeps working through flaky networks and offline stretches.

Run#

Requires Node 20+.

npm install
npm run dev

That brings up Vite on 5173 and the API server on 3001 (Vite proxies /api and /ws). For production-style deployment, see DEPLOY.md (Dockerfile + Railway recipe).

The first sign-in on a fresh instance becomes the founding user. Everyone else needs an invite URL.

Limits to be aware of#

  • Email isn't verified. Anyone with a valid invite URL can claim any email on the instance. Fine for a trusted group; not safe if invite links leak.
  • Per-file upload cap is 1.5 MB. Whole-doc snapshots ride the same 4 MB outbox slot.
  • Outbox TTL is 30 days. A device that goes dark for longer than that has to re-sync via snapshot from a peer that's still online. If no peer is online and the outbox has rotated past your watermark, you're stuck until one comes back.
  • No formal audit. Crypto choices are conventional (AES-GCM, X25519, HKDF, Argon2id, WebAuthn PRF), but nothing here has been independently reviewed.

Layout#

src/
  App.tsx               UI shell — auth, project list, project view
  main.tsx              entry point
  lib/
    auth.ts             passkey + passphrase flows (client)
    api.ts              REST helpers
    crypto.ts           AES-GCM / HKDF / X25519 primitives
    keystore.ts         master-key + wrapped-key handling
    userbots.ts         account-level bot recipes: sync + project propagation
    crdt.ts             Automerge document shape
    store.ts            per-project store: local ops + outbox in/out
    outbox.ts           encrypt → send / receive → decrypt
    idb.ts              IndexedDB persistence
    providers.ts        model-provider adapters (Vercel AI SDK)
server/
  index.ts              Fastify app
  auth.ts               WebAuthn + passphrase verification + passkey enrollment
  projects.ts           project + membership endpoints
  outbox.ts             ciphertext relay
  ws.ts                 live delivery
  db.ts                 SQLite open + migration runner
  paths.ts              DATA_DIR helper (shared by db + secret)
  secret.ts             deployment HMAC key for email blind index
  schemas/index.ts      versioned migrations (SQL + JS apply)