eleven / ocho Public
π€ Bots (agents) β design
Status: the sidecar exists and runs. The default is the Go sidecar (lchat bot run, part of the single binary β see Direction: a Go sidecar below, now shipped); the Node sidecar (agent/) is kept as the runnable reference. Beyond the "Direction" and "What exists today" sections, the rest is proposed direction captured from design discussion, not built. This doc is the single place for where we landed.
What a bot is
A bot is a normal member driven by code β nothing more privileged at the protocol level. It has:
- an identity: an ECDH keypair, a device token, a
usersrow withis_agent = 1, and a roster label + (optional) avatar. One provision = one identity. - a personality/behaviour: a system prompt, a model, and how it's triggered. This is config, not code.
Two layers, and the split is the whole governance story:
| Layer | What it is | Who owns it | |---|---|---| | Engine | the running process, the API key, the host, and which threads the bot is in | admin/operator β this is where all trust concentrates | | Behaviour | system prompt + triggerβaction recipes | admin, or users (within bounds) |
The one rule still holds: the server stays blind
A bot is a client, exactly like a browser. It holds its own private key and the thread keys wrapped to it; it decrypts and calls the model off the lchat server. The server only ever sees ciphertext.
The deliberate, disclosed trade-off: to generate a reply the bot sends decrypted messages to the model provider (Anthropic). That's leakage to the provider, not between users, and only a local model would close it. Say so in the UI where a bot is added.
Two consequences to design around:
- The bot's host is a high-value target. A bot is deliberately a member of many private threads, so its host transiently holds all their plaintext + keys. Compromise of the host = read every thread that host's bots are in. Mitigate: one sidecar per instance, minimise threads per bot, harden the host, keep no ambient prod credentials on it β and above all, run the sidecar somewhere better defended than the chat server (next section).
- No forward secrecy on removal β removing a bot doesn't retroactively protect past messages, same as removing a human today. Future hardening that would bound both consequences: rotate the thread key when a bot joins or leaves, so a stolen bot key decrypts only the bot's membership window, not the thread's whole history.
The sidecar runs anywhere: it's a pure outbound client
The sidecar never accepts a connection. It dials out to exactly two places β the instance (HTTPS/WSS) and the Anthropic API β and nothing ever dials it: no inbound ports, no webhooks, no route. The lchat server has zero knowledge of where it runs (a bot is just a member on normal endpoints). That makes the bot host a free variable in the threat model:
- Same box as the instance (the default today, as separate systemd units): convenient, but it puts the bot's keys on the most exposed machine in the system. Root on the chat server reads every bot thread.
- Any other machine the operator trusts: a home server behind NAT, a workstation, a Raspberry Pi. Root on the chat server then yields ciphertext and nothing else β the full "server compromise gets nothing" guarantee is restored, because the bot's keys sit on a box the attacker can't route to.
- A private network with no inbound at all: NAT-only, egress allowlisted to the instance +
api.anthropic.com. The bot machine's attack surface shrinks to supply chain and the message content it processes. For a no-public-egress deployment, Claude is also reachable via AWS Bedrock / GCP Vertex over private endpoints β and a write-only changelog watcher needs no model API at all.
The honest framing (for consent copy too): a bot is a member whose "device" is a server, and adding one to a thread extends that thread's E2E envelope to the machine the operator chose. Where that machine lives is the single biggest security decision the operator makes about bots.
One wrinkle of being a persistent outbound client: on a hibernating instance (the activator, activator.go), an always-on sidecar would either hold the instance awake forever or wake it right back on every reconnect. So there the sidecar is co-spawned β started by the activator when its instance wakes, stopped first when it sleeps β and every request the Go sidecar makes carries a marker header the router excludes from the instance's idle clock (internal/bot/sidecarmark.go). None of this changes the trust model: the sidecar is still its own process with its own keys, still outbound- only. See docs/instance-router.md Β§ Sidecars on hibernating instances.
Direction: a Go sidecar (decided 2026-07-06, shipped 2026-07-07)
The sidecar was ported to Go β a subcommand of the single binary (lchat bot run), run as its own process with its own env (never inside the server process: the bot holds private keys + the Anthropic key, and folding it into the server would collapse the trust boundary above). It is now the default; the full status is below.
The Node sidecar (agent/) is kept in-repo on purpose β a supported, runnable reference implementation, not deprecated and not scheduled for removal. It's the worked example of how to run a bot in Node if you'd rather, and (because it imports web/static/crypto.js verbatim) the clearest illustration of the exact E2EE wire format a client speaks. Keep it working alongside the Go port; the Go tests pin themselves to these JS sources (bot-commands.js, actions.mjs, eleven.mjs, the shared knowledge.md), so the reference actively guards the port against drift.
Why:
- Kills the Node runtime dependency β today the router box needs Node 24 +
npm ci+ a separate rsync deploy path solely for bots. - Makes "run it anywhere" real: copying one static binary to a Raspberry Pi or a locked-down VPC box is a far better story than standing up Node there.
- Nothing in the sidecar is Node-specific:
wsβcoder/websocket(already a dependency),@anthropic-ai/sdkβ the official Go SDK (which also speaks Bedrock/Vertex), and the crypto is ECDH P-256 + HKDF + AES-GCM, all stdlib.
The one real cost: Go must reimplement crypto.js's wire format (ECIES seal/wrap, iv||ciphertext base64url) byte-for-byte and stay in lockstep β the Node sidecar gets that for free by importing the file. The native-app work is already committed to publishing cross-language crypto test vectors for its Swift port; the Go sidecar becomes the second consumer of the same fixture set. Incremental path: port the Announcer first (write-only, no model API β exercises provision/connect/encrypt with the least surface), then the shared runtime, then Eleven's persona layer.
Status: the crypto port is landed. internal/e2ee mirrors crypto.js (AES-GCM blobs, ECIES wrap/seal/open, invite derivation, WebCrypto-compatible raw/pkcs8 keypairs), pinned by internal/e2ee/testdata/vectors.json β fixtures generated from crypto.js itself by test/gen-e2ee-vectors.mjs (regenerate them after any deliberate crypto.js change and re-run go test ./internal/e2ee). Provisioning is landed too: lchat bot provision (internal/bot + botcmd.go) claims an agent invite headlessly β the invite secret comes from LCHAT_INVITE in the env, never argv β and writes a session file byte-compatible with the Node sidecar's session.json, so either implementation can take over the other's identity in place. And the changelog engine (which powered the Announcer until its retirement β see below): it baselines silently on first run, batches new changelog entries into one encrypted post per thread, and self-declares kind bot; today it runs only under an explicit recipe's changelog rule (docs/bot-recipes.md). And the live runtime: lchat bot run (internal/bot/runtime.go) is the persona-agnostic core of runtime.mjs in Go β WebSocket with reconnect/backoff/keepalive, thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip, never-reply-to-agents, DM-always / group-@mention triggers β with the brain behind a single Handler seam. Today that seam holds the deterministic command floor (internal/bot/commands.go, the Go twin of commands.mjs; drift-pinned to web/static/bot-commands.js by a test), so a keyless Go bot answers exactly like a keyless Node one. One process hosts many identities (one session file per bot on the command line), and one deliberate divergence: the Go runtime answers the first message of a newly-seen thread (Node baselines the cursor past it and stays silent). And recipes: the runtime is recipe-shaped (internal/bot/recipe.go, schema + rationale in docs/bot-recipes.md) β behaviour as an ordered triggerβaction rule list from a bounded palette (message Γ trigger/any/keyword/regex β commands/reply/model; changelog β announce), validated fail-closed, one <session>.recipe.json per identity, watcher rules loudly flagged. And the model layer: brain.go is the Claude-backed reply path (internal/bot/anthropic.go, a minimal net/http Messages client β no new dependency) reached via the recipe model action. Set ANTHROPIC_API_KEY and the bot self-declares kind ai and upgrades from the command floor to conversational replies β same layered system prompt (non-overridable safety preamble β console persona β fellow-helpers line β docs), cache-marked system block, adaptive thinking, the chip tools (drift-pinned to actions.mjs), the promised-button forced-retry self-heal, and the listen-mode "name" classifier gate β all as in runtime.mjs. And connected-services (internal/bot/connect.go + dnsimple.go): the capability ops handleConnect / handleOAuthExchange are ported β a member seals a time-boxed grant to the bot's key, the bot opens it with its own private key (a grant sealed to a different bot won't open, so it stays silent), lists DNSimple domains or runs the confidential OAuth exchange via the shared broker, and seals the result back to the member. The Go sidecar is now a full drop-in for the Node one: everything the shipped bots do β the deterministic floor, conversational Eleven/Claude, connected-services, and the changelog engine β runs from the single lchat binary with no Node. The one thing deliberately not in the sidecar is Eleven's onboarding welcomer, which is moving client-side as data (docs/conversation-scripts.md). agent/ stays as the reference implementation; session/state/recipe files are Node-compatible so either can take over the other's identity in place.
What exists today (agent/)
A headless Node sidecar that behaves like a member's browser:
- imports
web/static/crypto.jsverbatim (runs unmodified in Node), so its wire format is guaranteed to match the app. provision.mjsβ one-time headlessjoin.js: derive invite id from the#fragment, generate a keypair, claim a device token, register the pubkey βsession.json(the bot's identity + private key; lives only on the host).agent.mjsβ connect/ws, unwrap thread keys, on each incomingmsgdecrypt, build context from the thread's recent history, call Claude (claude-opus-4-8, adaptive thinking), post an encrypted reply. DM β replies to everything; group β only on @mention by default. A per-bot listen mode (consolebot_configs.listen) can widen that toname, where a bare-name mention also replies β but only if a cheap Haiku yes/no call judges the bot is actually being addressed (talking about a bot doesn't summon it). Reconnects with backoff; picks up new threads on first unknownthread_id.- Server + web: additive
users.is_agentcolumn, rosterBOTtag, and a dedicated admin "Add an agent" section (distinct from inviting a person) that captures model + system prompt + reference docs and stores them in abot_configstable. The sidecar pulls its own config from/api/bot-configand applies it β a non-overridable safety/app-awareness preamble is always prepended, then the console-set persona, then the docs. - Still design, not built: editing an existing bot's config from the console; wrapping the config to the sidecar's key so the server can't read prompts/docs; and the ambient watcher read scope. The form now captures persona/model/ docs, the catalog fields (description + availability), and the listen mode (@mention-only, the default, vs. bare-name).
Limits today: reactive only (no scheduler, can't self-initiate β so it cannot remind you, and may falsely promise to); short-term memory only (re-reads the last ~30 messages of the thread per reply β nothing persisted); no tools. One behaviour is still interim vs. the target below: in a group it reads recent history to compose a reply (target: mention-scoped).
One sidecar per instance, all bots in it
Each instance runs exactly one sidecar β a separate headless process, never part of the server process. That single sidecar drives all of the instance's bots (default + custom): each bot is its own member identity with its own WebSocket connection, but one process, one host, one API key.
- Env:
LCHAT_BASE+ANTHROPIC_API_KEYβ the sidecar's own deploy env, the same channel as the app's operator secrets. The lchat server never sees the key. Per-bot config comes from the admin console (next section). - A small data dir holds the sidecar's keystore β one keypair per bot β so a redeploy doesn't re-provision. Private keys never leave the host.
- The sidecar auto-provisions each configured-but-new bot: generate a keypair, register its pubkey, mark it
is_agent, store the private key locally.
Deploy
On the fleet, lchat add-bot <host> provisions the identities and the lchat-bot@<host> systemd unit runs lchat bot run (the Go sidecar inside the instance's own versioned binary) with env in /data/<host>-bot.env and sessions in /data/<host>-bot/. The Node reference sidecar (agent/) runs the same way anywhere Node does: point LCHAT_BASE at the instance, feed it the agent invites, run node agent/agent.mjs. (The original deployment shape β a per-instance container app on the Miren platform, Dockerfile.agent β was retired 2026-07-15 along with that platform; the sidecar has no inbound ports, so any box that can dial out can host it.)
Configuration & control
Split config by what it is:
The Anthropic API key β the sidecar's env (ANTHROPIC_API_KEY, in /data/<host>-bot.env on the fleet), the same channel as the app's operator secrets (LCHAT_ADMIN_TOKEN, LCHAT_DB). Whoever deploys the sidecar is the operator; they set it at deploy and rotate it by updating env. Crucially the lchat server never touches the key in any form β no store-key endpoint, no encryption dance. That's simpler than routing it through the console and strictly better for "server blind": the server has zero involvement with the key, rather than holding a wrapped blob of it. (An earlier draft put the key in the console encrypted to the sidecar β that over-solved a problem you avoid entirely by not routing the key through the app.)
Bot config β the admin console (name, avatar, model, system prompt, reference docs, read-scope, availability) β the thing an admin actually wants a UI for. Stored server-side, pulled by the sidecar. If you want prompts/docs hidden from the server too, wrap those to the sidecar's key (the key itself is already out of the picture, in env). For a first version, bot config can even be a sidecar-side file and skip the console.
Once the key is set, a few default bots exist β e.g. a friendly general helper and a thread summariser (kid-safe, conservative). They're examples: fully editable or removable.
Adding / editing a bot is a console form:
| Field | Notes | |---|---| | Name + avatar | shown in the Helpers catalog with the BOT badge | | Model | Opus / Sonnet / Haiku per bot | | System prompt | the personality β layered on a non-overridable kid-safety preamble | | Reference docs | context the bot should always have (instructions, house rules, an FAQ); included in its context, with retrieval for large docs later | | Read scope / trigger | mention-only (default) vs. ambient watcher β see next section | | Available to members | whether it appears in the Helpers catalog (operator-only bots stay hidden) |
Catalog metadata that member browsers must render (name, avatar, description, availability) is readable by members β stored like roster labels, or wrapped to members via the instance key if labels are kept from the server.
Is the sidecar active and working?
The instance needs no knowledge of the sidecar to function β a bot is just a member on normal endpoints, which is what keeps the server dumb and the sidecar swappable. So skip a static SIDECAR_ACTIVE env flag: it encodes intent, not reality (it reads true while the sidecar is crashed β the exact false reassurance to avoid). Derive status from live facts the server already has β it sees whether each bot member is connected, and it holds the bot config for the catalog, so "configured vs. actually connected" needs no new state:
- Connected / last-seen per bot, read from the hub (it already tracks live clients by user id). A small
GET /api/admin/botspowers a "Bots" panel. - Heartbeat for liveness beyond a bare socket (reuse the existing
activebeat or a periodic ping) β "last heard 20s ago", so a wedged-but-connected sidecar is distinguishable from a healthy one. - A "Test bot" button (like the existing test-push): DM the bot a ping and confirm a reply. This is the real end-to-end check β and it validates the key, since a bot that's connected but can't reply almost always means a missing or invalid
ANTHROPIC_API_KEY. The sidecar can report a status string (model ok/auth error) to make the cause explicit.
Whether to offer "Add a bot" at all can't come from bot config alone β before the first bot there is none. In practice it comes for free from the default bots the sidecar seeds on first boot: their appearance (agent users the server didn't create) is the "a sidecar lives here" signal. Two conditions keep that honest:
- The sidecar must seed the defaults, not the server β otherwise it's circular.
- Record the capability sticky the first time an agent user appears, so the offer survives the admin later deleting every bot (don't gate purely on "are there bots right now").
A lightweight boot heartbeat is then only belt-and-braces β for the sidecar-present-but-zero-bots edge, and for live health. So a manual SIDECAR_ACTIVE env boolean stays the wrong tool: a signal derived from the sidecar actually being there can't drift the way a hand-set flag can (set true, deploy fails, UI offers bots that can't run). Net: capability is sticky (a sidecar has registered β show the UI); health is live (connected + recent heartbeat β green/red). Both drive admin UX only β bots still function with zero server awareness.
How much a bot reads in a group
> Decision (2026-07-02): recent-history context stays. In practice the > context is what makes replies good β a bot answering "@Claude is Bob right?" > without the surrounding conversation is useless β so a mention-TRIGGERED bot > reading recent thread history (what's built: ~30 recent messages at reply > time) is the accepted behaviour, and the mention-only ingestion described > below is NOT planned. Be honest about it in consent copy: adding a bot to a > group means it reads recent messages whenever it's called on. Watchers > (below) remain the distinct, heavier-consent mode: they process EVERY > message, not just when summoned. The original design rationale is kept for > the record:
Original design default (superseded): a group bot does NOT read the whole conversation. It ingests only messages addressed to it β those that @mention it β plus, when a mention is a reply, the single message being replied to (so "@Claude is Bob right?" gives it Bob's message to answer). Its context in a group is just its own sub-conversation: mentions of it, the parents those replies point to, and its own past replies β never the general chatter.
Why this is the default:
- Privacy + consent. A mention-only bot is far less invasive than one reading everything, so adding it to a group is a light decision β and it minimises what leaves the E2E envelope to reach the model provider.
- Safe-for-kids. Give the bot the least it needs to do its job.
Honest limit: the bot is a thread member and holds the thread key, so "only reads mentions" is a policy in the sidecar, not a cryptographic guarantee β it could decrypt the rest; it chooses not to, and the code is small and auditable. A cryptographic "cannot read the rest" would mean not giving the bot the thread key and instead re-encrypting each mention (and its replied-to parent) to the bot β stronger, but it complicates how the bot posts a group-readable reply. Flagged as possible future hardening, not v1.
Ambient / watcher bots are the explicit exception. Summaries ("what did we decide?") and keyword nudges (the inclusivity bot) genuinely need every message. That's a distinct, clearly-labelled mode: a "reads every message in this group" flag, heavier consent, and a visible "a helper is watching for X" notice. So the consent gate now means something β most bots are mention-only and cheap to add; only the few that must read everything carry the loud flag.
A watcher's detection is JS in the sidecar, not a Claude tool β a trigger that gates whether a message reaches the model. So there are two layers of exposure, which is the honest reading of "reads every message":
- The sidecar decrypts and matches every message (
/\bguys?\b/i) β this is the host-side exposure the consent flag is about, policy-enforced not cryptographic. - The model provider sees only matches β non-matching chatter never leaves the sidecar, so provider cost/exposure stay scoped to hits.
The action on a match is a choice: a fixed template (post/DM a pre-written nudge β no model call, deterministic, and often best for a nudge) or an LLM call (phrase it in context). Trigger = JS; action = template or LLM. This is the triggerβaction recipe, and it's separate from Claude tool-use (the dev-bot lane).
> Recipes have a sibling schema: docs/conversation-scripts.md β Eleven's > deterministic onboarding flows as data, interpreted client-side (needed for > the native apps). Same chip/bubble message shapes, deliberately not one > unified behaviour language. When the Go sidecar runtime is built > (Β§ Direction below), it should be recipe-shaped from day one β an engine > executing config β so declarative bots don't require a second rewrite.
Where bots earn their keep: groups, not DMs
The moat is shared context, which a standalone Claude/ChatGPT structurally can't have. A DM bot is a worse-UX version of tools people already have; a bot in a group can see and act on the conversation this group is having (summarise a thread, answer "what did we decide?", watch for a keyword). Invest here.
The exception that keeps DMs worthwhile: for kids, an in-app DM companion's value is precisely that the alternative β unsupervised ChatGPT β is what you're preventing. Treat DMs as the safe sandbox + proactive/reminder lane, not the differentiator.
Implications:
- Build group-native, ambient behaviours (summarise / answer-from-thread / watch-and-nudge), not a better DM chat loop.
- Group-scoped memory is both the most valuable and the safest kind. The cross-user-leak risk of bot memory disappears when memory is scoped to a group, because everyone who'd read it already shares the thread. Memory scope follows read scope: a mention-only bot remembers its own exchanges; an ambient watcher can remember the whole thread. Build memory for groups first.
Discovery under the connections-only roster
Members now see connections(userID) = self + people they share a thread with β not the full directory (db.go connections()). A new bot shares no thread, so it would be invisible. Bots therefore cannot ride the roster for discovery.
Resolution: a separate, admin-curated "Helpers" catalog, exempt from the connections rule. People stay connections-only (privacy β no seeing strangers); bots are intentional, vetted helpers, so the admin's members-available set is shown to everyone regardless of connection. Once a member adds/DMs a bot, it becomes a normal shared-thread connection and appears in their usual list β no special-casing after that.
Surface it as a "Helpers" section in the picker (distinct from People β reinforces "not a person"), each entry = name + avatar + one-line description. The description doubles as the consent hint. Weight it toward groups: the prominent affordance is "Add a helper" in the group create / add-member flow (where the value is); "DM a helper" is the secondary sandbox lane. Show only bots the admin marked available (see governance).
Built (helpers-catalog): bot_configs gained description + available_to_members (set from the admin's "Add an agent" form, availability default-on there, off in the schema). connections() unions in agents matching is_agent = 1 AND available_to_members = 1 that have registered a public key β that one exemption is the catalog; the client just renders roster entries with is_agent under a "Helpers" heading (new-chat and add-to-group pickers), with the description as the row's second line. The open decision on name/description storage was resolved toward in clear, consistent with roster labels today β revisit both together if labels ever move under the instance key.
Governance
Mirror the existing Users can invite members with Users can create bots β but it's not a 1:1 analogy, because a bot carries an API key, compute, and read-scope a member invite never does.
The setting governs behaviour on the admin's engine, tiered by read-scope:
- Default off. On kids-facing instances, keep it off (loosen only as opt-in).
- *ON β members may create personal bots freely* (a bot in their own DM): low risk, only their own data.
- Group-watching bots always need group/admin consent β even when the toggle is on. Reading a shared room is a shared decision; one member can't silently bug it. This gate is separate from the toggle and never removed by it.
- Capabilities come from an admin-approved recipe palette (reply / nudge / remind / summarise / keyword-watch) β declarative triggerβaction, never arbitrary tools or the build/deploy lane.
Controls the invite toggle never needed, because user bots spend shared resources: per-user caps, rate limits, a kill switch, and admin visibility into which user bots exist. Plus a per-bot available_to_members flag (operator-only bots β e.g. the dev bot below β never appear in the catalog).
Creating bots in plain English
Yes β but as a front-end to the same bounded recipe, not a code generator. A "Describe a bot" box takes natural language ("watch for 'guys' and gently remind people to be inclusive"); an LLM builder translates it (structured output) into the config schema β name, system prompt, model, trigger, action, read-scope. Plain English is just another way to fill the form; both emit the same config, so build the schema + form first and layer the builder on top.
Guardrails that keep it inside the boundary:
- Translate into the supported palette only β allowed triggers (keyword/regex/mention/schedule) and actions (reply / DM-nudge / summarise / template-or-LLM). An out-of-palette request ("delete messages", "email people", "deploy code") gets "I can't do that yet", never invented. The plain-English layer is UX, never a capability escalation.
- Review-and-confirm before it goes live. Show the derived config back in plain terms β "This bot will read every message in the group and privately DM anyone who says 'guys' a reminder." This is both the did-I-get-it-right check and the consent moment: the builder infers read-scope from the request (watcher β reads everything; Q&A-on-mention β mention-only) and must surface it, since the user didn't ask for it explicitly.
- Same governance + consent tiers. A personal DM bot is light; a described group watcher still hits the group/admin consent gate (driven by the derived read-scope). The kid-safety preamble is layered on regardless of the user's words, and the builder declines malicious intents ("watch for X and mock them").
- A small one-off LLM call on the instance key, covered by the per-user caps. Editing works the same way ("also watch for 'dude'", "make it friendlier") β diff β confirm.
Capability tiers (roadmap)
- Chat responder β exists. DM/mention reply.
- Watcher recipes β group-native, the differentiated layer. A trigger (keyword/regex/schedule/mention) + an action (reply / private-DM nudge / summarise). The inclusivity-nudge bot ("watch for
guy, gently DM the sender") is the template β detection is cheap string-matching; the model only phrases the nudge. Reads the whole group β consent + transparency required. - Memory & reminders β a
set_remindertool + a per-thread/per-group, host-side, encrypted store + a scheduler that posts proactively (rides the app's existing web push for delivery). Scope memory per-thread/group, never global-per-bot. - Operator-only dev bot β tools (bash/git/gh/miren) to build features. Technically just an agent tool-use loop, but it's the highest-risk thing here: chat input is untrusted, and a bot with GitHub push + deploy rights holds the exact credentials that protect everyone's E2EE. Rules: separate operator-only identity, never in a family/kids thread; bots draft, humans ship (PR, not direct deploy); least-privilege scoped creds; sandboxed worktree; authenticate the requester. Managed Agents is the purpose-built substrate (sandbox +
always_askgates + git proxy). Never user-grantable.
Consent, transparency, safety invariants
- Group default is mention-only (bot reads only messages @-ing it + a mention's replied-to parent). Reading the whole group is the loud, opt-in watcher exception.
- Adding a group-watcher is the consent moment β surface what it does and that it reads every message.
- Bots always wear the
BOTbadge; consider a "a helper is watching for X" notice in a watched group. - Cross-user isolation invariant: one model request = one thread's decrypted history, nothing else. The shared API key is not a leak vector (stateless, per-request). Never give a bot shared, cross-thread memory. Prompt caching is safe (prefix match returns your own tokens).
Open questions
- Catalog metadata: clear (like roster labels today) vs encrypted to members.
- Miren routeless worker vs health endpoint.
- Reminder scheduler: in-sidecar timer vs the platform cron.
- Kids-instance defaults: personal bots off entirely, or on with a curated recipe palette only.
- Per-bot model policy (cost): who may pick Opus vs Sonnet vs Haiku.
The Announcer is retired
The third default identity β the write-only Announcer, which posted release notes into every group it was added to β is retired. The app now announces its own updates with no bot in the loop, so it works on instances that run no sidecar at all: on startup the server diffs its embedded changelog against a watermark and notifies the space's admins (a Web Push / APNs banner, announce.go), and the notes live in the Admin app's update stream (a read-only chat view). Members no longer see release notes in their groups β updates are the admins' concern.
Existing deployments: the new lchat bot run skips a session named Announcer (running it under the default recipe would turn the old write-only bot into a responder), so a pre-retirement EnvironmentFile keeps working. Remove the Announcer member from the roster (admin console β members) whenever convenient; its session/state files on the sidecar machine can be deleted. An operator who wants group-posted release notes can still run a custom bot with a changelog recipe rule (docs/bot-recipes.md).