eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

πŸ€– CLAUDE.md

Working notes for anyone (human or agent) changing this codebase. User-facing docs and the encryption rationale live in README.md; this file is about how we build and the decisions we've committed to.

The one rule: end-to-end encryption is load-bearing

The whole point of lchat is "if the server is compromised, the attacker gets nothing." Every change must preserve that:

  • The server only ever stores/relays opaque ciphertext plus key material it cannot open. It must never see plaintext message content, screen names, or file bytes, and must never receive any usable key β€” not a thread key, the instance key, or a user's private key.
  • Each user has a keypair (ECDH P-256); the private key lives only in the browser. Every thread has its own AES-GCM key, wrapped to each member's public key via ECIES; the server stores only the wrapped blobs. There's also one per-instance "instance key" (for all-members content like profile photos), likewise wrapped per member. All wrap/unwrap + encrypt/decrypt is in the browser (web/static/crypto.js).
  • Invite links carry a one-time secret in the URL #fragment (/join/<name>#<T>) used only to bootstrap/ship the joiner's keys; fragments are never sent to the server.
  • The admin token authorises roster management only β€” it is not a decryption key. Store only its hash; never log tokens or keys.
  • When you add a feature that handles user content, encrypt it client-side and store a blob. Example: attachments upload encryptBytes output; the filename and MIME type ride inside the encrypted message payload, never as server metadata.

Two deliberate exceptions, both easy to gate behind a setting later:

  • Link previews (unfurl.go) β€” the server fetches a posted URL to build an OpenGraph card, so it briefly sees URLs (not message text).
  • GIF search (apps/gif.js + hostBridge.gif in shell.js) β€” the client queries Giphy directly (CSP pins connect-src to *.giphy.com), so Giphy sees the member's search terms and IP; the lchat server sees nothing β€” the chosen GIF is downloaded client-side, encrypted to the thread key, and posted as an ordinary opaque attachment. The Giphy API key is the space's: the first member to run /gif supplies one and it folds into the space thread's log (docs/space-apps.md), so every member reads it and nobody is asked twice. That every member can read it is the point, not a leak β€” a Giphy key is a public client identifier, not a secret β€” and the server still only ever holds the ciphertext. This is the reference for the implicitly space-placed shape (places: ["space"] + listed: false) and for space-shared app config generally: docs/app-credentials.md. πŸ€– When the instance points at a fetch service (LCHAT_FETCH_URL), search prefers its /api/gif instead β€” platform key, no space key needed, media riding the signed /img proxy so the browser never talks to Giphy; the space-key direct path stays as the fallback/self-host story (docs/fetch.md Β§ Giphy).

Instance branding is plaintext by design and doesn't count against the rule β€” it's admin-set, never user content. Three things: the group name (config group_name), the optional server avatar (instance_avatar table, served in the clear at GET /api/server-avatar), and the optional space description (config group_description). All must render where no key exists yet β€” the join/onboarding cards, the native add-chat screens, and the home Save-passkey card β€” so they can't be E2E-encrypted like profile/group photos. The description (like the admin name/email) is gated a notch tighter than the other two: served to members and to valid invite-link holders (GET /api/key-invite/{id}), never on the fully-public reads. Keep branding to this narrow set; anything that is user content still gets encrypted client-side (e.g. a member's own "about me", users.description_blob, under the instance key).

Bots are the rule's scoped, disclosed exception-by-membership: a bot is an ordinary E2EE member whose "device" is a machine the operator runs (agent/), so that machine holds the bot's private key and can decrypt exactly the threads the bot is in β€” never anything else, and the lchat server process still never holds a usable key. Two invariants follow: bot code never runs inside the server process (compromising the server must not yield bot keys or the Anthropic key), and the sidecar stays a pure outbound client (no inbound ports), so operators can move it to a better-defended machine β€” off-box, behind NAT, or in a no-inbound private network. Trust model + the Go sidecar (the default now β€” lchat bot run, a subcommand of the single binary, no Node; agent/ is kept on purpose as the runnable Node reference, not deprecated): docs/bots.md.

