eleven / ocho Public

docs: bots run anywhere outbound-only — trust model + Go sidecar direction (#261)

A bot is a member whose "device" is a machine the operator chooses; the
sidecar is a pure outbound client (no inbound ports), so running it off the
chat server — home NAT, or a no-inbound VPC with egress allowlisted to the
instance + model API — restores the full "server compromise gets nothing"
guarantee. Record the decision to port the sidecar to Go (lchat bot
subcommand), keeping agent/ as the Node reference implementation.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bbef5bed16f0549fb20ca8f7aa1d4aea261915bc parent 3df2b53
4 files changed, +115 −2
  • CLAUDE.md +16 −0
  • README.md +11 −0
  • agent/README.md +28 −0
  • docs/bots.md +60 −2
diff --git a/CLAUDE.md b/CLAUDE.md
index 21a296c..393c539 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,6 +43,17 @@ Save-passkey card — so they can't be E2E-encrypted like profile/group photos.
Keep branding to this narrow set; anything that is *user* content still gets
encrypted client-side.
+**Bots** are the rule's scoped, disclosed exception-by-membership: a bot is an
+ordinary E2EE member whose "device" is a machine the operator runs (`agent/`),
+so that machine holds the bot's private key and can decrypt exactly the threads
+the bot is in — never anything else, and the lchat *server process* still never
+holds a usable key. Two invariants follow: **bot code never runs inside the
+server process** (compromising the server must not yield bot keys or the
+Anthropic key), and the sidecar stays a **pure outbound client** (no inbound
+ports), so operators can move it to a better-defended machine — off-box, behind
+NAT, or in a no-inbound private network. Trust model + the planned Go port (a
+`lchat bot` subcommand; `agent/` remains the Node reference): `docs/bots.md`.
+
Native iOS push goes through a central APNs relay (`push.elevenmessenger.com`,
`push/`) so the Apple `.p8` signing key lives in exactly one place instead of on
every instance. Like the admin token, the `.p8` is *auth*, not a decryption key:
@@ -72,6 +83,11 @@ in, so deploy is one file (no cgo).
**Authoring guide: `web/static/apps/README.md`** — read it before writing or
changing a mini-app.
- `push.go` — Web Push (self-hosted VAPID, `SherClockHolmes/webpush-go`).
+- `agent/` — the bot sidecar (Claude / Eleven / Announcer): a separate headless
+ Node process that is just another E2EE member; holds bot private keys + the
+ `ANTHROPIC_API_KEY`, outbound-only, runs on any machine (see `docs/bots.md`).
+ A Go port (`lchat bot` subcommand) is the planned default; the Node version
+ stays as the reference implementation.
- `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache).
- `web/pages/*.html` — served with `__GROUP_NAME__` substituted (HTML-escaped).
- `web/static/*` — vanilla ES modules, **no build step**, served via `go:embed`
diff --git a/README.md b/README.md
index 5286095..139c373 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,17 @@ see Trade-offs.)
guarantee above holds), but an *active* malicious server could substitute a
public key to intercept *new* conversations. Out-of-band key verification is
planned to close this.
+- **A bot is a member whose "device" is a machine you choose.** Adding a bot
+ (see `agent/`) to a thread hands that thread's key to the machine the bot
+ runs on — so whoever controls that machine holds the key to those
+ conversations, exactly as if they were a member. Threads without a bot are
+ untouched. Crucially, the bot doesn't have to live on the chat server: it's a
+ pure outbound client (it dials the instance and the model API; nothing ever
+ dials it — no open ports), so you can run it on any machine you already
+ trust — a home server behind your router, or a box on a private network
+ whose only way out is to your instance and the model API. Run it there and a
+ fully compromised chat server still yields nothing: the keys are on a
+ machine an attacker can't even reach.
## Run it
diff --git a/agent/README.md b/agent/README.md
index 8d29f29..0ee31b9 100644
--- a/agent/README.md
+++ b/agent/README.md
@@ -6,6 +6,11 @@ ever sees ciphertext** — private keys and your `ANTHROPIC_API_KEY` live only o
this host. It shares `web/static/crypto.js` verbatim (imported directly), so
its wire format is guaranteed to match the app.
+> This Node sidecar is the **reference implementation**. A Go port (a `lchat
+> bot` subcommand — one static binary, no Node runtime) is the planned default;
+> see `docs/bots.md § Direction: a Go sidecar`. The Node version stays supported
+> as a working example for self-hosters.
+
| agent | what it does |
|---|---|
| **Claude** | The plain assistant: replies to every DM; in groups only when @mentioned (the default listen mode — the console can widen a bot to also answer its bare name, gated by a cheap "am I being addressed?" classifier). (`runtime.mjs`, identity via `LCHAT_SESSION`/`LCHAT_INVITE`.) |
@@ -51,6 +56,29 @@ an individual bot's listen mode to `name`, where a message that just uses its
name also gets a reply — but only when a small classifier call agrees the bot is
being spoken *to* rather than *about*, so chatting about a bot never summons it.
+## Where to run it
+
+Anywhere. The sidecar is a **pure outbound client** — it dials the instance
+(HTTPS/WSS) and the Anthropic API, and nothing ever dials it: no inbound
+ports, no webhooks, no public address. The server doesn't know or care where
+it runs.
+
+That choice is the biggest security decision you make about bots, because the
+bot's private keys unlock every thread it's a member of:
+
+- **On the instance box** (the hosted default): convenient, but a compromise of
+ the chat server also reads the bot's threads.
+- **On a machine you trust more** — a home server behind NAT, a spare box, a
+ Raspberry Pi: now a fully compromised chat server yields only ciphertext,
+ same as for human members. The attacker can't even route to the keys.
+- **Inside a private network with no inbound at all**, egress allowlisted to
+ the instance + `api.anthropic.com` (or Claude via Bedrock/Vertex private
+ endpoints for zero public egress). The Announcer never calls the model API,
+ so it needs only the one route to the instance.
+
+See `docs/bots.md § The sidecar runs anywhere` for the full threat-model
+discussion.
+
## Eleven & the Announcer
**Eleven** needs to see members it shares no thread with (to welcome them), so
diff --git a/docs/bots.md b/docs/bots.md
index 4c2a7f0..47afa9d 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -39,9 +39,67 @@ Two consequences to design around:
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.
+ 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.
+ 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 the Announcer 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.
+
+## Direction: a Go sidecar (decided 2026-07-06)
+
+The sidecar will be ported to **Go** — ideally a subcommand of the single
+binary (`lchat bot`), still 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). The Node
+sidecar (`agent/`) stays in-repo as the **functional reference implementation**
+for self-hosters who prefer it.
+
+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.
## What exists today (`agent/`)