| 1 | # π€ CLAUDE.md |
| 2 | |
| 3 | Working notes for anyone (human or agent) changing this codebase. User-facing |
| 4 | docs and the encryption rationale live in `README.md`; this file is about how we |
| 5 | build and the decisions we've committed to. |
| 6 | |
| 7 | ## The one rule: end-to-end encryption is load-bearing |
| 8 | |
| 9 | The whole point of lchat is **"if the server is compromised, the attacker gets |
| 10 | nothing."** Every change must preserve that: |
| 11 | |
| 12 | - The server only ever stores/relays **opaque ciphertext** plus key material it |
| 13 | cannot open. It must never see plaintext message content, screen names, or file |
| 14 | bytes, and must never receive any usable key β not a thread key, the instance |
| 15 | key, or a user's private key. |
| 16 | - Each user has a **keypair** (ECDH P-256); the private key lives only in the |
| 17 | browser. Every **thread** has its own **AES-GCM key**, wrapped to each member's |
| 18 | public key via ECIES; the server stores only the wrapped blobs. There's also one |
| 19 | per-instance "instance key" (for all-members content like profile photos), |
| 20 | likewise wrapped per member. All wrap/unwrap + encrypt/decrypt is in the browser |
| 21 | (`web/static/crypto.js`). |
| 22 | - Invite links carry a one-time secret in the URL `#fragment` (`/join/<name>#<T>`) |
| 23 | used only to bootstrap/ship the joiner's keys; fragments are never sent to the |
| 24 | server. |
| 25 | - The **admin token** authorises roster management only β it is *not* a |
| 26 | decryption key. Store only its hash; never log tokens or keys. |
| 27 | - When you add a feature that handles user content, encrypt it client-side and |
| 28 | store a blob. Example: attachments upload `encryptBytes` output; the filename |
| 29 | and MIME type ride *inside* the encrypted message payload, never as server |
| 30 | metadata. |
| 31 | |
| 32 | Two deliberate exceptions, both easy to gate behind a setting later: |
| 33 | |
| 34 | - **Link previews** (`unfurl.go`) β the *server* fetches a posted URL to build |
| 35 | an OpenGraph card, so it briefly sees URLs (not message text). |
| 36 | - **GIF search** (`apps/gif.js` + `hostBridge.gif` in `shell.js`) β the |
| 37 | *client* queries Giphy directly (CSP pins `connect-src` to `*.giphy.com`), |
| 38 | so Giphy sees the member's search terms and IP; the lchat server sees |
| 39 | nothing β the chosen GIF is downloaded client-side, encrypted to the thread |
| 40 | key, and posted as an ordinary opaque attachment. The Giphy API key is the |
| 41 | **space's**: the first member to run `/gif` supplies one and it folds into the |
| 42 | space thread's log (`docs/space-apps.md`), so every member reads it and nobody |
| 43 | is asked twice. That every member can read it is the point, not a leak β a |
| 44 | Giphy key is a public client identifier, not a secret β and the server still |
| 45 | only ever holds the ciphertext. This is the reference for the |
| 46 | **implicitly space-placed** shape (`places: ["space"]` + `listed: false`) and |
| 47 | for space-shared app config generally: `docs/app-credentials.md`. |
| 48 | π€ When the instance points at a **fetch service** (`LCHAT_FETCH_URL`), |
| 49 | search prefers its `/api/gif` instead β platform key, no space key needed, |
| 50 | media riding the signed `/img` proxy so the browser never talks to Giphy; |
| 51 | the space-key direct path stays as the fallback/self-host story |
| 52 | (`docs/fetch.md Β§ Giphy`). |
| 53 | |
| 54 | Instance **branding** is plaintext by design and doesn't count against the rule β |
| 55 | it's admin-set, never user content. Three things: the **group name** (`config` |
| 56 | `group_name`), the optional **server avatar** (`instance_avatar` table, served |
| 57 | in the clear at `GET /api/server-avatar`), and the optional **space description** |
| 58 | (`config` `group_description`). All must render where no key exists yet β the |
| 59 | join/onboarding cards, the native add-chat screens, and the home Save-passkey |
| 60 | card β so they can't be E2E-encrypted like profile/group photos. The description |
| 61 | (like the admin name/email) is gated a notch tighter than the other two: served |
| 62 | to members and to valid invite-link holders (`GET /api/key-invite/{id}`), never |
| 63 | on the fully-public reads. Keep branding to this narrow set; anything that is |
| 64 | *user* content still gets encrypted client-side (e.g. a member's own "about me", |
| 65 | `users.description_blob`, under the instance key). |
| 66 | |
| 67 | **Bots** are the rule's scoped, disclosed exception-by-membership: a bot is an |
| 68 | ordinary E2EE member whose "device" is a machine the operator runs (`agent/`), |
| 69 | so that machine holds the bot's private key and can decrypt exactly the threads |
| 70 | the bot is in β never anything else, and the lchat *server process* still never |
| 71 | holds a usable key. Two invariants follow: **bot code never runs inside the |
| 72 | server process** (compromising the server must not yield bot keys or the |
| 73 | Anthropic key), and the sidecar stays a **pure outbound client** (no inbound |
| 74 | ports), so operators can move it to a better-defended machine β off-box, behind |
| 75 | NAT, or in a no-inbound private network. Trust model + the Go sidecar (the |
| 76 | default now β `lchat bot run`, a subcommand of the single binary, no Node; |
| 77 | `agent/` is kept **on purpose** as the runnable Node reference, not deprecated): |
| 78 | `docs/bots.md`. |
| 79 | |
| 80 | Native iOS push goes through a central APNs relay (`push.elevenmessenger.com`, |
| 81 | `push/`) so the Apple `.p8` signing key lives in exactly one place instead of on |
| 82 | every instance. Like the admin token, the `.p8` is *auth*, not a decryption key: |
| 83 | the relay forwards the same ciphertext blob Apple already sees (device token + |
| 84 | opaque thread id + sealed bytes), never plaintext and never a usable key β |
| 85 | instances authenticate to it with a per-instance bearer token. The one copy of |
| 86 | the signer + aps envelope lives in `internal/apns/` (shared by the messenger and |
| 87 | the relay so they can't drift). Web Push is unaffected (per-instance VAPID, no |
| 88 | shared secret). See `docs/push-relay.md`. |
| 89 | |
| 90 | ## π€ The second rule: built to outlive AI |
| 91 | |
| 92 | Everything built here is designed to **outlive AI**. This codebase was largely |
| 93 | produced with AI assistance, and it must never *depend* on that assistance: |
| 94 | |
| 95 | - **All code stays human-manageable.** If AI became unavailable or |
| 96 | unaffordable tomorrow, a small human team could read, maintain, extend, and |
| 97 | ship this product. That's a load-bearing reason for standing choices: |
| 98 | vanilla JS with no build step, one boring Go binary, additive migrations, |
| 99 | pure helpers split out for testing, comments that explain *why*. |
| 100 | - **All tools stay human-understandable.** Scripts, deploy paths, and dev |
| 101 | harnesses must be readable in one sitting and operable by hand. Never add |
| 102 | machinery that only an LLM can drive, and never encode critical operational |
| 103 | knowledge solely in prompts or agent memory β it belongs in the repo's docs. |
| 104 | - **Nothing assumes AI stays cheap.** Features, operations, and the product's |
| 105 | economics must outlast today's low relative cost of AI-assisted production. |
| 106 | When a design choice trades human legibility for AI-assisted convenience, |
| 107 | legibility wins. |
| 108 | |
| 109 | The upside cuts the other way too: if high-capability AI keeps getting |
| 110 | cheaper, a human-legible, well-tested baseline is the most solid floor it can |
| 111 | build from. Either way the product doesn't wobble with the AI industry's |
| 112 | fortunes. |
| 113 | |
| 114 | ## Architecture |
| 115 | |
| 116 | Single static Go binary; the frontend and a pure-Go SQLite engine are compiled |
| 117 | in, so deploy is one file (no cgo). |
| 118 | |
| 119 | - `main.go` β routes (all wired in `Server.handler()` so tests exercise the real |
| 120 | router via `httptest`), HTTP handlers, bootstrap. Reads `LCHAT_ADMIN_TOKEN`, |
| 121 | `LCHAT_DB`, `ADDR` from env. |
| 122 | - `db.go` β SQLite (WAL, `modernc.org/sqlite`), all tables + queries. Migrations |
| 123 | are additive `CREATE TABLE IF NOT EXISTS`, so deploying over existing data is |
| 124 | safe. |
| 125 | - `hub.go` β WebSocket fan-out (`coder/websocket`). Handles `msg` (incl. |
| 126 | relay-only ephemeral frames) / `edit` / `delete` / `typing`; ignores unknown |
| 127 | types (e.g. the client keepalive `ping`). Reactions are not a frame type β |
| 128 | they ride inside encrypted message events, folded client-side. |
| 129 | - `miniapps.go` β the mini-app server surface: scheduled "future messages" |
| 130 | (encrypted blobs delivered into a thread at a set time by a sweep goroutine) |
| 131 | and per-thread encrypted state snapshots with optimistic versioning. |
| 132 | **Authoring guide: `web/static/apps/README.md`** β read it before writing or |
| 133 | changing a mini-app. |
| 134 | - `memberapps.go` β **member apps**: apps members build by asking a bot |
| 135 | ("build me a habit tracker"). Unlike admin-installed custom apps, member-app |
| 136 | code is USER CONTENT: stored as an instance-key-encrypted blob and executed |
| 137 | only inside an opaque-origin sandboxed iframe (`/app-sandbox` + |
| 138 | `web/static/sandbox.js`/`sandbox-frame.js`) β no network, no keys, no parent |
| 139 | DOM. The bot's builder is `internal/bot/appbuild.go`; authoring rules |
| 140 | `docs/building-member-apps.md`; design + trust model `docs/member-apps.md`. |
| 141 | - `memberbots.go` β **member bots**: the other axis from member apps β |
| 142 | autonomous, scheduled agents members create by asking ("send me a news |
| 143 | summary every morning"). A bot is a **charter** (persona + schedule + task), |
| 144 | stored as an instance-key-encrypted blob exactly like a member app and run |
| 145 | unattended by the sidecar's charter host (`internal/bot/charter.go`, |
| 146 | `botbuild.go`, `task.go`, `chartersched.go`). Design + trust model |
| 147 | `docs/member-bots.md`. |
| 148 | - `spacethread.go` β **space-wide apps**: the hidden, all-members "space |
| 149 | thread" backing apps installed *for the whole space* (`places` including |
| 150 | `"space"`) β one shared instance per app, its commands available in every |
| 151 | chat. Implicit |
| 152 | membership (every non-agent member; no rows), key via `open_key` under the |
| 153 | instance key, and the install registry is `spaceapps.add/remove` fold events |
| 154 | in its log β the server never learns which apps. Design + trust model |
| 155 | `docs/space-apps.md`. |
| 156 | - `push.go` β Web Push (self-hosted VAPID, `SherClockHolmes/webpush-go`). |
| 157 | - The **bot sidecar** (Claude / Eleven): a separate headless process |
| 158 | that is just another E2EE member; holds bot private keys + the |
| 159 | `ANTHROPIC_API_KEY`, outbound-only, runs on any machine (see `docs/bots.md`). |
| 160 | The default is **Go** β `lchat bot run`, a subcommand of the single binary |
| 161 | (`botcmd.go`, `internal/bot/`), no Node runtime. `agent/` is the **Node |
| 162 | reference implementation, kept on purpose** (a runnable example + the wire-format |
| 163 | illustration the Go tests pin against) β don't delete it or let it rot. |
| 164 | - `hooks.go` β **incoming webhooks** (#610): the admin-managed registry of |
| 165 | webhook definition blobs (opaque to the server) behind the webhook-ingest |
| 166 | sidecar β the bot sidecar's inverse. `lchat hooks run` (`hookscmd.go`, |
| 167 | `internal/hooks/`) serves `in.<host>` behind the router and turns inbound |
| 168 | HTTP into encrypted `webhooks.<ns>` events posted into one thread; it is |
| 169 | strictly write-only (no websocket, no message reads, no instance key). |
| 170 | Stood up per instance by `lchat add-hooks`. Design + trust model: |
| 171 | `docs/webhooks.md`; management UI `web/static/apps/webhooks.js` (admin-only). |
| 172 | - `unfurl.go` β OpenGraph link previews (SSRF-guarded fetch + cache). |
| 173 | - `fetch.go` β `lchat fetch run`, the **public-web fetch service**: a shared, |
| 174 | identity-free service (unfurl + camo-style HMAC-signed image proxy) that |
| 175 | fronts public web content for clients, so eventually the *instance* never |
| 176 | sees URLs and card-image hosts never see member IPs. Reuses unfurl.go's |
| 177 | fetch/SSRF/sealed-cache machinery; sees URLs attached to nothing; member |
| 178 | content categorically out of scope. Design + trust model + roadmap |
| 179 | (client flip, resize, Giphy-with-our-key): `docs/fetch.md`. |
| 180 | - `linkscmd.go` β `lchat links run`, the `11ch.at` short-link redirector for |
| 181 | message permalinks (issue #905): a bare apex request bounces to |
| 182 | `elevenmessenger.com`; `/l/<host>` live-checks the target (SSRF-guarded fetch |
| 183 | of its own `/api/version`, cached in memory, no database) before |
| 184 | redirecting. A subcommand of this binary, not a separate sibling service |
| 185 | like push/home β see `links.go` for the message-permalink tokens it fronts. |
| 186 | - `controld.go` / `cloud.go` / `reaper.go` β self-serve space creation on a |
| 187 | router box: a root-only local control daemon that executes `add-instance` (with |
| 188 | a capacity cap), and the public, unprivileged provisioner in front of it (caller |
| 189 | tokens, per-user quotas, friendly slugs). The home server's `POST /api/spaces` |
| 190 | forwards a signed-in user's request to it. `reaper.go` reclaims never-claimed |
| 191 | (zero-member) spaces so abandoned creates don't eat the cap, archiving to |
| 192 | `/data/trash` (recoverable) + a trash purge. See `docs/cloud-provisioner.md`. |
| 193 | - `activator.go` + `internal/activator/` β instance **hibernation** (opt-in, |
| 194 | per-host): state parks in S3 (embedded Litestream + a single-writer lease) |
| 195 | and the router wakes/sleeps instance processes on demand. The running half |
| 196 | of the planet-scale design: `docs/planet-scale.md`. |
| 197 | - `web/pages/*.html` β served with `__GROUP_NAME__` substituted (HTML-escaped). |
| 198 | - `web/static/*` β vanilla ES modules, **no build step**, served via `go:embed` |
| 199 | with `Cache-Control: no-cache`. |
| 200 | |
| 201 | Frontend module layout: |
| 202 | - `crypto.js` β AES-GCM helpers (`encrypt`/`decrypt` for JSON, `encryptBytes`/ |
| 203 | `decryptBytes` for binary). Format is base64url/`iv(12)||ciphertext`. |
| 204 | - `shell.js` β the chat page **host/shell**: sidebar, thread list, WebSocket, |
| 205 | crypto/keys, uploads, unfurl, push, and the bridge that mounts the app chat |
| 206 | engine. **All chat behaviour lives in `apps/chat.js`** β the sole chat |
| 207 | experience, mounted full-pane into `#app-pane` (`.app-mode` hides the shell's |
| 208 | legacy chat surfaces). β οΈ One last residue of the retired original chat |
| 209 | remains: the always-empty `#feed` div and its per-thread parking cache |
| 210 | (`feedCache`/`stashFeed`/`bindThreadState` and the alias maps |
| 211 | `msgEls`/`msgData`/β¦ β nothing writes them anymore). Dead-in-effect, pending |
| 212 | deletion; don't extend it or read it as a reference. (The classic renderer |
| 213 | and its typing pipeline are already deleted; `openLightbox` is NOT legacy β |
| 214 | it's the live header-avatar zoom.) |
| 215 | - `format.js`, `ui.js` β **pure, framework-free helpers kept separate so they're |
| 216 | unit-testable in Node** (`tokenizeText`, `formatBytes`, `shouldSend`). |
| 217 | - `theme.js` β colour theme / appearance / desktop text size (local-only |
| 218 | prefs; applies them at import time). First slice of breaking `shell.js` |
| 219 | into modules β extract sections verbatim, leaf-most first. |
| 220 | - `lightbox.js` β the shared photo lightbox (FLIP fly-in/out + gestures); |
| 221 | one copy, imported by both `shell.js` (avatar zoom) and `apps/chat.js` |
| 222 | (message photos) β they used to carry twin verbatim copies. |
| 223 | - `media.js` β pure client-side media helpers: `processImage` (downscale + |
| 224 | re-encode + EXIF strip + BlurHash) and `videoMeta` (poster frame, dims, |
| 225 | duration). Bytes out; callers encrypt. |
| 226 | - `shellstate.js` β the shell's shared mutable state (session, threads, |
| 227 | threadKeys, activeId, ws, roster, myUserId): `export let` + assignment-only |
| 228 | setters. Design: `docs/shell-modules.md`. State only, never behaviour. |
| 229 | - `moderation.js` β muted people, report-to-admins, blocked people (#578): |
| 230 | sealed prefs under the member's own keypair; repaints via the |
| 231 | `onModerationChange` sink. |
| 232 | - `searchui.js` β message search (#586): index wiring over the local store, |
| 233 | results, the deep back-fill walk, jump-to-message. Navigation via the |
| 234 | `wireSearch` hook; `search.js` stays the pure index. |
| 235 | - `avatars.js` β the decrypted-photo cache + initials/photo painters |
| 236 | (`avatarFor`/`threadAvatarFor`/`paintAvatar`/`avatarEl`). |
| 237 | - `shellverbs.js` β the behaviour seam: an empty registry shell.js fills at |
| 238 | boot; modules call `verbs.openThread(id)` etc. Registration only. |
| 239 | - `rosterui.js` β the member-display vocabulary (labels, presence lines, |
| 240 | picker rows). `groupactions.js` β the thread kebab's whole surface (edit |
| 241 | group, members, profiles, mute/pin, leave/close), through the verbs seam. |
| 242 | - `newchat.js` β the roster picker, membership view, Discover, apps |
| 243 | catalogue, and thread creation, through the verbs seam. |
| 244 | - `adddevice.js` β the add-a-device sheet (key-invite + QR; secret rides the |
| 245 | fragment). |
| 246 | - `onboardingui.js` β one Eleven: the scripted setup/welcome conversations + |
| 247 | the quick forms, bridging the flow engine into the chat app. |
| 248 | - `billingui.js` β paid access levels + billing (subscriptions.go): the tier |
| 249 | list load/render, per-tier key unwrap, and member subscribe/cancel/resume/ |
| 250 | change/portal calls. The verbs newchat/groupactions use stay in the seam. |
| 251 | - `store.js` β the local message store (#457): IndexedDB cache of RAW server |
| 252 | responses (ciphertext rows + wrapped keys, never plaintext) that boot and |
| 253 | thread-opens hydrate from before reconciling over the network. Its pure |
| 254 | decision helpers are Node-tested (`test/store.test.mjs`); the DB is |
| 255 | `lchat-store`, deliberately separate from the SW's version-pinned `lchat` DB. |
| 256 | - `apps.js` + `apps/*.js` β the mini-app framework and the apps themselves (one |
| 257 | self-contained file each). See `web/static/apps/README.md`. |
| 258 | - `push.js` / `sw.js` β push subscription + service worker. |
| 259 | - `admin.js` β admin console (invites, group name, server photo, encryption key). |
| 260 | |
| 261 | ## Commands & mini-apps: familiar first, delightful on top |
| 262 | |
| 263 | We're building a slash-command + mini-app layer. Hold this bar β the failure |
| 264 | mode is being too clever. |
| 265 | |
| 266 | - **Familiar before novel.** Commands should feel instantly recognisable to |
| 267 | anyone who's used Slack or Discord: `/command args`, a `/` typeahead of what's |
| 268 | available, a reply when something's off. Match those conventions; don't invent |
| 269 | our own. |
| 270 | - **A command invokes a *response*, in place.** Running a command produces a |
| 271 | reply β in almost every case a message from a bot, public or private ("only |
| 272 | visible to you") depending on context β **not** a UI change and **not** a jump |
| 273 | elsewhere. The default is: stay in the current context and answer. If a command |
| 274 | needs to move you or change the UI, offer that as an **action chip** on the |
| 275 | response, so leaving the current context is always the user's tap, never the |
| 276 | command's side effect. (Personal commands that navigate in the app are a |
| 277 | possible *later* addition β never the default.) A response may also be a |
| 278 | **command panel** β a temporary, interactive results surface in the same chat |
| 279 | (above the composer, or pinned above the feed) that the user or the app |
| 280 | dismisses: still in place, still transient, never a navigation (`ctx.panel`; |
| 281 | see `web/static/apps/README.md Β§ Command panels`). |
| 282 | - **Apps can live *alongside* the chat β but never must.** A mini-app may present |
| 283 | as a second view onto the same thread (a Chat conversation *and* an Interface, |
| 284 | toggled in the header), or it may take over the thread's full surface. The |
| 285 | companion shape is a *choice an app makes*, never something the platform imposes. |
| 286 | - **Delight rides on top.** The payoff is a delightful layer over plain chat β |
| 287 | beautiful pre-baked UI, or the expressive, reshape-it-on-demand power AI brings |
| 288 | β earned only once the familiar basics feel right. |
| 289 | |
| 290 | ### Views: takeover by default, companion by opt-in |
| 291 | |
| 292 | A personal app-thread is presented one of two ways, and **the app chooses** β |
| 293 | `shell.js` no longer decides for every app: |
| 294 | |
| 295 | - **Takeover (default).** The app's `render` *is* the whole thread β its full |
| 296 | interactive surface, no chat pane, no toggle. This is the original full-pane |
| 297 | model; anything from a whiteboard to a game wants it. |
| 298 | - **Companion (opt-in).** The thread offers a **Chat** view (a DM-style |
| 299 | conversation with the app's bot) *and* an **Interface** view, switched from a |
| 300 | header toggle (`Chat | Split | Interface`; Split is desktop-only). To-do and |
| 301 | Reminders use this. Both views fold from the **same event log** β one source of |
| 302 | truth, no separate state β so an action in one shows in the other. |
| 303 | |
| 304 | An app opts into companion **simply by implementing `chatMessage(event)`** β the |
| 305 | hook that projects each of its events into the Chat view (a bot bubble, or a |
| 306 | `/me`-style action line via `{ text, action:true }`); its state renders in |
| 307 | Interface via `render`. No `chatMessage` β takeover, with no exceptions: an app |
| 308 | that wants its events narrated in a *group chat's* feed without becoming a |
| 309 | companion implements the feed-only hook **`groupFeedMessage(event)`** instead |
| 310 | (the Vault), while a companion projects into groups through its one |
| 311 | `chatMessage` (called with `{ group: true }`) unless it implements both. So |
| 312 | "wants a chat view" and "knows how to project into chat" are the same |
| 313 | declaration, and the companion split is opt-in, not mandatory. The gate is |
| 314 | `appWantsChatView(name)` in `apps.js`; the decision lives there, not spread |
| 315 | through `shell.js`. |
| 316 | |
| 317 | ## Who wrote this: AI vs human markers (π€ / π¨) |
| 318 | |
| 319 | Prose in this repo carries an authorship marker, so a reader (human or agent) |
| 320 | can tell who wrote what β and so a human can **fence text off** from AI rewriting. |
| 321 | (The formal, versioned form of this convention is `docs/ai-markers.md` β a |
| 322 | project policy, not currently a licence condition; see `README.md Β§ Licence` |
| 323 | for why. Keep the two in step.) |
| 324 | |
| 325 | - **π€ β written by an LLM.** Always **visible**, immediately before the heading, |
| 326 | paragraph, or section it applies to (`# π€ Title`, or a leading `π€ ` on a |
| 327 | paragraph). A π€ on a heading **cascades**: it marks that heading and everything |
| 328 | beneath it β sub-headings, paragraphs, lists β down to the next marker of the |
| 329 | same-or-higher level or a human-certified block. So one π€ on a document's top |
| 330 | heading marks the whole document. |
| 331 | - **No marker β ambiguous.** Could be either; today's status quo. Never *assume* |
| 332 | AI authorship; when you genuinely don't know, leave it unmarked. |
| 333 | - **A person emoji (π¨ / π€ / π§ β¦) β certified human, off-limits.** The text was |
| 334 | written or vetted by a person. **An LLM must not rewrite, rephrase, condense, |
| 335 | or delete it.** You may add new π€ prose nearby, but the human's words are |
| 336 | fixed. In HTML/Markdown the person marker may hide in a comment (`<!-- π¨ -->`) |
| 337 | so it doesn't render; the π€ marker must **never** be hidden. |
| 338 | |
| 339 | Transitions: |
| 340 | - An LLM writing new prose marks it π€ (including new `changelog/*.md` entries β |
| 341 | their first line gets the π€). |
| 342 | - An LLM that substantially rewrites *unmarked* prose makes it π€. |
| 343 | - A human rewriting π€ text **removes the π€** (back to ambiguous), or adds their |
| 344 | person marker to certify and lock it. |
| 345 | - An LLM that reaches a person-marked block hands off β leave it exactly as is. |
| 346 | |
| 347 | Baseline: **everything in this repo was AI-written unless a block carries a person |
| 348 | marker.** Every Markdown file is stamped π€ at its top; changelog entries (which |
| 349 | open with the note itself) carry the π€ on their first line. |
| 350 | |
| 351 | ## Conventions |
| 352 | |
| 353 | - π€ **Every feature ships to web *and* native β unless explicitly scoped |
| 354 | otherwise.** Don't stop at the web implementation: a change isn't done until |
| 355 | it also lands on the native apps (or genuinely doesn't apply to them β e.g. |
| 356 | it lives in a surface natives render through the webview). Only a deliberate, |
| 357 | stated decision ("web-only for now") narrows the scope, never an omission. |
| 358 | - π€ **Mac and iOS keep feature parity β and iOS comes first.** The two native |
| 359 | apps are one product: a feature that lands on one lands on the other in the |
| 360 | same change (shared logic in `ios/LChatCore`, thin per-platform UI). When |
| 361 | effort must be split or sequenced, build and polish the **iOS** app first β |
| 362 | it's the primary surface members live in; the Mac follows. |
| 363 | - **Never `innerHTML` untrusted content.** Build DOM nodes / use `textContent`. |
| 364 | `innerHTML` is only acceptable with static, in-code strings (e.g. `ICON_X`). |
| 365 | - **Never show a toast that floats over UI.** Nudges, warnings, and status |
| 366 | render inline, occupying layout space β nothing overlays content someone may |
| 367 | be reading or about to tap. Existing floating-`#toast` uses migrate to |
| 368 | inline feedback as they're touched (see `docs/onboarding-and-home-server.md`). |
| 369 | - π€ **Every displayed number pluralises and localises.** Any count shown with |
| 370 | a label handles pluralisation ("1 contributor" / "2 contributors" β never |
| 371 | "1 contributors"), and digits render with locale delimiting |
| 372 | (`toLocaleString()` on web / `NumberFormatter`β`Text(_, format:)` on |
| 373 | native: 2,723 not 2723; prefer `font-variant-numeric: tabular-nums` where |
| 374 | columns align). Never concatenate a bare `n + " things"`. |
| 375 | - π€ **Accessibility is a shipping requirement β WCAG 2.2 AA** (statement: |
| 376 | `docs/accessibility.md`). New/changed UI is keyboard-operable with visible, |
| 377 | managed focus; icon-only controls are labelled (give DOM nodes their ARIA as |
| 378 | you build them); contrast holds in light *and* dark; drags/swipes have |
| 379 | non-drag alternatives and β₯24px targets; ambient motion (smoke/motes etc.) |
| 380 | respects `prefers-reduced-motion` / Reduce Motion; state is never color |
| 381 | alone. Native views carry accessibility labels/traits and survive the |
| 382 | largest Dynamic Type sizes. |
| 383 | - **Token-gate every data endpoint** with `s.tokenOK(token)` (or the equivalent |
| 384 | invite check) β messages, uploads, blob downloads, unfurl, push subscribe. |
| 385 | - **Pull pure logic into a testable module** rather than burying it in DOM code, |
| 386 | then test it (see `format.js`/`ui.js` + `test/*.mjs`). |
| 387 | - **Platform split** via `isCoarse` (`matchMedia("(pointer: coarse)")`): |
| 388 | - Composer: desktop Enter sends / Shift+Enter newlines; touch Enter newlines, |
| 389 | the send button sends (`shouldSend`). |
| 390 | - Message actions: **touch = long-press** opens the message menu (emoji |
| 391 | reactions above the bubble, a labelled Reply/Copy/Edit/Delete menu below); |
| 392 | **desktop = hover strip + press-and-hold** for the picker. Native touch |
| 393 | selection/callout is suppressed on bubbles so long-press opens the menu β Copy |
| 394 | lives in the menu instead. Swipe-right still quick-replies on touch. |
| 395 | - **Images** are downscaled in the browser before upload (`processImage`, max |
| 396 | 2048px long edge) β there is no server-side image proxy (it only has |
| 397 | ciphertext). Re-encoding also strips EXIF/GPS. |
| 398 | - π€ **Eleven is not gendered.** Eleven β the built-in helper persona β is the |
| 399 | product's voice, not a character with a gender: never "she/her" (or |
| 400 | "he/him") in any copy, changelog entry, doc, prompt, or commit message. |
| 401 | Refer to Eleven by name, or as "it" where a pronoun is unavoidable (recast |
| 402 | the sentence when "it" reads awkwardly). Audit Eleven-adjacent prose for |
| 403 | stray pronouns before shipping. |
| 404 | - π€ **The Eleven app icon already exists β use it, don't invent a mark.** The |
| 405 | logo is a purple rounded tile with the white double-slash "11". Canonical PNGs: |
| 406 | `web/static/icon-{180,192,512}.png` (the PWA/web icons) and the native icon |
| 407 | sets `ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png` + |
| 408 | `ios/LChatMac/Assets.xcassets/AppIcon.appiconset/`. When a page needs the brand |
| 409 | mark or a favicon, reference/copy one of these β never hand-roll a substitute |
| 410 | glyph. (Each service embeds its *own* static dir, so the home server carries |
| 411 | its own copy at `home/web/static/icon-{192,512}.png`.) |
| 412 | - **Push notifications** suppress two cases: your *own* messages (server skips the |
| 413 | sender's subscriptions by token) and messages while the app is open (the page |
| 414 | writes a `lastActiveAt` heartbeat to IndexedDB that the SW checks β more |
| 415 | reliable than `clients.matchAll().focused` on iOS). The admin "test" push |
| 416 | always shows. |
| 417 | - Match the surrounding code's style and comment density. Comment the **why**, |
| 418 | not the what. |
| 419 | - **User-visible changes add a changelog entry**: a short, user-facing note in |
| 420 | `changelog/<date>-<slug>.md` (one file per change β parallel PRs never |
| 421 | conflict). Entries are embedded in the binary and served at `/api/changelog`; |
| 422 | the server announces new ones after a deploy β a push to the space's admins |
| 423 | plus the Admin app's update stream (announce.go) β so write |
| 424 | them for members, not developers. **Native Mac/iOS changes: hold the entry |
| 425 | until the release is minted.** The changelog announces on the next *server* |
| 426 | deploy, but a Mac/iOS change only reaches members once a build is actually |
| 427 | downloadable (TestFlight for iOS; GitHub Releases/Sparkle for Mac) β so a |
| 428 | native feature PR ships *without* an entry, and the PR that mints the release |
| 429 | adds one covering everything in that build. |
| 430 | - π€ **Keep the fleet operations guide current.** `docs/planet-scale-guide/` is |
| 431 | the multi-page, human-focussed HTML guide to the planet-scale machinery β the |
| 432 | router, the process/systemd model, versioned deploys and channels, space |
| 433 | creation/removal, hibernation, and the mesh. A PR that changes any of that |
| 434 | machinery's operator-visible behaviour (commands, flags, env vars, unit files, |
| 435 | filesystem layout, timings/defaults, or the shipped-vs-roadmap status of a |
| 436 | feature) updates the guide in the same PR. Keep its status badges honest β |
| 437 | "running today" / "partially built" / "designed, not built" β and when a |
| 438 | roadmap item ships, promote its badge and move the text out of the dashed |
| 439 | roadmap panel. How the guide is structured (shared nav in `guide.js`, shared |
| 440 | styles, page conventions): `docs/planet-scale-guide/README.html`. |
| 441 | - **Refresh Eleven's stats periodically.** `internal/bot/knowledge.md` Β§16 has a |
| 442 | *"By the numbers"* block (LOC + binary/app sizes) that Eleven quotes to members |
| 443 | (this file is Eleven's reference β the single source both sidecars use: the Go |
| 444 | one `go:embed`s it, `agent/knowledge.mjs` reads it). The figures are |
| 445 | hand-measured and drift every release, so re-measure and update them (and the |
| 446 | "as of" date) every so often β a quick `git ls-files '*.go' β¦| wc -l` for |
| 447 | lines, and `du -h` on the built binaries / `.app` bundles for sizes. |
| 448 | - π€ **Keep the carbon tally accruing β act on the session nudge.** |
| 449 | `web/status/carbon.json` is the public running estimate of the AI tokens |
| 450 | spent building this app, per contributor machine (served at |
| 451 | status.elevenmessenger.com/carbon.html; methodology |
| 452 | `docs/comparison-carbon.md`). A `SessionStart` hook runs |
| 453 | `node tools/carbon-tally.mjs check` and tells the session when this |
| 454 | machine's entries are stale (>3 days) β its raw material, local Claude Code |
| 455 | transcripts, prunes after about a week, so a missed week is lost to the |
| 456 | ledger. (The same hook runs `node tools/prompt-registry.mjs check` for the |
| 457 | prompt registry β silent on machines that don't publish one; when it nags, |
| 458 | accrue per `docs/status.md Β§ The prompt registry`.) When nudged (or before starting long work): run |
| 459 | `node tools/carbon-tally.mjs --write` (per-source cutoffs β re-runs and |
| 460 | other machines never double-count), commit the tally as a **tiny standalone |
| 461 | PR merged promptly** (two open branches accruing the same window is the one |
| 462 | race the cutoffs can't see; the contract test catches it on main), and copy |
| 463 | it to the router box's status-pages dir (`docs/status.md Β§ Keeping the |
| 464 | carbon tally accruing`). |
| 465 | |
| 466 | ## π€ Cross-platform event contract (web β native) |
| 467 | |
| 468 | 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. |
| 469 | |
| 470 | **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. |
| 471 | |
| 472 | **The solution:** Shared JSON vector files (one per app) list every event type with: |
| 473 | - The decrypted payload shape |
| 474 | - The server frame metadata |
| 475 | - The expected fold event (what the web handler must produce) |
| 476 | - The expected native fields (what native structures must hold) |
| 477 | |
| 478 | Both test suites validate against these vectors: |
| 479 | - **Web:** `test/event-vectors.test.mjs` β feeds each payload + meta to the fold handlers, asserts output matches expected state |
| 480 | - **Native:** `ios/.../EventVectorTests.swift` + `TodoAppVectorTests.swift` + `RemindAppVectorTests.swift` β deserialize payloads, assert state structures match expected shapes |
| 481 | |
| 482 | **Maintaining the contract:** |
| 483 | |
| 484 | 1. **When you change a web app's fold handlers** (e.g., `app.hostEvents` in `web/static/apps/chat.js`): |
| 485 | - Run `npm test` to ensure vectors still pass |
| 486 | - If the handler changes what it produces, update the app's vector file in `test/event-vectors/` + re-run tests |
| 487 | - If the change is a bug fix, add a note in the vector's `note` field |
| 488 | - Example: changing `chat.js` β update `test/event-vectors/chat.json` |
| 489 | |
| 490 | 2. **When you change a native structure** (e.g., `MessagePayload` in `ios/.../Models.swift`): |
| 491 | - Update the relevant test file if the decode rules changed (e.g., `EventVectorTests.swift`) |
| 492 | - Run iOS tests; they validate the native side still matches vectors |
| 493 | - If native and web have diverged, that's a test failure β fix both sides in the same PR |
| 494 | |
| 495 | 3. **When you add a new feature to an app** (e.g., a new field in message payload or todo text): |
| 496 | - Add a vector entry in the appropriate file in `test/event-vectors/` (e.g., `test/event-vectors/chat.json`) |
| 497 | - Add a test case to both web and native test files |
| 498 | - Run both test suites before landing the PR |
| 499 | |
| 500 | **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. |
| 501 | |
| 502 | **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). |
| 503 | |
| 504 | **Extended to app-threads:** Todo and Reminders follow the same pattern. |
| 505 | - `test/event-vectors/todo.json` and `test/event-vectors/remind.json` include app-specific vectors |
| 506 | - `test/event-vectors.test.mjs` tests fold handlers for all apps (web side) |
| 507 | - `ios/.../TodoAppVectorTests.swift` + `RemindAppVectorTests.swift` (native side β tests fail initially, pass as you implement) |
| 508 | |
| 509 | ## App-thread events: todo & remind contract |
| 510 | |
| 511 | Both apps are personal (one thread per user), so native implementations don't exist yetβthe vectors serve as the spec. |
| 512 | |
| 513 | **To-do app** (web/static/apps/todo.js): |
| 514 | - State: `{ items: [{ id, text, done, doneAt, by }] }` |
| 515 | - 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) |
| 516 | - Grace period: done items linger ~750ms with a depleting ring, then tuck into "Completed" |
| 517 | - Chat projection: `/todo X` shows as an action line; done/reopen/remove are silent |
| 518 | |
| 519 | **Reminders app** (web/static/apps/remind.js): |
| 520 | - State: `{ items: [{ id, text, when, ts, sid, fired, firedAt }] }` |
| 521 | - Events: `remind.add` (text, when=absolute epoch ms, sid=server schedule id), `remind.fire` (notice + timestamp), `remind.done` |
| 522 | - Sorting: items always sorted by `when` (soonest first) |
| 523 | - Grace period: fired reminders linger ~750ms, then tuck into "Previous reminders" |
| 524 | - Offline fallback: if `sid` is empty, scheduling failed; UI renders a warning and ticker fires client-side |
| 525 | - Chat projection: `remind.fire` shows as a message (title + body from notice); add/done are silent |
| 526 | |
| 527 | **Native implementation guidance:** |
| 528 | - Use the vectors as the spec; tests initially fail (that's the point) |
| 529 | - Implement fold handlers for each app that match web side exactly (use vectors as golden truth) |
| 530 | - Partition logic (grace period, sorting) is derived in render, not a timerβnever mutate state on a clock |
| 531 | - For todo: doneAt > (now - 750ms) stays active; for remind: same but firedAt |
| 532 | - Tests document idempotence (duplicate adds deduped), text preservation (unicode safe), sorting, and partition rules |
| 533 | |
| 534 | ## Project memory (`.claude/memory/`) |
| 535 | |
| 536 | 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. |
| 537 | |
| 538 | When working on this project, Claude agents will automatically load memory from this directory. To update memory: |
| 539 | - Edit `.claude/memory/*.md` files directly |
| 540 | - Commit memory changes to the branch like any other code change |
| 541 | - Memory is version-controlled and portableβno machine-specific paths |
| 542 | |
| 543 | ## Build & test |
| 544 | |
| 545 | ```sh |
| 546 | go build -o lchat . # or: CGO_ENABLED=0 go build ./... |
| 547 | go test ./... # db_test, handlers_test, unfurl_test (httptest) |
| 548 | npm test # node test/{crypto,ui,format,event-vectors}.test.mjs |
| 549 | node --check web/static/shell.js # JS isn't run by tests; syntax-check edits |
| 550 | |
| 551 | # Native (iOS/Mac): open ios/LChat.xcodeproj and run EventVectorTests.swift |
| 552 | # Or via xcodebuild: xcodebuild test -scheme LChat -only EventVectorTests |
| 553 | ``` |
| 554 | |
| 555 | Add tests as you go. Go handler tests use `newTestServer(t)` which returns |
| 556 | `(*httptest.Server, *Server)` so you can seed the DB directly. SSRF tests flip |
| 557 | `allowPrivatePreviewHosts` to reach a loopback `httptest` server. |
| 558 | |
| 559 | **Event vectors are mandatory:** Every chat message feature PR must pass |
| 560 | `npm test` (web side) and EventVectorTests (native side) β they verify the |
| 561 | event schema stays in sync across platforms. |
| 562 | |
| 563 | π€ **Where manual verification happens is a per-developer preference β ask |
| 564 | if you don't know it** (Paul, 2026-07-16). Whether a session verifies |
| 565 | features by hand against the full stack (a per-session `test-<slug>.11ch.at` |
| 566 | canary or the dev environment) or against a hand-run local server is not a |
| 567 | project mandate β it's each developer's stored preference. One developer may |
| 568 | always want a live canary, another may always want localhost; the system |
| 569 | doesn't care. Each developer records theirs in their own `CLAUDE.local.md`; |
| 570 | if the prompter's preference isn't already recorded, **ask them** before |
| 571 | manual testing starts, then save the answer there so no session asks twice. |
| 572 | Know what the choice trades: localhost skips the router, real TLS, push, the |
| 573 | home server, and every cross-service seam. **Local testing can close most of |
| 574 | that gap by riding the relay** (`lchat try` β the tunnel gateway fronts the |
| 575 | local server at a real `try-*.11relay.com` origin with real TLS; |
| 576 | `docs/tunnel-try.md`): local code, reached the way the world reaches it β |
| 577 | the accepted middle ground when a local-preference developer needs a real |
| 578 | origin, or a full-stack developer needs to test unmerged code. For a |
| 579 | full-stack developer, a feature touching the singleton services (home, push, |
| 580 | cloud) that can't canary today is **held for the dev environment**, not |
| 581 | localhost-tested in the meantime. The automated suites are unaffected either |
| 582 | way (`go test`'s httptest servers, `npm test`, the hermetic E2E rigs β those |
| 583 | are tests, not testing). |
| 584 | |
| 585 | ## Deploy |
| 586 | |
| 587 | Deployed as a single static binary per chat. Each chat is a **fully-isolated |
| 588 | instance** β its own process, DB, admin token, and subdomain β supervised by |
| 589 | systemd behind the instance router (`docs/instance-router.md`); shipping is |
| 590 | via the signed release channels (`docs/server-releases.md`). The operator |
| 591 | entry point for all of it is **`./deploy/ops`** (`deploy/README.md Β§ The |
| 592 | operator surface`). The `Dockerfile` exists for self-hosters on container |
| 593 | platforms (README Β§ Docker); nothing in our production runs in Docker (the |
| 594 | original Miren container path was retired 2026-07-15). |
| 595 | |
| 596 | > Box addressing rides `~/.ssh/config` plus the `LCHAT_*` env overrides the |
| 597 | > ops scripts read (defaults live in the scripts themselves); secrets live in |
| 598 | > Bitwarden under the vault contract (`deploy/vault-items.sh`). |
| 599 | > **`CLAUDE.local.md` holds personal preferences** (e.g. the manual-testing |
| 600 | > choice) β never operational procedure. A local file or doc that contradicts |
| 601 | > an ops verb is stale by definition: fix the verb, not the prose. |
| 602 | |
| 603 | Principles that hold regardless of environment: |
| 604 | |
| 605 | - π€ **Operations run through `./deploy/ops` β never reconstructed from |
| 606 | prose. NO LLM COOKBOOKS.** Bare `ops` lists the verbs; `ops doctor` is the |
| 607 | read-only preflight that checks every credential and access path an |
| 608 | operator needs and prints the fix under each failure, never a secret. If a |
| 609 | task has no verb, or a verb falls short, **extend ops in a small PR and |
| 610 | then run it** β do not hand-run command sequences gleaned from `docs/`, |
| 611 | this file, `CLAUDE.local.md`, or memory; that path is how snowballing |
| 612 | runbooks happen, and it is closed. The documents under `docs/` are design |
| 613 | rationale and history: where one appears to give operational steps, the |
| 614 | script is the truth and the doc is stale (`deploy/README.md Β§ The operator |
| 615 | surface`). |
| 616 | - **Deploys are data-safe.** `lchat deploy` repoints the instance's `live` |
| 617 | symlink and restarts β it never touches `/data/<host>.db*`. **Never delete |
| 618 | an instance's data to update it**; `lchat remove` deliberately leaves |
| 619 | `/data` behind, and deleting those files is only for intentionally |
| 620 | resetting an instance (or tearing down a canary). DB migrations are additive |
| 621 | (`CREATE TABLE IF NOT EXISTS`), so new code over old data is safe. |
| 622 | - **Admin tokens survive redeploys** β they live in `/data/<host>.env`, which |
| 623 | deploys never touch. Only `add-instance` without `--admin-token` mints a |
| 624 | fresh one, which would invalidate saved links; reuse the existing token when |
| 625 | recreating an instance. |
| 626 | - **Env contract:** the app reads `LCHAT_ADMIN_TOKEN`, `LCHAT_DB`, `ADDR`. Use the |
| 627 | `LCHAT_`-prefix deliberately (a bare `ADMIN_TOKEN` can be auto-provisioned/ |
| 628 | overridden by the platform). |
| 629 | - Keep a **throwaway instance** for verifying on real devices. |
| 630 | - π€ **Fleet boxes: operator SSH rides WireGuard; root SSH is bootstrap-only.** |
| 631 | Public firewall = 80/443 + the WireGuard port only; operators connect as |
| 632 | named accounts over the tunnel, and root capability exists solely through |
| 633 | the committed sudoers allowlist (`deploy/sudoers-operators`) β anything new |
| 634 | that needs root gets an allowlist line via a reviewed PR, never a root |
| 635 | shell. Every operator SSH session gets a public why-entry in the status |
| 636 | ledger (`web/status/ssh/`). Recipe: `docs/instance-router.md Β§ Hardening`. |
| 637 | |
| 638 | ### π€ Per-session canaries (test-*.fi.dev.11ch.at) |
| 639 | |
| 640 | Instances are disposable β wildcard DNS covers the canary domains, so |
| 641 | creating one needs no DNS step. **Canaries live on the dev mesh by default** |
| 642 | (Paul, 2026-07-22): throwaway test instances don't belong on the box real |
| 643 | members live on, and they'd eat its instance cap. The default is the |
| 644 | Helsinki dev box (`fi.dev.11ch.at` β amd64, matching `build-linux.sh`); |
| 645 | targeting production (`LCHAT_CANARY_BOX=elevenbox LCHAT_CANARY_DOMAIN=11ch.at`) |
| 646 | is a deliberate, stated exception. One dev caveat: no wildcard cert there by |
| 647 | design, so each dev canary burns a per-host HTTP-01 cert from the shared |
| 648 | 50/week 11ch.at budget β fine at canary cadence. So instead of sharing one |
| 649 | `test` instance across parallel sessions (they trample each other): |
| 650 | |
| 651 | The encoded path is `./deploy/ops canary create|deploy|destroy <slug>` β |
| 652 | each verb enforces the rules below, so prefer it over hand-run `lchat` |
| 653 | commands. The rules themselves, and why: |
| 654 | |
| 655 | - **Every Claude session doing server work creates its own canary**: |
| 656 | `ops canary create <slug>` (box override: `LCHAT_CANARY_BOX`), slug = |
| 657 | your branch/feature. The `test-` prefix keeps canaries visually distinct |
| 658 | from real member spaces (short sqid subdomains); creation also flags the |
| 659 | route `--canary` β the deliberate signal (never inferred from the |
| 660 | hostname) that lets `lchat reset` wipe it at teardown. |
| 661 | - **A canary never auto-updates β pin it.** `ops canary deploy <slug>` |
| 662 | builds the current branch and deploys `--pin`; the hourly auto-updater |
| 663 | leaves pinned instances alone, and `lchat update status` shows them as |
| 664 | `AUTO=pinned`. The session owns its canary's version for its whole life |
| 665 | (`lchat rollback` / re-deploy as you iterate). |
| 666 | - **The session tears its canary down** when the work merges: |
| 667 | `ops canary destroy <slug>` β `lchat reset` (archives the DB to |
| 668 | `/data/trash`; the canary flag is its gate) then `lchat remove --stop` |
| 669 | (which also disables bot/hooks sidecar units). Removal alone never deletes |
| 670 | data β that's deliberate for real instances; the canary-gated reset is how |
| 671 | a hardened box (no allowlisted `rm`) destroys a canary's data anyway. |
| 672 | - Canaries hold **no real member data** and their admin tokens are **not** |
| 673 | stored in 1Password β everything about them dies with the session. |
| 674 | - Corollary for fleet-wide operations (channel changes, unpin sweeps, |
| 675 | `deploy --all`): check `lchat update status` first β every `AUTO=pinned` |
| 676 | row is someone's live canary; leave it alone. (`deploy --all --version` |
| 677 | now skips pinned rows by itself; `--include-pinned` is the deliberate |
| 678 | override. The check still matters for channel changes and unpin sweeps.) |
| 679 | |
| 680 | ## Dev CLI (`tools/dev`) |
| 681 | |
| 682 | Simple command-line interface for common dev tasks. Run from anywhere in the repo. |
| 683 | |
| 684 | ```sh |
| 685 | ./tools/dev build mac # Build native Mac app (xcodebuild) |
| 686 | ./tools/dev build ios # Build native iOS app (xcodebuild) |
| 687 | ./tools/dev run mac # Launch the Mac app |
| 688 | ./tools/dev run ios # Run iOS app on simulator |
| 689 | ./tools/dev install ios [id] # Build + install + launch on a REAL iPhone, over |
| 690 | # the network (no cable): unlocked, trusted, same |
| 691 | # Wi-Fi. Device id defaults to the first paired |
| 692 | # one; LCHAT_IOS_DEVICE overrides. Signing is |
| 693 | # automatic β a new device must be registered |
| 694 | # with the team first. "iOS <v> is not installed" |
| 695 | # = point DEVELOPER_DIR at an Xcode with that |
| 696 | # platform. |
| 697 | ./tools/dev alt [slug] # Build + launch a side-by-side dev alt of the Mac app |
| 698 | ./tools/dev up [flags] # Start local dev server (persistent config) |
| 699 | # + the webhook-ingest sidecar (--no-hooks skips) |
| 700 | # flags pass to lchat-dev-up: --clean, --onboarding, |
| 701 | # --port N, --help |
| 702 | ./tools/dev seed-mac # Seed Mac app with dev server credentials |
| 703 | ./tools/dev link # Print dev server sign-in link |
| 704 | ./tools/dev test # Run full test suite (Go + Node) |
| 705 | ./tools/dev test web # Run web tests only |
| 706 | ./tools/dev test go [args] # Run Go tests only (args go to `go test`, |
| 707 | # e.g. -run TestFoo ./internal/bot) |
| 708 | ./tools/dev shots <verb> # Marketing-screenshot kit (docs/marketing-screenshots.md): |
| 709 | # up build the isolated demo Mac app (if needed) + |
| 710 | # run a throwaway local server seeded with the |
| 711 | # fictional "The Commons" space (foreground) |
| 712 | # tunnel give it a public https try-*.11relay.com address |
| 713 | # mac launch the isolated "Eleven Demo" Mac app |
| 714 | # web-check <png> headless 2x screenshot of the seeded space |
| 715 | ``` |
| 716 | |
| 717 | 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. |
| 718 | |
| 719 | π€ **Dev alts (`./tools/dev alt [slug] [--icon live]`)** build the same LChatMac |
| 720 | target under an |
| 721 | alternate identity β bundle id `β¦messenger.mac.alt-<slug>`, name "Eleven Dev", |
| 722 | the orange dev icon (`AppIconDev`; `--icon live` picks the green `AppIconDevLive` |
| 723 | β the convention for an alt aimed at PRODUCTION home, so two alts with |
| 724 | different aims never share an icon; regenerate either with |
| 725 | `swift tools/make-dev-icon.swift [live]`), |
| 726 | Sparkle auto-checks off β so a dev build runs **beside** the Sparkle-updated app |
| 727 | instead of replacing it. The keychain access group is unchanged, so an alt sees |
| 728 | the signed-in spaces: **it's the real account, not a sandbox** (and while it |
| 729 | runs you'll get doubled notification banners β quit it when done). One running |
| 730 | app per slug; parallel sessions pick different slugs (`dev`, `dev2`, β¦) the way |
| 731 | web canaries pick subdomains. Debug builds can aim at the dev home server: |
| 732 | `defaults write <bundle id> home_base_override https://home.dev.elevenmessenger.com` |
| 733 | (or env `LCHAT_HOME_BASE`; LChatCore `HomeBase.swift` β Release compiles it out). |
| 734 | Never install an alt (or any Debug build) to |
| 735 | /Applications β that's how a stale dev build ends up masquerading as the |
| 736 | released app. |
| 737 | |
| 738 | π€ **Marketing screenshots (`./tools/dev shots <verb>`)** stand up a |
| 739 | **fictional** demo space ("The Commons") for capturing the native Mac + |
| 740 | iPhone shots in the website hero collage β the committed form of a |
| 741 | capture recipe that used to live only in agent memory. Implementation in |
| 742 | `tools/marketing-shots/`; full process + the WHY in |
| 743 | `docs/marketing-screenshots.md`. `shots up` builds the isolated demo Mac app |
| 744 | (if needed) and runs a throwaway local server seeded through the real E2EE |
| 745 | protocol; `shots tunnel` fronts it at a public `try-*.11relay.com` origin for |
| 746 | the native apps; `shots mac` launches it; `shots web-check <png>` grabs a |
| 747 | headless 2Γ screenshot to eyeball the look. **Isolation is the point:** unlike |
| 748 | a normal build or a dev alt (both share the `β¦messenger.shared` |
| 749 | keychain-access-group, so they read the *real* signed-in spaces β an alt has |
| 750 | grabbed Paul's private space before), the demo Mac build is signed with the |
| 751 | minimal `LChatMac-Demo.entitlements` (no shared keychain β its own empty |
| 752 | keychain), a distinct `β¦mac.capdemo` bundle id, applied purely as |
| 753 | **command-line build-setting overrides** so `project.yml` is never touched and |
| 754 | a plain `./tools/dev build mac` stays the real app. Interactive Mac/iPhone |
| 755 | captures stay **manual** (the tool stands up the demo + points you at the doc). |
| 756 | |
| 757 | Typical workflow: |
| 758 | |
| 759 | ```sh |
| 760 | ./tools/dev up # 1. Start dev server in one terminal |
| 761 | ./tools/dev seed-mac # 2. Seed Mac app with dev server credentials |
| 762 | ./tools/dev run mac # 3. Build (if needed) and launch it |
| 763 | ``` |
| 764 | |
| 765 | ## Branches, commits & PRs |
| 766 | |
| 767 | - **Prefer small, focused PRs that ship incrementally.** When iterating, slice |
| 768 | work into the smallest change that stands on its own and land it, rather than |
| 769 | batching a large feature into one big PR. Small PRs review faster, merge with |
| 770 | fewer conflicts (several land in parallel), and are safer to deploy and revert. |
| 771 | Split a big idea into a sequence of shippable steps. |
| 772 | - **Work on a feature branch β don't commit straight to `main`.** Several PRs |
| 773 | land in parallel, so pushing directly to `main` causes push races and forced |
| 774 | rebases. Branch per feature (e.g. `reply-to-messages`), open a PR, and |
| 775 | **squash-merge** into `main` (`gh pr merge --squash --delete-branch`). |
| 776 | - **Always do PR work in a git worktree**, not the main checkout. Several |
| 777 | sessions share this checkout, so editing in place trips over concurrent work; |
| 778 | a worktree keeps each PR isolated until it merges. |
| 779 | - **Multiple commits per branch are fine** β they squash into one clean commit on |
| 780 | `main`, so iterate freely. Within a branch, prefix commits by area: `web:`, |
| 781 | `server:`, `admin:`, `sw:`, `crypto:`, `docs:`. |
| 782 | - End commit and PR-body messages with the `Co-Authored-By:` trailer for the |
| 783 | assisting model. (Commit author is the repo's local git config β see |
| 784 | `CLAUDE.local.md`.) |
| 785 | - **Deploy from `main` after the squash-merge** (pull `main`, then redeploy β see |
| 786 | Deploy), never from a feature branch. Branch / commit / PR / merge / deploy |
| 787 | only when asked. |
| 788 | |