Native iOS push goes through a central APNs relay (push.elevenmessenger.com, push/) so the Apple .p8 signing key lives in exactly one place instead of on every instance. Like the admin token, the .p8 is auth, not a decryption key: the relay forwards the same ciphertext blob Apple already sees (device token + opaque thread id + sealed bytes), never plaintext and never a usable key β€” instances authenticate to it with a per-instance bearer token. The one copy of the signer + aps envelope lives in internal/apns/ (shared by the messenger and the relay so they can't drift). Web Push is unaffected (per-instance VAPID, no shared secret). See docs/push-relay.md.

πŸ€– The second rule: built to outlive AI

Everything built here is designed to outlive AI. This codebase was largely produced with AI assistance, and it must never depend on that assistance:

  • All code stays human-manageable. If AI became unavailable or unaffordable tomorrow, a small human team could read, maintain, extend, and ship this product. That's a load-bearing reason for standing choices: vanilla JS with no build step, one boring Go binary, additive migrations, pure helpers split out for testing, comments that explain why.
  • All tools stay human-understandable. Scripts, deploy paths, and dev harnesses must be readable in one sitting and operable by hand. Never add machinery that only an LLM can drive, and never encode critical operational knowledge solely in prompts or agent memory β€” it belongs in the repo's docs.
  • Nothing assumes AI stays cheap. Features, operations, and the product's economics must outlast today's low relative cost of AI-assisted production. When a design choice trades human legibility for AI-assisted convenience, legibility wins.

The upside cuts the other way too: if high-capability AI keeps getting cheaper, a human-legible, well-tested baseline is the most solid floor it can build from. Either way the product doesn't wobble with the AI industry's fortunes.

Architecture

Single static Go binary; the frontend and a pure-Go SQLite engine are compiled in, so deploy is one file (no cgo).

  • main.go β€” routes (all wired in Server.handler() so tests exercise the real router via httptest), HTTP handlers, bootstrap. Reads LCHAT_ADMIN_TOKEN, LCHAT_DB, ADDR from env.
  • db.go β€” SQLite (WAL, modernc.org/sqlite), all tables + queries. Migrations are additive CREATE TABLE IF NOT EXISTS, so deploying over existing data is safe.
  • hub.go β€” WebSocket fan-out (coder/websocket). Handles msg (incl. relay-only ephemeral frames) / edit / delete / typing; ignores unknown types (e.g. the client keepalive ping). Reactions are not a frame type β€” they ride inside encrypted message events, folded client-side.
  • miniapps.go β€” the mini-app server surface: scheduled "future messages" (encrypted blobs delivered into a thread at a set time by a sweep goroutine) and per-thread encrypted state snapshots with optimistic versioning. Authoring guide: web/static/apps/README.md β€” read it before writing or changing a mini-app.
  • memberapps.go β€” member apps: apps members build by asking a bot ("build me a habit tracker"). Unlike admin-installed custom apps, member-app code is USER CONTENT: stored as an instance-key-encrypted blob and executed only inside an opaque-origin sandboxed iframe (/app-sandbox + web/static/sandbox.js/sandbox-frame.js) β€” no network, no keys, no parent DOM. The bot's builder is internal/bot/appbuild.go; authoring rules docs/building-member-apps.md; design + trust model docs/member-apps.md.
  • memberbots.go β€” member bots: the other axis from member apps β€” autonomous, scheduled agents members create by asking ("send me a news summary every morning"). A bot is a charter (persona + schedule + task), stored as an instance-key-encrypted blob exactly like a member app and run unattended by the sidecar's charter host (internal/bot/charter.go, botbuild.go, task.go, chartersched.go). Design + trust model docs/member-bots.md.
  • spacethread.go β€” space-wide apps: the hidden, all-members "space thread" backing apps installed for the whole space (places including "space") β€” one shared instance per app, its commands available in every chat. Implicit membership (every non-agent member; no rows), key via open_key under the instance key, and the install registry is spaceapps.add/remove fold events in its log β€” the server never learns which apps. Design + trust model docs/space-apps.md.
  • push.go β€” Web Push (self-hosted VAPID, SherClockHolmes/webpush-go).
  • The bot sidecar (Claude / Eleven): a separate headless process that is just another E2EE member; holds bot private keys + the ANTHROPIC_API_KEY, outbound-only, runs on any machine (see docs/bots.md). The default is Go β€” lchat bot run, a subcommand of the single binary (botcmd.go, internal/bot/), no Node runtime. agent/ is the Node reference implementation, kept on purpose (a runnable example + the wire-format illustration the Go tests pin against) β€” don't delete it or let it rot.
  • hooks.go β€” incoming webhooks (#610): the admin-managed registry of webhook definition blobs (opaque to the server) behind the webhook-ingest sidecar β€” the bot sidecar's inverse. lchat hooks run (hookscmd.go, internal/hooks/) serves in.<host> behind the router and turns inbound HTTP into encrypted webhooks.<ns> events posted into one thread; it is strictly write-only (no websocket, no message reads, no instance key). Stood up per instance by lchat add-hooks. Design + trust model: docs/webhooks.md; management UI web/static/apps/webhooks.js (admin-only).
  • unfurl.go β€” OpenGraph link previews (SSRF-guarded fetch + cache).
  • fetch.go β€” lchat fetch run, the public-web fetch service: a shared, identity-free service (unfurl + camo-style HMAC-signed image proxy) that fronts public web content for clients, so eventually the instance never sees URLs and card-image hosts never see member IPs. Reuses unfurl.go's fetch/SSRF/sealed-cache machinery; sees URLs attached to nothing; member content categorically out of scope. Design + trust model + roadmap (client flip, resize, Giphy-with-our-key): docs/fetch.md.
  • linkscmd.go β€” lchat links run, the 11ch.at short-link redirector for message permalinks (issue #905): a bare apex request bounces to elevenmessenger.com; /l/<host> live-checks the target (SSRF-guarded fetch of its own /api/version, cached in memory, no database) before redirecting. A subcommand of this binary, not a separate sibling service like push/home β€” see links.go for the message-permalink tokens it fronts.
  • controld.go / cloud.go / reaper.go β€” self-serve space creation on a router box: a root-only local control daemon that executes add-instance (with a capacity cap), and the public, unprivileged provisioner in front of it (caller tokens, per-user quotas, friendly slugs). The home server's POST /api/spaces forwards a signed-in user's request to it. reaper.go reclaims never-claimed (zero-member) spaces so abandoned creates don't eat the cap, archiving to /data/trash (recoverable) + a trash purge. See docs/cloud-provisioner.md.
  • activator.go + internal/activator/ β€” instance hibernation (opt-in, per-host): state parks in S3 (embedded Litestream + a single-writer lease) and the router wakes/sleeps instance processes on demand. The running half of the planet-scale design: docs/planet-scale.md.
  • web/pages/*.html β€” served with __GROUP_NAME__ substituted (HTML-escaped).
  • web/static/* β€” vanilla ES modules, no build step, served via go:embed with Cache-Control: no-cache.

Frontend module layout:

  • crypto.js β€” AES-GCM helpers (encrypt/decrypt for JSON, encryptBytes/ decryptBytes for binary). Format is base64url/iv(12)||ciphertext.
  • shell.js β€” the chat page host/shell: sidebar, thread list, WebSocket, crypto/keys, uploads, unfurl, push, and the bridge that mounts the app chat engine. All chat behaviour lives in apps/chat.js β€” the sole chat experience, mounted full-pane into #app-pane (.app-mode hides the shell's legacy chat surfaces). ⚠️ One last residue of the retired original chat remains: the always-empty #feed div and its per-thread parking cache (feedCache/stashFeed/bindThreadState and the alias maps msgEls/msgData/… β€” nothing writes them anymore). Dead-in-effect, pending deletion; don't extend it or read it as a reference. (The classic renderer and its typing pipeline are already deleted; openLightbox is NOT legacy β€” it's the live header-avatar zoom.)
  • format.js, ui.js β€” pure, framework-free helpers kept separate so they're unit-testable in Node (tokenizeText, formatBytes, shouldSend).
  • theme.js β€” colour theme / appearance / desktop text size (local-only prefs; applies them at import time). First slice of breaking shell.js into modules β€” extract sections verbatim, leaf-most first.
  • lightbox.js β€” the shared photo lightbox (FLIP fly-in/out + gestures); one copy, imported by both shell.js (avatar zoom) and apps/chat.js (message photos) β€” they used to carry twin verbatim copies.
  • media.js β€” pure client-side media helpers: processImage (downscale + re-encode + EXIF strip + BlurHash) and videoMeta (poster frame, dims, duration). Bytes out; callers encrypt.
  • shellstate.js β€” the shell's shared mutable state (session, threads, threadKeys, activeId, ws, roster, myUserId): export let + assignment-only setters. Design: docs/shell-modules.md. State only, never behaviour.
  • moderation.js β€” muted people, report-to-admins, blocked people (#578): sealed prefs under the member's own keypair; repaints via the onModerationChange sink.
  • searchui.js β€” message search (#586): index wiring over the local store, results, the deep back-fill walk, jump-to-message. Navigation via the wireSearch hook; search.js stays the pure index.
  • avatars.js β€” the decrypted-photo cache + initials/photo painters (avatarFor/threadAvatarFor/paintAvatar/avatarEl).
  • shellverbs.js β€” the behaviour seam: an empty registry shell.js fills at boot; modules call verbs.openThread(id) etc. Registration only.
  • rosterui.js β€” the member-display vocabulary (labels, presence lines, picker rows). groupactions.js β€” the thread kebab's whole surface (edit group, members, profiles, mute/pin, leave/close), through the verbs seam.
  • newchat.js β€” the roster picker, membership view, Discover, apps catalogue, and thread creation, through the verbs seam.
  • adddevice.js β€” the add-a-device sheet (key-invite + QR; secret rides the fragment).
  • onboardingui.js β€” one Eleven: the scripted setup/welcome conversations + the quick forms, bridging the flow engine into the chat app.
  • billingui.js β€” paid access levels + billing (subscriptions.go): the tier list load/render, per-tier key unwrap, and member subscribe/cancel/resume/ change/portal calls. The verbs newchat/groupactions use stay in the seam.
  • store.js β€” the local message store (#457): IndexedDB cache of RAW server responses (ciphertext rows + wrapped keys, never plaintext) that boot and thread-opens hydrate from before reconciling over the network. Its pure decision helpers are Node-tested (test/store.test.mjs); the DB is lchat-store, deliberately separate from the SW's version-pinned lchat DB.
  • apps.js + apps/*.js β€” the mini-app framework and the apps themselves (one self-contained file each). See web/static/apps/README.md.
  • push.js / sw.js β€” push subscription + service worker.
  • admin.js β€” admin console (invites, group name, server photo, encryption key).

Commands & mini-apps: familiar first, delightful on top

We're building a slash-command + mini-app layer. Hold this bar β€” the failure mode is being too clever.

  • Familiar before novel. Commands should feel instantly recognisable to anyone who's used Slack or Discord: /command args, a / typeahead of what's available, a reply when something's off. Match those conventions; don't invent our own.
  • *A command invokes a response, in place. Running a command produces a reply β€” in almost every case a message from a bot, public or private ("only visible to you") depending on context β€” not a UI change and not a jump elsewhere. The default is: stay in the current context and answer. If a command needs to move you or change the UI, offer that as an action chip on the response, so leaving the current context is always the user's tap, never the command's side effect. (Personal commands that navigate in the app are a possible later addition β€” never the default.) A response may also be a command panel* β€” a temporary, interactive results surface in the same chat (above the composer, or pinned above the feed) that the user or the app dismisses: still in place, still transient, never a navigation (ctx.panel; see web/static/apps/README.md Β§ Command panels).
  • *Apps can live alongside the chat β€” but never must. A mini-app may present as a second view onto the same thread (a Chat conversation and an Interface, toggled in the header), or it may take over the thread's full surface. The companion shape is a choice an app makes*, never something the platform imposes.
  • Delight rides on top. The payoff is a delightful layer over plain chat β€” beautiful pre-baked UI, or the expressive, reshape-it-on-demand power AI brings β€” earned only once the familiar basics feel right.

Views: takeover by default, companion by opt-in

A personal app-thread is presented one of two ways, and the app chooses β€” shell.js no longer decides for every app:

  • Takeover (default). The app's render is the whole thread β€” its full interactive surface, no chat pane, no toggle. This is the original full-pane model; anything from a whiteboard to a game wants it.
  • Companion (opt-in). The thread offers a Chat view (a DM-style conversation with the app's bot) and an Interface view, switched from a header toggle (Chat | Split | Interface; Split is desktop-only). To-do and Reminders use this. Both views fold from the same event log β€” one source of truth, no separate state β€” so an action in one shows in the other.

An app opts into companion simply by implementing chatMessage(event) β€” the hook that projects each of its events into the Chat view (a bot bubble, or a /me-style action line via { text, action:true }); its state renders in Interface via render. No chatMessage β†’ takeover, with no exceptions: an app that wants its events narrated in a group chat's feed without becoming a companion implements the feed-only hook groupFeedMessage(event) instead (the Vault), while a companion projects into groups through its one chatMessage (called with { group: true }) unless it implements both. So "wants a chat view" and "knows how to project into chat" are the same declaration, and the companion split is opt-in, not mandatory. The gate is appWantsChatView(name) in apps.js; the decision lives there, not spread through shell.js.

Who wrote this: AI vs human markers (πŸ€– / πŸ‘¨)

Prose in this repo carries an authorship marker, so a reader (human or agent) can tell who wrote what β€” and so a human can fence text off from AI rewriting. (The formal, versioned form of this convention is docs/ai-markers.md β€” a project policy, not currently a licence condition; see README.md Β§ Licence for why. Keep the two in step.)

  • πŸ€– β€” written by an LLM. Always visible, immediately before the heading, paragraph, or section it applies to (# πŸ€– Title, or a leading πŸ€– on a paragraph). A πŸ€– on a heading cascades: it marks that heading and everything beneath it β€” sub-headings, paragraphs, lists β€” down to the next marker of the same-or-higher level or a human-certified block. So one πŸ€– on a document's top heading marks the whole document.
  • No marker β€” ambiguous. Could be either; today's status quo. Never assume AI authorship; when you genuinely don't know, leave it unmarked.
  • A person emoji (πŸ‘¨ / πŸ‘€ / πŸ§‘ …) β€” certified human, off-limits. The text was written or vetted by a person. An LLM must not rewrite, rephrase, condense, or delete it. You may add new πŸ€– prose nearby, but the human's words are fixed. In HTML/Markdown the person marker may hide in a comment (<!-- πŸ‘¨ -->) so it doesn't render; the πŸ€– marker must never be hidden.

Transitions:

  • An LLM writing new prose marks it πŸ€– (including new changelog/*.md entries β€” their first line gets the πŸ€–).
  • An LLM that substantially rewrites unmarked prose makes it πŸ€–.
  • A human rewriting πŸ€– text removes the πŸ€– (back to ambiguous), or adds their person marker to certify and lock it.
  • An LLM that reaches a person-marked block hands off β€” leave it exactly as is.

Baseline: everything in this repo was AI-written unless a block carries a person marker. Every Markdown file is stamped πŸ€– at its top; changelog entries (which open with the note itself) carry the πŸ€– on their first line.

Conventions

  • πŸ€– *Every feature ships to web and native β€” unless explicitly scoped otherwise.* Don't stop at the web implementation: a change isn't done until it also lands on the native apps (or genuinely doesn't apply to them β€” e.g. it lives in a surface natives render through the webview). Only a deliberate, stated decision ("web-only for now") narrows the scope, never an omission.
  • πŸ€– Mac and iOS keep feature parity β€” and iOS comes first. The two native apps are one product: a feature that lands on one lands on the other in the same change (shared logic in ios/LChatCore, thin per-platform UI). When effort must be split or sequenced, build and polish the iOS app first β€” it's the primary surface members live in; the Mac follows.
  • Never innerHTML untrusted content. Build DOM nodes / use textContent. innerHTML is only acceptable with static, in-code strings (e.g. ICON_X).
  • Never show a toast that floats over UI. Nudges, warnings, and status render inline, occupying layout space β€” nothing overlays content someone may be reading or about to tap. Existing floating-#toast uses migrate to inline feedback as they're touched (see docs/onboarding-and-home-server.md).
  • πŸ€– Every displayed number pluralises and localises. Any count shown with a label handles pluralisation ("1 contributor" / "2 contributors" β€” never "1 contributors"), and digits render with locale delimiting (toLocaleString() on web / NumberFormatter–Text(_, format:) on native: 2,723 not 2723; prefer font-variant-numeric: tabular-nums where columns align). Never concatenate a bare n + " things".
  • πŸ€– Accessibility is a shipping requirement β€” WCAG 2.2 AA (statement: docs/accessibility.md). New/changed UI is keyboard-operable with visible, managed focus; icon-only controls are labelled (give DOM nodes their ARIA as you build them); contrast holds in light and dark; drags/swipes have non-drag alternatives and β‰₯24px targets; ambient motion (smoke/motes etc.) respects prefers-reduced-motion / Reduce Motion; state is never color alone. Native views carry accessibility labels/traits and survive the largest Dynamic Type sizes.
  • Token-gate every data endpoint with s.tokenOK(token) (or the equivalent invite check) β€” messages, uploads, blob downloads, unfurl, push subscribe.
  • Pull pure logic into a testable module rather than burying it in DOM code, then test it (see format.js/ui.js + test/*.mjs).
  • Platform split via isCoarse (matchMedia("(pointer: coarse)")): - Composer: desktop Enter sends / Shift+Enter newlines; touch Enter newlines, the send button sends (shouldSend). - Message actions: touch = long-press opens the message menu (emoji reactions above the bubble, a labelled Reply/Copy/Edit/Delete menu below); desktop = hover strip + press-and-hold for the picker. Native touch selection/callout is suppressed on bubbles so long-press opens the menu β€” Copy lives in the menu instead. Swipe-right still quick-replies on touch.
  • Images are downscaled in the browser before upload (processImage, max 2048px long edge) β€” there is no server-side image proxy (it only has ciphertext). Re-encoding also strips EXIF/GPS.
  • πŸ€– Eleven is not gendered. Eleven β€” the built-in helper persona β€” is the product's voice, not a character with a gender: never "she/her" (or "he/him") in any copy, changelog entry, doc, prompt, or commit message. Refer to Eleven by name, or as "it" where a pronoun is unavoidable (recast the sentence when "it" reads awkwardly). Audit Eleven-adjacent prose for stray pronouns before shipping.
  • πŸ€– The Eleven app icon already exists β€” use it, don't invent a mark. The logo is a purple rounded tile with the white double-slash "11". Canonical PNGs: web/static/icon-{180,192,512}.png (the PWA/web icons) and the native icon sets ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png + ios/LChatMac/Assets.xcassets/AppIcon.appiconset/. When a page needs the brand mark or a favicon, reference/copy one of these β€” never hand-roll a substitute glyph. (Each service embeds its own static dir, so the home server carries its own copy at home/web/static/icon-{192,512}.png.)
  • Push notifications suppress two cases: your own messages (server skips the sender's subscriptions by token) and messages while the app is open (the page writes a lastActiveAt heartbeat to IndexedDB that the SW checks β€” more reliable than clients.matchAll().focused on iOS). The admin "test" push always shows.
  • Match the surrounding code's style and comment density. Comment the why, not the what.
  • User-visible changes add a changelog entry: a short, user-facing note in changelog/<date>-<slug>.md (one file per change β€” parallel PRs never conflict). Entries are embedded in the binary and served at /api/changelog; the server announces new ones after a deploy β€” a push to the space's admins plus the Admin app's update stream (announce.go) β€” so write them for members, not developers. Native Mac/iOS changes: hold the entry until the release is minted. The changelog announces on the next server deploy, but a Mac/iOS change only reaches members once a build is actually downloadable (TestFlight for iOS; GitHub Releases/Sparkle for Mac) β€” so a native feature PR ships without an entry, and the PR that mints the release adds one covering everything in that build.
  • πŸ€– Keep the fleet operations guide current. docs/planet-scale-guide/ is the multi-page, human-focussed HTML guide to the planet-scale machinery β€” the router, the process/systemd model, versioned deploys and channels, space creation/removal, hibernation, and the mesh. A PR that changes any of that machinery's operator-visible behaviour (commands, flags, env vars, unit files, filesystem layout, timings/defaults, or the shipped-vs-roadmap status of a feature) updates the guide in the same PR. Keep its status badges honest β€” "running today" / "partially built" / "designed, not built" β€” and when a roadmap item ships, promote its badge and move the text out of the dashed roadmap panel. How the guide is structured (shared nav in guide.js, shared styles, page conventions): docs/planet-scale-guide/README.html.
  • Refresh Eleven's stats periodically. internal/bot/knowledge.md Β§16 has a "By the numbers" block (LOC + binary/app sizes) that Eleven quotes to members (this file is Eleven's reference β€” the single source both sidecars use: the Go one go:embeds it, agent/knowledge.mjs reads it). The figures are hand-measured and drift every release, so re-measure and update them (and the "as of" date) every so often β€” a quick git ls-files '*.go' …| wc -l for lines, and du -h on the built binaries / .app bundles for sizes.
  • πŸ€– Keep the carbon tally accruing β€” act on the session nudge. web/status/carbon.json is the public running estimate of the AI tokens spent building this app, per contributor machine (served at status.elevenmessenger.com/carbon.html; methodology docs/comparison-carbon.md). A SessionStart hook runs node tools/carbon-tally.mjs check and tells the session when this machine's entries are stale (>3 days) β€” its raw material, local Claude Code transcripts, prunes after about a week, so a missed week is lost to the ledger. (The same hook runs node tools/prompt-registry.mjs check for the prompt registry β€” silent on machines that don't publish one; when it nags, accrue per docs/status.md Β§ The prompt registry.) When nudged (or before starting long work): run node tools/carbon-tally.mjs --write (per-source cutoffs β€” re-runs and other machines never double-count), commit the tally as a tiny standalone PR merged promptly (two open branches accruing the same window is the one race the cutoffs can't see; the contract test catches it on main), and copy it to the router box's status-pages dir (docs/status.md Β§ Keeping the carbon tally accruing).

πŸ€– Cross-platform event contract (web ↔ native)

Chat events must be identical on web (chat.js) and native (iOS/Mac). The event vectors (in test/event-vectors/) document the contract and are tested on both sides, so any drift is caught on PR.

The problem: The web folds events into chat.js via chat.msg / chat.react / chat.edit / chat.delete fold handlers; native deserializes MessagePayload + Reaction from WebSocket frames and stores them. Without an authoritative spec, both could drift.

The solution: Shared JSON vector files (one per app) list every event type with:

  • The decrypted payload shape
  • The server frame metadata
  • The expected fold event (what the web handler must produce)
  • The expected native fields (what native structures must hold)

Both test suites validate against these vectors:

  • Web: test/event-vectors.test.mjs β€” feeds each payload + meta to the fold handlers, asserts output matches expected state
  • Native: ios/.../EventVectorTests.swift + TodoAppVectorTests.swift + RemindAppVectorTests.swift β€” deserialize payloads, assert state structures match expected shapes

Maintaining the contract:

  1. When you change a web app's fold handlers (e.g., app.hostEvents in web/static/apps/chat.js): - Run npm test to ensure vectors still pass - If the handler changes what it produces, update the app's vector file in test/event-vectors/ + re-run tests - If the change is a bug fix, add a note in the vector's note field - Example: changing chat.js β†’ update test/event-vectors/chat.json
  1. When you change a native structure (e.g., MessagePayload in ios/.../Models.swift): - Update the relevant test file if the decode rules changed (e.g., EventVectorTests.swift) - Run iOS tests; they validate the native side still matches vectors - If native and web have diverged, that's a test failure β€” fix both sides in the same PR
  1. When you add a new feature to an app (e.g., a new field in message payload or todo text): - Add a vector entry in the appropriate file in test/event-vectors/ (e.g., test/event-vectors/chat.json) - Add a test case to both web and native test files - Run both test suites before landing the PR

Vectors are not snapshots; they are the spec. If a test fails, it means web and native are out of sync β€” that's a hard error. Don't update vectors to make tests pass if the implementations actually differ; fix the implementations instead.

Watch for: timestamp handling (web uses meta.ts, native reads ts + created_at), reaction mine-flag semantics (live broadcast vs. load-time history), edited flag updates, and uid attribution fallbacks (uid-less legacy messages).

Extended to app-threads: Todo and Reminders follow the same pattern.

  • test/event-vectors/todo.json and test/event-vectors/remind.json include app-specific vectors
  • test/event-vectors.test.mjs tests fold handlers for all apps (web side)
  • ios/.../TodoAppVectorTests.swift + RemindAppVectorTests.swift (native side β€” tests fail initially, pass as you implement)

App-thread events: todo & remind contract

Both apps are personal (one thread per user), so native implementations don't exist yetβ€”the vectors serve as the spec.

To-do app (web/static/apps/todo.js):

  • State: { items: [{ id, text, done, doneAt, by }] }
  • Events: todo.add (text, name), todo.done (ts for grace-period animation), todo.reopen, todo.remove, todo.move (after = id the item now follows in the array, "" = start)
  • Grace period: done items linger ~750ms with a depleting ring, then tuck into "Completed"
  • Chat projection: /todo X shows as an action line; done/reopen/remove are silent

Reminders app (web/static/apps/remind.js):

  • State: { items: [{ id, text, when, ts, sid, fired, firedAt }] }
  • Events: remind.add (text, when=absolute epoch ms, sid=server schedule id), remind.fire (notice + timestamp), remind.done
  • Sorting: items always sorted by when (soonest first)
  • Grace period: fired reminders linger ~750ms, then tuck into "Previous reminders"
  • Offline fallback: if sid is empty, scheduling failed; UI renders a warning and ticker fires client-side
  • Chat projection: remind.fire shows as a message (title + body from notice); add/done are silent

Native implementation guidance:

  • Use the vectors as the spec; tests initially fail (that's the point)
  • Implement fold handlers for each app that match web side exactly (use vectors as golden truth)
  • Partition logic (grace period, sorting) is derived in render, not a timerβ€”never mutate state on a clock
  • For todo: doneAt > (now - 750ms) stays active; for remind: same but firedAt
  • Tests document idempotence (duplicate adds deduped), text preservation (unicode safe), sorting, and partition rules

Project memory (.claude/memory/)

Project context is stored in .claude/memory/ (relative to repo root, committed to git) so it works across all developer machines. This includes architecture decisions, ongoing work status, and cross-platform patterns like the event contract.

When working on this project, Claude agents will automatically load memory from this directory. To update memory:

  • Edit .claude/memory/*.md files directly
  • Commit memory changes to the branch like any other code change
  • Memory is version-controlled and portableβ€”no machine-specific paths

Build & test

go build -o lchat .          # or: CGO_ENABLED=0 go build ./...
go test ./...                # db_test, handlers_test, unfurl_test (httptest)
npm test                     # node test/{crypto,ui,format,event-vectors}.test.mjs
node --check web/static/shell.js   # JS isn't run by tests; syntax-check edits

# Native (iOS/Mac): open ios/LChat.xcodeproj and run EventVectorTests.swift
# Or via xcodebuild: xcodebuild test -scheme LChat -only EventVectorTests

Add tests as you go. Go handler tests use newTestServer(t) which returns (*httptest.Server, *Server) so you can seed the DB directly. SSRF tests flip allowPrivatePreviewHosts to reach a loopback httptest server.

Event vectors are mandatory: Every chat message feature PR must pass npm test (web side) and EventVectorTests (native side) β€” they verify the event schema stays in sync across platforms.

πŸ€– Where manual verification happens is a per-developer preference β€” ask if you don't know it (Paul, 2026-07-16). Whether a session verifies features by hand against the full stack (a per-session test-<slug>.11ch.at canary or the dev environment) or against a hand-run local server is not a project mandate β€” it's each developer's stored preference. One developer may always want a live canary, another may always want localhost; the system doesn't care. Each developer records theirs in their own CLAUDE.local.md; if the prompter's preference isn't already recorded, ask them before manual testing starts, then save the answer there so no session asks twice. Know what the choice trades: localhost skips the router, real TLS, push, the home server, and every cross-service seam. Local testing can close most of that gap by riding the relay (lchat try β€” the tunnel gateway fronts the local server at a real try-*.11relay.com origin with real TLS; docs/tunnel-try.md): local code, reached the way the world reaches it β€” the accepted middle ground when a local-preference developer needs a real origin, or a full-stack developer needs to test unmerged code. For a full-stack developer, a feature touching the singleton services (home, push, cloud) that can't canary today is held for the dev environment, not localhost-tested in the meantime. The automated suites are unaffected either way (go test's httptest servers, npm test, the hermetic E2E rigs β€” those are tests, not testing).

Deploy

Deployed as a single static binary per chat. Each chat is a fully-isolated instance β€” its own process, DB, admin token, and subdomain β€” supervised by systemd behind the instance router (docs/instance-router.md); shipping is via the signed release channels (docs/server-releases.md). The operator entry point for all of it is ./deploy/ops (deploy/README.md Β§ The operator surface). The Dockerfile exists for self-hosters on container platforms (README Β§ Docker); nothing in our production runs in Docker (the original Miren container path was retired 2026-07-15).

> Box addressing rides ~/.ssh/config plus the LCHAT_* env overrides the > ops scripts read (defaults live in the scripts themselves); secrets live in > Bitwarden under the vault contract (deploy/vault-items.sh). > CLAUDE.local.md holds personal preferences (e.g. the manual-testing > choice) β€” never operational procedure. A local file or doc that contradicts > an ops verb is stale by definition: fix the verb, not the prose.

Principles that hold regardless of environment:

  • πŸ€– Operations run through ./deploy/ops β€” never reconstructed from prose. NO LLM COOKBOOKS. Bare ops lists the verbs; ops doctor is the read-only preflight that checks every credential and access path an operator needs and prints the fix under each failure, never a secret. If a task has no verb, or a verb falls short, extend ops in a small PR and then run it β€” do not hand-run command sequences gleaned from docs/, this file, CLAUDE.local.md, or memory; that path is how snowballing runbooks happen, and it is closed. The documents under docs/ are design rationale and history: where one appears to give operational steps, the script is the truth and the doc is stale (deploy/README.md Β§ The operator surface).
  • Deploys are data-safe. lchat deploy repoints the instance's live symlink and restarts β€” it never touches /data/<host>.db*. Never delete an instance's data to update it; lchat remove deliberately leaves /data behind, and deleting those files is only for intentionally resetting an instance (or tearing down a canary). DB migrations are additive (CREATE TABLE IF NOT EXISTS), so new code over old data is safe.
  • Admin tokens survive redeploys β€” they live in /data/<host>.env, which deploys never touch. Only add-instance without --admin-token mints a fresh one, which would invalidate saved links; reuse the existing token when recreating an instance.
  • Env contract: the app reads LCHAT_ADMIN_TOKEN, LCHAT_DB, ADDR. Use the LCHAT_-prefix deliberately (a bare ADMIN_TOKEN can be auto-provisioned/ overridden by the platform).
  • Keep a throwaway instance for verifying on real devices.
  • πŸ€– Fleet boxes: operator SSH rides WireGuard; root SSH is bootstrap-only. Public firewall = 80/443 + the WireGuard port only; operators connect as named accounts over the tunnel, and root capability exists solely through the committed sudoers allowlist (deploy/sudoers-operators) β€” anything new that needs root gets an allowlist line via a reviewed PR, never a root shell. Every operator SSH session gets a public why-entry in the status ledger (web/status/ssh/). Recipe: docs/instance-router.md Β§ Hardening.

πŸ€– Per-session canaries (test-*.fi.dev.11ch.at)

Instances are disposable β€” wildcard DNS covers the canary domains, so creating one needs no DNS step. Canaries live on the dev mesh by default (Paul, 2026-07-22): throwaway test instances don't belong on the box real members live on, and they'd eat its instance cap. The default is the Helsinki dev box (fi.dev.11ch.at β€” amd64, matching build-linux.sh); targeting production (LCHAT_CANARY_BOX=elevenbox LCHAT_CANARY_DOMAIN=11ch.at) is a deliberate, stated exception. One dev caveat: no wildcard cert there by design, so each dev canary burns a per-host HTTP-01 cert from the shared 50/week 11ch.at budget β€” fine at canary cadence. So instead of sharing one test instance across parallel sessions (they trample each other):

The encoded path is ./deploy/ops canary create|deploy|destroy <slug> β€” each verb enforces the rules below, so prefer it over hand-run lchat commands. The rules themselves, and why:

  • Every Claude session doing server work creates its own canary: ops canary create <slug> (box override: LCHAT_CANARY_BOX), slug = your branch/feature. The test- prefix keeps canaries visually distinct from real member spaces (short sqid subdomains); creation also flags the route --canary β€” the deliberate signal (never inferred from the hostname) that lets lchat reset wipe it at teardown.
  • A canary never auto-updates β€” pin it. ops canary deploy <slug> builds the current branch and deploys --pin; the hourly auto-updater leaves pinned instances alone, and lchat update status shows them as AUTO=pinned. The session owns its canary's version for its whole life (lchat rollback / re-deploy as you iterate).
  • The session tears its canary down when the work merges: ops canary destroy <slug> β€” lchat reset (archives the DB to /data/trash; the canary flag is its gate) then lchat remove --stop (which also disables bot/hooks sidecar units). Removal alone never deletes data β€” that's deliberate for real instances; the canary-gated reset is how a hardened box (no allowlisted rm) destroys a canary's data anyway.
  • Canaries hold no real member data and their admin tokens are not stored in 1Password β€” everything about them dies with the session.
  • Corollary for fleet-wide operations (channel changes, unpin sweeps, deploy --all): check lchat update status first β€” every AUTO=pinned row is someone's live canary; leave it alone. (deploy --all --version now skips pinned rows by itself; --include-pinned is the deliberate override. The check still matters for channel changes and unpin sweeps.)

Dev CLI (tools/dev)

Simple command-line interface for common dev tasks. Run from anywhere in the repo.

./tools/dev build mac              # Build native Mac app (xcodebuild)
./tools/dev build ios              # Build native iOS app (xcodebuild)
./tools/dev run mac                # Launch the Mac app
./tools/dev run ios                # Run iOS app on simulator
./tools/dev install ios [id]       # Build + install + launch on a REAL iPhone, over
                                   #  the network (no cable): unlocked, trusted, same
                                   #  Wi-Fi. Device id defaults to the first paired
                                   #  one; LCHAT_IOS_DEVICE overrides. Signing is
                                   #  automatic β€” a new device must be registered
                                   #  with the team first. "iOS <v> is not installed"
                                   #  = point DEVELOPER_DIR at an Xcode with that
                                   #  platform.
./tools/dev alt [slug]             # Build + launch a side-by-side dev alt of the Mac app
./tools/dev up [flags]             # Start local dev server (persistent config)
                                   #  + the webhook-ingest sidecar (--no-hooks skips)
                                   #  flags pass to lchat-dev-up: --clean, --onboarding,
                                   #  --port N, --help
./tools/dev seed-mac               # Seed Mac app with dev server credentials
./tools/dev link                   # Print dev server sign-in link
./tools/dev test                   # Run full test suite (Go + Node)
./tools/dev test web               # Run web tests only
./tools/dev test go [args]         # Run Go tests only (args go to `go test`,
                                   #  e.g. -run TestFoo ./internal/bot)
./tools/dev shots <verb>           # Marketing-screenshot kit (docs/marketing-screenshots.md):
                                   #  up        build the isolated demo Mac app (if needed) +
                                   #            run a throwaway local server seeded with the
                                   #            fictional "The Commons" space (foreground)
                                   #  tunnel    give it a public https try-*.11relay.com address
                                   #  mac       launch the isolated "Eleven Demo" Mac app
                                   #  web-check <png>  headless 2x screenshot of the seeded space

All build and test commands are thin wrappers around the underlying toolchain (xcodebuild, go, npm). The dev subcommands orchestrate the dev server (tools/lchat-dev-up.mjs) and app seeding scripts. The run commands automatically build if needed, so you can skip the explicit build step. Reuse and extend this as the single entry point for all dev workflows β€” don't hand-roll xcodebuild invocations or embed them in Claude prompts. When adding new dev commands, document them here and in the script's usage header.

πŸ€– Dev alts (./tools/dev alt [slug] [--icon live]) build the same LChatMac target under an alternate identity β€” bundle id …messenger.mac.alt-<slug>, name "Eleven Dev", the orange dev icon (AppIconDev; --icon live picks the green AppIconDevLive β€” the convention for an alt aimed at PRODUCTION home, so two alts with different aims never share an icon; regenerate either with swift tools/make-dev-icon.swift [live]), Sparkle auto-checks off β€” so a dev build runs beside the Sparkle-updated app instead of replacing it. The keychain access group is unchanged, so an alt sees the signed-in spaces: it's the real account, not a sandbox (and while it runs you'll get doubled notification banners β€” quit it when done). One running app per slug; parallel sessions pick different slugs (dev, dev2, …) the way web canaries pick subdomains. Debug builds can aim at the dev home server: defaults write <bundle id> home_base_override https://home.dev.elevenmessenger.com (or env LCHAT_HOME_BASE; LChatCore HomeBase.swift β€” Release compiles it out). Never install an alt (or any Debug build) to /Applications β€” that's how a stale dev build ends up masquerading as the released app.

πŸ€– Marketing screenshots (./tools/dev shots <verb>) stand up a fictional demo space ("The Commons") for capturing the native Mac + iPhone shots in the website hero collage β€” the committed form of a capture recipe that used to live only in agent memory. Implementation in tools/marketing-shots/; full process + the WHY in docs/marketing-screenshots.md. shots up builds the isolated demo Mac app (if needed) and runs a throwaway local server seeded through the real E2EE protocol; shots tunnel fronts it at a public try-*.11relay.com origin for the native apps; shots mac launches it; shots web-check <png> grabs a headless 2Γ— screenshot to eyeball the look. Isolation is the point: unlike a normal build or a dev alt (both share the …messenger.shared keychain-access-group, so they read the real signed-in spaces β€” an alt has grabbed Paul's private space before), the demo Mac build is signed with the minimal LChatMac-Demo.entitlements (no shared keychain β†’ its own empty keychain), a distinct …mac.capdemo bundle id, applied purely as command-line build-setting overrides so project.yml is never touched and a plain ./tools/dev build mac stays the real app. Interactive Mac/iPhone captures stay manual (the tool stands up the demo + points you at the doc).

Typical workflow:

./tools/dev up                     # 1. Start dev server in one terminal
./tools/dev seed-mac               # 2. Seed Mac app with dev server credentials
./tools/dev run mac                # 3. Build (if needed) and launch it

Branches, commits & PRs

  • Prefer small, focused PRs that ship incrementally. When iterating, slice work into the smallest change that stands on its own and land it, rather than batching a large feature into one big PR. Small PRs review faster, merge with fewer conflicts (several land in parallel), and are safer to deploy and revert. Split a big idea into a sequence of shippable steps.
  • Work on a feature branch β€” don't commit straight to main. Several PRs land in parallel, so pushing directly to main causes push races and forced rebases. Branch per feature (e.g. reply-to-messages), open a PR, and squash-merge into main (gh pr merge --squash --delete-branch).
  • Always do PR work in a git worktree, not the main checkout. Several sessions share this checkout, so editing in place trips over concurrent work; a worktree keeps each PR isolated until it merges.
  • Multiple commits per branch are fine β€” they squash into one clean commit on main, so iterate freely. Within a branch, prefix commits by area: web:, server:, admin:, sw:, crypto:, docs:.
  • End commit and PR-body messages with the Co-Authored-By: trailer for the assisting model. (Commit author is the repo's local git config β€” see CLAUDE.local.md.)
  • Deploy from main after the squash-merge (pull main, then redeploy β€” see Deploy), never from a feature branch. Branch / commit / PR / merge / deploy only when asked.