eleven / messenger Public
π€ 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
encryptBytesoutput; 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.gifinshell.js) β the client queries Giphy directly (CSP pinsconnect-srcto*.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/gifsupplies 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/gifinstead β platform key, no space key needed, media riding the signed/imgproxy 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 inServer.handler()so tests exercise the real router viahttptest), HTTP handlers, bootstrap. ReadsLCHAT_ADMIN_TOKEN,LCHAT_DB,ADDRfrom env.db.goβ SQLite (WAL,modernc.org/sqlite), all tables + queries. Migrations are additiveCREATE TABLE IF NOT EXISTS, so deploying over existing data is safe.hub.goβ WebSocket fan-out (coder/websocket). Handlesmsg(incl. relay-only ephemeral frames) /edit/delete/typing; ignores unknown types (e.g. the client keepaliveping). 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 isinternal/bot/appbuild.go; authoring rulesdocs/building-member-apps.md; design + trust modeldocs/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 modeldocs/member-bots.md.spacethread.goβ space-wide apps: the hidden, all-members "space thread" backing apps installed for the whole space (placesincluding"space") β one shared instance per app, its commands available in every chat. Implicit membership (every non-agent member; no rows), key viaopen_keyunder the instance key, and the install registry isspaceapps.add/removefold events in its log β the server never learns which apps. Design + trust modeldocs/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 (seedocs/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/) servesin.<host>behind the router and turns inbound HTTP into encryptedwebhooks.<ns>events posted into one thread; it is strictly write-only (no websocket, no message reads, no instance key). Stood up per instance bylchat add-hooks. Design + trust model:docs/webhooks.md; management UIweb/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, the11ch.atshort-link redirector for message permalinks (issue #905): a bare apex request bounces toelevenmessenger.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 β seelinks.gofor 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 executesadd-instance(with a capacity cap), and the public, unprivileged provisioner in front of it (caller tokens, per-user quotas, friendly slugs). The home server'sPOST /api/spacesforwards a signed-in user's request to it.reaper.goreclaims never-claimed (zero-member) spaces so abandoned creates don't eat the cap, archiving to/data/trash(recoverable) + a trash purge. Seedocs/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 viago:embedwithCache-Control: no-cache.
Frontend module layout:
crypto.jsβ AES-GCM helpers (encrypt/decryptfor JSON,encryptBytes/decryptBytesfor 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 inapps/chat.jsβ the sole chat experience, mounted full-pane into#app-pane(.app-modehides the shell's legacy chat surfaces). β οΈ One last residue of the retired original chat remains: the always-empty#feeddiv and its per-thread parking cache (feedCache/stashFeed/bindThreadStateand the alias mapsmsgEls/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;openLightboxis 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 breakingshell.jsinto modules β extract sections verbatim, leaf-most first.lightbox.jsβ the shared photo lightbox (FLIP fly-in/out + gestures); one copy, imported by bothshell.js(avatar zoom) andapps/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) andvideoMeta(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 theonModerationChangesink.searchui.jsβ message search (#586): index wiring over the local store, results, the deep back-fill walk, jump-to-message. Navigation via thewireSearchhook;search.jsstays 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 callverbs.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 islchat-store, deliberately separate from the SW's version-pinnedlchatDB.apps.js+apps/*.jsβ the mini-app framework and the apps themselves (one self-contained file each). Seeweb/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; seeweb/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
renderis 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/*.mdentries β 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
innerHTMLuntrusted content. Build DOM nodes / usetextContent.innerHTMLis 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-
#toastuses migrate to inline feedback as they're touched (seedocs/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; preferfont-variant-numeric: tabular-numswhere columns align). Never concatenate a baren + " 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.) respectsprefers-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 setsios/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 athome/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
lastActiveAtheartbeat to IndexedDB that the SW checks β more reliable thanclients.matchAll().focusedon 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 inguide.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 onego:embeds it,agent/knowledge.mjsreads 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 quickgit ls-files '*.go' β¦| wc -lfor lines, anddu -hon the built binaries /.appbundles for sizes. - π€ Keep the carbon tally accruing β act on the session nudge.
web/status/carbon.jsonis the public running estimate of the AI tokens spent building this app, per contributor machine (served at status.elevenmessenger.com/carbon.html; methodologydocs/comparison-carbon.md). ASessionStarthook runsnode tools/carbon-tally.mjs checkand 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 runsnode tools/prompt-registry.mjs checkfor the prompt registry β silent on machines that don't publish one; when it nags, accrue perdocs/status.md Β§ The prompt registry.) When nudged (or before starting long work): runnode 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:
- When you change a web app's fold handlers (e.g.,
app.hostEventsinweb/static/apps/chat.js): - Runnpm testto ensure vectors still pass - If the handler changes what it produces, update the app's vector file intest/event-vectors/+ re-run tests - If the change is a bug fix, add a note in the vector'snotefield - Example: changingchat.jsβ updatetest/event-vectors/chat.json
- When you change a native structure (e.g.,
MessagePayloadinios/.../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
- 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.jsonandtest/event-vectors/remind.jsoninclude app-specific vectorstest/event-vectors.test.mjstests 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 Xshows 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
sidis empty, scheduling failed; UI renders a warning and ticker fires client-side - Chat projection:
remind.fireshows 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/*.mdfiles 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 EventVectorTestsAdd 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. Bareopslists the verbs;ops doctoris 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 fromdocs/, this file,CLAUDE.local.md, or memory; that path is how snowballing runbooks happen, and it is closed. The documents underdocs/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 deployrepoints the instance'slivesymlink and restarts β it never touches/data/<host>.db*. Never delete an instance's data to update it;lchat removedeliberately leaves/databehind, 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. Onlyadd-instancewithout--admin-tokenmints 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 theLCHAT_-prefix deliberately (a bareADMIN_TOKENcan 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. Thetest-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 letslchat resetwipe 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, andlchat update statusshows them asAUTO=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) thenlchat 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 allowlistedrm) 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): checklchat update statusfirst β everyAUTO=pinnedrow is someone's live canary; leave it alone. (deploy --all --versionnow skips pinned rows by itself;--include-pinnedis 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 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 spaceAll 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 itBranches, 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 tomaincauses push races and forced rebases. Branch per feature (e.g.reply-to-messages), open a PR, and squash-merge intomain(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 β seeCLAUDE.local.md.) - Deploy from
mainafter the squash-merge (pullmain, then redeploy β see Deploy), never from a feature branch. Branch / commit / PR / merge / deploy only when asked.