# 🤖 lchat agent sidecar

One headless process that hosts an instance's **default agents**, each a normal
E2EE member with its own keypair and identity file. **The lchat server only
ever sees ciphertext** — private keys and your `ANTHROPIC_API_KEY` live only on
this host. It shares `web/static/crypto.js` verbatim (imported directly), so
its wire format is guaranteed to match the app.

> **The default sidecar is now Go** — `lchat bot run`, a subcommand of the same
> single static `lchat` binary, no Node runtime (`internal/bot/`, see
> `docs/bots.md`). It's a full drop-in: same wire format, and session/state/
> recipe files are byte-compatible, so it can take over a Node bot's identity in
> place.
>
> **This Node sidecar is kept on purpose** as a runnable **reference
> implementation** — a worked example of how to run a bot in Node if you'd
> rather (it imports `web/static/crypto.js` verbatim, so it's also the clearest
> illustration of the exact E2EE wire format a client speaks). It is not
> deprecated and not scheduled for removal; keep it working alongside the Go
> port.

| 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`.) |
| **Eleven** | The app guide: same chat loop with a shipped-in persona + app reference (updated with every deploy), and it **DMs each new member a welcome** when they join. (`eleven.mjs`, `LCHAT_ELEVEN_*`.) |

Each identity is optional — it runs if its session file exists or its invite is
set. Every identity is minted the same way: an agent invite from the admin
console, its `#fragment` passed once as the matching `*_INVITE` env var.

## What you provide

| | |
|---|---|
| `ANTHROPIC_API_KEY` | The only recurring secret + cost. Every DM and every group @mention is one Claude API call billed to your account (bots in the "name" listen mode also spend a fraction-of-a-cent Haiku call per bare-name mention to decide whether to answer — `LCHAT_CLASSIFIER_MODEL` overrides). |
| `LCHAT_BASE` | Your instance URL, e.g. `https://test.example.com`. |
| `LCHAT_INVITE` | **One-time.** The `#fragment` of a `/join/…` link minted in the admin console (tick "This is an agent"). Spent after provisioning. |

Optional: `LCHAT_AGENT_NAME` (default `Claude`), `LCHAT_MODEL` (default
`claude-sonnet-5`; try `claude-opus-4-8` when a bot needs deeper reasoning), `LCHAT_SYSTEM` (system
prompt), `LCHAT_SESSION` (session-file path), `LCHAT_LISTEN` (`mention` = groups
reply only on @mention, the default; `name` = also consider bare-name mentions —
console config overrides per bot).

## Run

```sh
cd agent
npm install

# 1. Provision once (creates session.json — the bot's identity; back it up)
LCHAT_BASE=https://test.example.com LCHAT_INVITE='<#fragment from the invite link>' \
  npm run provision

# 2. Run
export ANTHROPIC_API_KEY=sk-ant-...
LCHAT_BASE=https://test.example.com npm start
```

Then, in the app, add the agent to a group or start a DM with it. It replies to
every message in a DM, and in a group only when @mentioned. An admin can widen
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). A changelog-recipe bot 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

**Eleven** needs to see members it shares no thread with (to welcome them), so
vetted bots — agents the admin marked *available to members* — read the full
joined roster; people stay connections-only. On its first run it baselines
silently (existing members are never welcomed retroactively). Its app knowledge
ships in `eleven.mjs`, so it improves with each deploy; console config can
still override the persona per instance.

(An earlier third identity, the write-only **Announcer**, posted release notes
to groups. It's retired: the server announces its own changelog now — a push to
the space's admins plus the Admin app's update stream. The Go sidecar's
`changelog` recipe rule remains for custom deploy watchers.)

`node eleven.mjs` runs one standalone welcome tick — handy for tests and
cron-style setups.

## Notes & limits

- **The Claude API sees plaintext.** The lchat server does not — but the reply is
  generated by sending decrypted messages to Anthropic. Only a local model would
  close that gap. Disclose this where you add the bot.
- **Catches up on reconnect.** On a WebSocket reconnect it backfills messages
  missed during the gap (per-thread cursor), so a message sent in the ~1s blip
  still gets a reply. A full process restart resets cursors to each thread's
  latest, so it won't answer messages sent while the process itself was down (and
  never replies to old backlog on first start).
- **No forward secrecy on removal** — same as removing a human member today.
- Keep one sidecar per instance so a compromised host can read only the threads
  that bot was added to.
