eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– CLAUDE.md
2
3Working notes for anyone (human or agent) changing this codebase. User-facing
4docs and the encryption rationale live in `README.md`; this file is about how we
5build and the decisions we've committed to.
6
7## The one rule: end-to-end encryption is load-bearing
8
9The whole point of lchat is **"if the server is compromised, the attacker gets
10nothing."** 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
32Two 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
54Instance **branding** is plaintext by design and doesn't count against the rule β€”
55it's admin-set, never user content. Three things: the **group name** (`config`
56`group_name`), the optional **server avatar** (`instance_avatar` table, served
57in 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
59join/onboarding cards, the native add-chat screens, and the home Save-passkey
60card β€” 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
62to members and to valid invite-link holders (`GET /api/key-invite/{id}`), never
63on 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
68ordinary E2EE member whose "device" is a machine the operator runs (`agent/`),
69so that machine holds the bot's private key and can decrypt exactly the threads
70the bot is in β€” never anything else, and the lchat *server process* still never
71holds a usable key. Two invariants follow: **bot code never runs inside the
72server process** (compromising the server must not yield bot keys or the
73Anthropic key), and the sidecar stays a **pure outbound client** (no inbound
74ports), so operators can move it to a better-defended machine β€” off-box, behind
75NAT, or in a no-inbound private network. Trust model + the Go sidecar (the
76default 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
80Native 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
82every instance. Like the admin token, the `.p8` is *auth*, not a decryption key:
83the relay forwards the same ciphertext blob Apple already sees (device token +
84opaque thread id + sealed bytes), never plaintext and never a usable key β€”
85instances authenticate to it with a per-instance bearer token. The one copy of
86the signer + aps envelope lives in `internal/apns/` (shared by the messenger and
87the relay so they can't drift). Web Push is unaffected (per-instance VAPID, no
88shared secret). See `docs/push-relay.md`.
89
90## πŸ€– The second rule: built to outlive AI
91
92Everything built here is designed to **outlive AI**. This codebase was largely
93produced 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
109The upside cuts the other way too: if high-capability AI keeps getting
110cheaper, a human-legible, well-tested baseline is the most solid floor it can
111build from. Either way the product doesn't wobble with the AI industry's
112fortunes.
113
114## Architecture
115
116Single static Go binary; the frontend and a pure-Go SQLite engine are compiled
117in, 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
201Frontend 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
263We're building a slash-command + mini-app layer. Hold this bar β€” the failure
264mode 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
292A 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
304An app opts into companion **simply by implementing `chatMessage(event)`** β€” the
305hook 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
307Interface via `render`. No `chatMessage` β†’ takeover, with no exceptions: an app
308that wants its events narrated in a *group chat's* feed without becoming a
309companion 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
313declaration, and the companion split is opt-in, not mandatory. The gate is
314`appWantsChatView(name)` in `apps.js`; the decision lives there, not spread
315through `shell.js`.
316
317## Who wrote this: AI vs human markers (πŸ€– / πŸ‘¨)
318
319Prose in this repo carries an authorship marker, so a reader (human or agent)
320can 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
322project policy, not currently a licence condition; see `README.md Β§ Licence`
323for 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
339Transitions:
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
347Baseline: **everything in this repo was AI-written unless a block carries a person
348marker.** Every Markdown file is stamped πŸ€– at its top; changelog entries (which
349open 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
468Chat 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
478Both 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
4841. **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
4902. **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
4953. **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
511Both 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
536Project 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
538When 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
546go build -o lchat . # or: CGO_ENABLED=0 go build ./...
547go test ./... # db_test, handlers_test, unfurl_test (httptest)
548npm test # node test/{crypto,ui,format,event-vectors}.test.mjs
549node --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
555Add 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
561event schema stays in sync across platforms.
562
563πŸ€– **Where manual verification happens is a per-developer preference β€” ask
564if you don't know it** (Paul, 2026-07-16). Whether a session verifies
565features by hand against the full stack (a per-session `test-<slug>.11ch.at`
566canary or the dev environment) or against a hand-run local server is not a
567project mandate β€” it's each developer's stored preference. One developer may
568always want a live canary, another may always want localhost; the system
569doesn't care. Each developer records theirs in their own `CLAUDE.local.md`;
570if the prompter's preference isn't already recorded, **ask them** before
571manual testing starts, then save the answer there so no session asks twice.
572Know what the choice trades: localhost skips the router, real TLS, push, the
573home server, and every cross-service seam. **Local testing can close most of
574that gap by riding the relay** (`lchat try` β€” the tunnel gateway fronts the
575local 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 β€”
577the accepted middle ground when a local-preference developer needs a real
578origin, or a full-stack developer needs to test unmerged code. For a
579full-stack developer, a feature touching the singleton services (home, push,
580cloud) that can't canary today is **held for the dev environment**, not
581localhost-tested in the meantime. The automated suites are unaffected either
582way (`go test`'s httptest servers, `npm test`, the hermetic E2E rigs β€” those
583are tests, not testing).
584
585## Deploy
586
587Deployed as a single static binary per chat. Each chat is a **fully-isolated
588instance** β€” its own process, DB, admin token, and subdomain β€” supervised by
589systemd behind the instance router (`docs/instance-router.md`); shipping is
590via the signed release channels (`docs/server-releases.md`). The operator
591entry point for all of it is **`./deploy/ops`** (`deploy/README.md Β§ The
592operator surface`). The `Dockerfile` exists for self-hosters on container
593platforms (README Β§ Docker); nothing in our production runs in Docker (the
594original 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
603Principles 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
640Instances are disposable β€” wildcard DNS covers the canary domains, so
641creating 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
643members live on, and they'd eat its instance cap. The default is the
644Helsinki dev box (`fi.dev.11ch.at` β€” amd64, matching `build-linux.sh`);
645targeting production (`LCHAT_CANARY_BOX=elevenbox LCHAT_CANARY_DOMAIN=11ch.at`)
646is a deliberate, stated exception. One dev caveat: no wildcard cert there by
647design, so each dev canary burns a per-host HTTP-01 cert from the shared
64850/week 11ch.at budget β€” fine at canary cadence. So instead of sharing one
649`test` instance across parallel sessions (they trample each other):
650
651The encoded path is `./deploy/ops canary create|deploy|destroy <slug>` β€”
652each verb enforces the rules below, so prefer it over hand-run `lchat`
653commands. 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
682Simple 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 alt [slug] # Build + launch a side-by-side dev alt of the Mac app
690./tools/dev up [flags] # Start local dev server (persistent config)
691 # + the webhook-ingest sidecar (--no-hooks skips)
692 # flags pass to lchat-dev-up: --clean, --onboarding,
693 # --port N, --help
694./tools/dev seed-mac # Seed Mac app with dev server credentials
695./tools/dev link # Print dev server sign-in link
696./tools/dev test # Run full test suite (Go + Node)
697./tools/dev test web # Run web tests only
698./tools/dev test go [args] # Run Go tests only (args go to `go test`,
699 # e.g. -run TestFoo ./internal/bot)
700./tools/dev shots <verb> # Marketing-screenshot kit (docs/marketing-screenshots.md):
701 # up build the isolated demo Mac app (if needed) +
702 # run a throwaway local server seeded with the
703 # fictional "The Commons" space (foreground)
704 # tunnel give it a public https try-*.11relay.com address
705 # mac launch the isolated "Eleven Demo" Mac app
706 # web-check <png> headless 2x screenshot of the seeded space
707```
708
709All 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.
710
711πŸ€– **Dev alts (`./tools/dev alt [slug] [--icon live]`)** build the same LChatMac
712target under an
713alternate identity β€” bundle id `…messenger.mac.alt-<slug>`, name "Eleven Dev",
714the orange dev icon (`AppIconDev`; `--icon live` picks the green `AppIconDevLive`
715β€” the convention for an alt aimed at PRODUCTION home, so two alts with
716different aims never share an icon; regenerate either with
717`swift tools/make-dev-icon.swift [live]`),
718Sparkle auto-checks off β€” so a dev build runs **beside** the Sparkle-updated app
719instead of replacing it. The keychain access group is unchanged, so an alt sees
720the signed-in spaces: **it's the real account, not a sandbox** (and while it
721runs you'll get doubled notification banners β€” quit it when done). One running
722app per slug; parallel sessions pick different slugs (`dev`, `dev2`, …) the way
723web canaries pick subdomains. Debug builds can aim at the dev home server:
724`defaults write <bundle id> home_base_override https://home.dev.elevenmessenger.com`
725(or env `LCHAT_HOME_BASE`; LChatCore `HomeBase.swift` β€” Release compiles it out).
726Never install an alt (or any Debug build) to
727/Applications β€” that's how a stale dev build ends up masquerading as the
728released app.
729
730πŸ€– **Marketing screenshots (`./tools/dev shots <verb>`)** stand up a
731**fictional** demo space ("The Commons") for capturing the native Mac +
732iPhone shots in the website hero collage β€” the committed form of a
733capture recipe that used to live only in agent memory. Implementation in
734`tools/marketing-shots/`; full process + the WHY in
735`docs/marketing-screenshots.md`. `shots up` builds the isolated demo Mac app
736(if needed) and runs a throwaway local server seeded through the real E2EE
737protocol; `shots tunnel` fronts it at a public `try-*.11relay.com` origin for
738the native apps; `shots mac` launches it; `shots web-check <png>` grabs a
739headless 2Γ— screenshot to eyeball the look. **Isolation is the point:** unlike
740a normal build or a dev alt (both share the `…messenger.shared`
741keychain-access-group, so they read the *real* signed-in spaces β€” an alt has
742grabbed Paul's private space before), the demo Mac build is signed with the
743minimal `LChatMac-Demo.entitlements` (no shared keychain β†’ its own empty
744keychain), a distinct `…mac.capdemo` bundle id, applied purely as
745**command-line build-setting overrides** so `project.yml` is never touched and
746a plain `./tools/dev build mac` stays the real app. Interactive Mac/iPhone
747captures stay **manual** (the tool stands up the demo + points you at the doc).
748
749Typical workflow:
750
751```sh
752./tools/dev up # 1. Start dev server in one terminal
753./tools/dev seed-mac # 2. Seed Mac app with dev server credentials
754./tools/dev run mac # 3. Build (if needed) and launch it
755```
756
757## Branches, commits & PRs
758
759- **Prefer small, focused PRs that ship incrementally.** When iterating, slice
760 work into the smallest change that stands on its own and land it, rather than
761 batching a large feature into one big PR. Small PRs review faster, merge with
762 fewer conflicts (several land in parallel), and are safer to deploy and revert.
763 Split a big idea into a sequence of shippable steps.
764- **Work on a feature branch β€” don't commit straight to `main`.** Several PRs
765 land in parallel, so pushing directly to `main` causes push races and forced
766 rebases. Branch per feature (e.g. `reply-to-messages`), open a PR, and
767 **squash-merge** into `main` (`gh pr merge --squash --delete-branch`).
768- **Always do PR work in a git worktree**, not the main checkout. Several
769 sessions share this checkout, so editing in place trips over concurrent work;
770 a worktree keeps each PR isolated until it merges.
771- **Multiple commits per branch are fine** β€” they squash into one clean commit on
772 `main`, so iterate freely. Within a branch, prefix commits by area: `web:`,
773 `server:`, `admin:`, `sw:`, `crypto:`, `docs:`.
774- End commit and PR-body messages with the `Co-Authored-By:` trailer for the
775 assisting model. (Commit author is the repo's local git config β€” see
776 `CLAUDE.local.md`.)
777- **Deploy from `main` after the squash-merge** (pull `main`, then redeploy β€” see
778 Deploy), never from a feature branch. Branch / commit / PR / merge / deploy
779 only when asked.
780