hezo#
Small-group experiment: a multi-user Claude chat app, synced over iCloud Drive using Automerge. Single Electron app, file-per-actor sync, branching-DAG conversations under the hood.
What this is (and isn't)#
It is a skeleton — enough to demonstrate the architecture end-to-end:
- Sign in with email + Anthropic API key (key stored in macOS Keychain).
- Create a project, which becomes a folder under your sync root.
- Create conversations and send messages; Claude replies are billed to whoever hit send.
- Two clients pointed at the same project folder merge each other's changes.
It is not production-ready. See "stubbed / missing" below.
Run#
Requires Node 20+ and macOS (for the iCloud default and the Keychain integration via keytar).
npm install
npm run dev
If you get an ABI mismatch from keytar, run:
npx electron-rebuild -f -w keytar
Sync architecture#
- One project = one folder under your sync root (default
~/Library/Mobile Documents/com~apple~CloudDocs/Hezo/, picker in UI). - One file per actor:
doc.<userId>.automerge. Your client only ever writes its own file; it reads everyone else's. This sidesteps iCloud's "conflict copy" behavior when two clients write the same file concurrently. - Watcher (chokidar) merges any peer file on change.
- Blobs (attachments, future-large content) live in
blobs/<sha256>and are referenced by hash from the doc. Not used yet.
Data model#
Defined in src/shared/schema.ts. Highlights:
Turnis immutable once written. "Editing" a turn means creating a sibling with the sameparentId. So the conversation is a DAG, not a list.- Two users replying to the same assistant turn simultaneously each create a child branch — no CRDT conflicts.
- Mutable metadata (title, system prompt, role) uses Automerge's default LWW.
- Sets are
{ [id]: true }objects, not arrays, to avoid array-merge ordering surprises.
The UI currently flattens the DAG into "the chain to the most recent leaf" and ignores other branches; the data is there to do better.
Testing peer-to-peer locally#
You need two Electron instances writing as different users into the same project folder. Easiest path:
- Sign in as
alice@…, create a project. Note the project folder path. - Quit the app, run again with a different
userDatato get a separate identity:
Sign in asnpm run dev -- --user-data-dir=/tmp/hezo-bobbob@…. In the project list, change the sync root to the same parent dir Alice used, then open Alice's project. - Send a message from one window; watch it appear in the other within ~1s (no iCloud involved — chokidar reacts to the same local file).
For real iCloud testing, two machines logged into the same Apple ID with the folder shared via iCloud Drive sharing.
Stubbed / missing#
- No branching UI — the DAG exists in the data; the renderer always shows the latest leaf chain.
- No member-invite flow — peers join a project by being given the sync folder out of band.
- No presence indicators — no "alice is composing".
- No file attachments / files-in-project.
- One project open at a time.
- No system-prompt or model editor UI — defaults baked in (
claude-sonnet-4-6). - No conflict-copy reconciliation — if iCloud does produce
doc.… 2.automergefiles we don't merge those. Real fix is the file-per-actor scheme already in place (which makes this rare), plus a periodic sweeper. - Orphaned streaming turns — if a client closes mid-stream, the assistant
turn stays at
status: 'streaming'forever in the saved doc. A startup sweep should mark stale streaming turns from this actor aserror. - Full-doc save per flush — during streaming we call
Automerge.saveon the whole doc every 250ms. Fine for small docs; for long-running projects switch toAutomerge.saveIncrementaland a separate changelog file.
File map#
src/
shared/schema.ts types shared between main and renderer
main/
index.ts app entry, IPC handlers, project lifecycle
sync.ts ProjectSync: Automerge + file-per-actor
claudeApi.ts Anthropic SDK call + DAG → message list
keychain.ts keytar wrapper
preload/index.ts contextBridge surface (`window.mc`)
renderer/
index.html
src/main.tsx
src/App.tsx Login → ProjectList → ProjectView → ConversationView