server+agent: per-bot listen mode; groups default to @mention-only (#74)
How a bot listens in groups is now console config (bot_configs.listen), set when adding an agent: - '' / 'mention' (default): reply only on an explicit @Name - 'name': also consider a bare-name mention, still gated by the cheap "am I being addressed?" classifier The sidecar applies it via /api/bot-config (LCHAT_LISTEN is the headless fallback); unknown values are rejected at the invite endpoint. DMs are unchanged. Docs + Eleven's guide updated; changelog entry added.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+91
−32
agent/README.md+9 −7agent/eleven.mjs+1 −1agent/runtime.mjs+11 −6bots_test.go+33 −5changelog/2026-07-02-helpers-mention-only.md+4 −0db.go+11 −6docs/bots.md+8 −6main.go+8 −1web/pages/admin.html+4 −0web/static/admin.js+2 −0
diff --git a/agent/README.md b/agent/README.md| index 7f16b5a..ffc9922 100644 |
| --- a/agent/README.md |
| +++ b/agent/README.md |
| @@ -8,7 +8,7 @@ its wire format is guaranteed to match the app. |
| | agent | what it does | |
| |---|---| |
| -| **Claude** | The plain assistant: replies to every DM; in groups an @mention always gets a reply, and a bare-name mention only does if a cheap classifier judges the bot is actually being addressed (not just talked about). (`runtime.mjs`, identity via `LCHAT_SESSION`/`LCHAT_INVITE`.) | |
| +| **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_*`.) | |
| | **Announcer** | Write-only deploy notes: polls the embedded changelog (`/api/changelog`) and posts what's new to groups it's added to. Never reads messages, never calls the Claude API. (`announcer.mjs`, `LCHAT_ANNOUNCER_*`.) | |
| @@ -20,13 +20,15 @@ console, its `#fragment` passed once as the matching `*_INVITE` env var. |
| | | | |
| |---|---| |
| -| `ANTHROPIC_API_KEY` | The only recurring secret + cost. Every DM and every group @mention is one Claude API call billed to your account (bare-name mentions also spend a fraction-of-a-cent Haiku call to decide whether to answer — `LCHAT_CLASSIFIER_MODEL` overrides). | |
| +| `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.luan.chat`. | |
| | `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). |
| +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 |
| @@ -44,10 +46,10 @@ LCHAT_BASE=https://test.luan.chat 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. In a group, an explicit @mention is a guaranteed reply; |
| -a message that just uses its name gets a reply only when a small classifier |
| -call agrees the bot is being spoken *to* rather than *about* — so chatting |
| -about a bot doesn't summon it. |
| +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. |
| ## Eleven & the Announcer |
diff --git a/agent/eleven.mjs b/agent/eleven.mjs| index f3cbb0c..b5f6c21 100644 |
| --- a/agent/eleven.mjs |
| +++ b/agent/eleven.mjs |
| @@ -42,7 +42,7 @@ export const ELEVEN_PERSONA = |
| export const ELEVEN_DOCS = [ |
| "lchat basics: every chat is end-to-end encrypted — the server (and its operator) can never read messages.", |
| "Start a chat: tap ➕, pick a person for a DM, or 'New group' to pick several people and name the group.", |
| - "Helpers: bots live under the Helpers section of the ➕ picker; each says what it does. @mention a bot (@Claude) in a group for a guaranteed reply — speaking to it by name also works, but it stays quiet when a message merely talks about it. In a DM it always replies.", |
| + "Helpers: bots live under the Helpers section of the ➕ picker; each says what it does. @mention a bot (@Claude) to get its attention in a group; in a DM it always replies.", |
| "Invite someone new: if the admin enabled member invites, ➕ → 'Invite someone' creates a private link — share it only with that person, since the link can read the chat it opens.", |
| "Groups: tap the group name for members; add people via the menu; swipe right on a message (or use the message menu) to reply; double-tap (touch) or press-and-hold (desktop) to react.", |
| "Photos & files: the paperclip attaches them; images are shrunk and stripped of location data before upload, and everything is encrypted.", |
diff --git a/agent/runtime.mjs b/agent/runtime.mjs| index 1a2109a..f383528 100644 |
| --- a/agent/runtime.mjs |
| +++ b/agent/runtime.mjs |
| @@ -25,7 +25,7 @@ const SAFETY = (name) => |
| `You are ${name}, an AI assistant and a member of lchat — a small, private, ` + |
| `end-to-end encrypted group chat; the server and its operator can't read messages, ` + |
| `and that privacy is the whole point of the app. You are one participant among the people here. ` + |
| - `You may be in a one-to-one DM or in a group; in a group you're brought in only when someone @mentions you or speaks to you directly by name. ` + |
| + `You may be in a one-to-one DM or in a group; in a group you're brought in only when someone addresses you directly (usually an @mention). ` + |
| `You can only take part in the conversation — no reminders, notifications, files, or web access — ` + |
| `so never promise to do those things; if asked, say you can't yet. ` + |
| `This chat may include children: always be kind and age-appropriate, and never say anything scary, adult, or unsafe. ` + |
| @@ -39,6 +39,10 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "" }) |
| let persona = builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| let model = MODEL; |
| let docs = builtinDocs; |
| + // How the bot listens in groups: "mention" (default) replies only to an |
| + // explicit @Name; "name" also considers a bare-name mention (still gated by |
| + // the classifier below). Console config wins; env is the headless fallback. |
| + let listen = process.env.LCHAT_LISTEN || "mention"; |
| const tag = (m) => log(`[${name}] ${m}`); |
| @@ -101,20 +105,21 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "" }) |
| persona = c.system_prompt || builtinPersona || process.env.LCHAT_SYSTEM || DEFAULT_PERSONA; |
| model = c.model || MODEL; |
| docs = c.docs || builtinDocs; |
| + listen = c.listen || process.env.LCHAT_LISTEN || "mention"; |
| } |
| } catch (e) { |
| tag(`loadConfig: ${e.message}`); |
| } |
| } |
| - // DMs to the bot: always answer. Groups are layered: an explicit @Name is a |
| - // guaranteed, deterministic reply; a bare name is ambiguous — people talk |
| - // *about* a bot as often as *to* it — so it only earns a reply if the |
| - // classifier below agrees we're being addressed. Anything else is ignored. |
| + // DMs to the bot: always answer. Groups: an explicit @Name is a guaranteed, |
| + // deterministic reply. A bare name counts only in listen mode "name" — and |
| + // even then it's ambiguous (people talk *about* a bot as often as *to* it), |
| + // so it must also pass the classifier below. Anything else is ignored. |
| function replyTrigger(kind, text) { |
| if (kind === "dm") return "always"; |
| if (new RegExp(`(^|[^a-z0-9])@${name}\\b`, "i").test(text || "")) return "always"; |
| - if (new RegExp(`(^|[^a-z0-9])${name}\\b`, "i").test(text || "")) return "classify"; |
| + if (listen === "name" && new RegExp(`(^|[^a-z0-9])${name}\\b`, "i").test(text || "")) return "classify"; |
| return null; |
| } |
diff --git a/bots_test.go b/bots_test.go| index f8dc16b..b0074da 100644 |
| --- a/bots_test.go |
| +++ b/bots_test.go |
| @@ -26,7 +26,7 @@ func TestHelpersCatalogInConnections(t *testing.T) { |
| if err := db.setUserAgent(bot); err != nil { |
| t.Fatalf("setUserAgent: %v", err) |
| } |
| - if err := db.setBotConfig(bot, "", "", "", "Answers questions and summarises chats", true); err != nil { |
| + if err := db.setBotConfig(bot, "", "", "", "", "Answers questions and summarises chats", true); err != nil { |
| t.Fatalf("setBotConfig: %v", err) |
| } |
| @@ -54,7 +54,7 @@ func TestHelpersCatalogInConnections(t *testing.T) { |
| } |
| // Admin flips availability off → the bot drops back out (still no shared thread). |
| - if err := db.setBotConfig(bot, "", "", "", "Answers questions and summarises chats", false); err != nil { |
| + if err := db.setBotConfig(bot, "", "", "", "", "Answers questions and summarises chats", false); err != nil { |
| t.Fatalf("setBotConfig off: %v", err) |
| } |
| conns, _ = db.connections(paul) |
| @@ -71,7 +71,7 @@ func TestHelpersCatalogInConnections(t *testing.T) { |
| if err := db.setUserAgent(ghost); err != nil { |
| t.Fatalf("setUserAgent ghost: %v", err) |
| } |
| - if err := db.setBotConfig(ghost, "", "", "", "not joined yet", true); err != nil { |
| + if err := db.setBotConfig(ghost, "", "", "", "", "not joined yet", true); err != nil { |
| t.Fatalf("setBotConfig ghost: %v", err) |
| } |
| conns, _ = db.connections(paul) |
| @@ -185,7 +185,7 @@ func TestVettedAgentSeesFullRoster(t *testing.T) { |
| _, paulTok := join("Paul", "inv-p") |
| botID, botTok := join("Eleven", "inv-b") |
| srv.db.setUserAgent(botID) |
| - if err := srv.db.setBotConfig(botID, "", "", "", "Your guide to the app", true); err != nil { |
| + if err := srv.db.setBotConfig(botID, "", "", "", "", "Your guide to the app", true); err != nil { |
| t.Fatalf("setBotConfig: %v", err) |
| } |
| @@ -213,10 +213,38 @@ func TestVettedAgentSeesFullRoster(t *testing.T) { |
| } |
| // Unvetted: flip availability off — the bot drops back to connections-only. |
| - if err := srv.db.setBotConfig(botID, "", "", "", "Your guide to the app", false); err != nil { |
| + if err := srv.db.setBotConfig(botID, "", "", "", "", "Your guide to the app", false); err != nil { |
| t.Fatalf("setBotConfig off: %v", err) |
| } |
| if got := labels(botTok); got["Paul"] { |
| t.Fatalf("unvetted agent must not see strangers: %v", got) |
| } |
| } |
| + |
| +// The listen mode (@mention-only vs bare-name) set at agent-invite time must |
| +// round-trip to /api/bot-config — the sidecar's reply gating depends on it — |
| +// and unknown values must be rejected at the API boundary. |
| +func TestBotConfigListen(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + uid, _ := srv.db.createUser("Sunny") |
| + srv.db.createKeyInvite("inv-listen", uid, "", 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-listen", "Sunny", "") |
| + srv.db.setUserAgent(uid) |
| + if err := srv.db.setBotConfig(uid, "", "", "", "name", "A helper", true); err != nil { |
| + t.Fatalf("setBotConfig: %v", err) |
| + } |
| + |
| + rr, _ := http.Get(ts.URL + "/api/bot-config?token=" + tok) |
| + var c BotConfig |
| + json.NewDecoder(rr.Body).Decode(&c) |
| + if c.Listen != "name" { |
| + t.Fatalf("listen should round-trip to the sidecar: %+v", c) |
| + } |
| + |
| + // The invite endpoint only accepts the known modes. |
| + resp := adminPost(t, ts.URL+"/api/admin/key-invites", |
| + `{"id":"inv-bad-listen","label":"Bad","agent":true,"listen":"watcher"}`) |
| + if resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("unknown listen mode: want 400, got %d", resp.StatusCode) |
| + } |
| +} |
diff --git a/changelog/2026-07-02-helpers-mention-only.md b/changelog/2026-07-02-helpers-mention-only.md| new file mode 100644 |
| index 0000000..77c0e64 |
| --- /dev/null |
| +++ b/changelog/2026-07-02-helpers-mention-only.md |
| @@ -0,0 +1,4 @@ |
| +Helpers now stay out of group conversations unless you @mention them (like |
| +@Claude). Your admin can allow an individual helper to also answer when spoken |
| +to by name — it checks it's really being talked *to*, not just *about*. DMs |
| +are unchanged: a helper always replies there. |
diff --git a/db.go b/db.go| index d8530d7..3e7e021 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -233,6 +233,10 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| // like the roster label — in clear, never message content. |
| "ALTER TABLE bot_configs ADD COLUMN description TEXT NOT NULL DEFAULT ''", |
| "ALTER TABLE bot_configs ADD COLUMN available_to_members INTEGER NOT NULL DEFAULT 0", |
| + // Listen mode (docs/bots.md): when a bot replies in a group — ''/'mention' |
| + // = only on an @mention (the default), 'name' = also when spoken to by |
| + // bare name (the sidecar still gates that through a classifier). |
| + "ALTER TABLE bot_configs ADD COLUMN listen TEXT NOT NULL DEFAULT ''", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -877,18 +881,19 @@ type BotConfig struct { |
| Model string `json:"model"` |
| SystemPrompt string `json:"system_prompt"` |
| Docs string `json:"docs"` |
| + Listen string `json:"listen"` // ''/'mention' = @mention only, 'name' = also bare name |
| } |
| -func (db *DB) setBotConfig(userID, model, systemPrompt, docs, description string, available bool) error { |
| +func (db *DB) setBotConfig(userID, model, systemPrompt, docs, listen, description string, available bool) error { |
| av := 0 |
| if available { |
| av = 1 |
| } |
| _, err := db.sql.Exec( |
| - `INSERT INTO bot_configs(user_id, model, system_prompt, docs, description, available_to_members, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?) |
| + `INSERT INTO bot_configs(user_id, model, system_prompt, docs, listen, description, available_to_members, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?) |
| ON CONFLICT(user_id) DO UPDATE SET model = excluded.model, system_prompt = excluded.system_prompt, docs = excluded.docs, |
| - description = excluded.description, available_to_members = excluded.available_to_members, updated_at = excluded.updated_at`, |
| - userID, model, systemPrompt, docs, description, av, time.Now().Unix(), |
| + listen = excluded.listen, description = excluded.description, available_to_members = excluded.available_to_members, updated_at = excluded.updated_at`, |
| + userID, model, systemPrompt, docs, listen, description, av, time.Now().Unix(), |
| ) |
| return err |
| } |
| @@ -896,8 +901,8 @@ func (db *DB) setBotConfig(userID, model, systemPrompt, docs, description string |
| // botConfig returns an agent's config (ok=false if none has been set). |
| func (db *DB) botConfig(userID string) (BotConfig, bool, error) { |
| var c BotConfig |
| - err := db.sql.QueryRow("SELECT model, system_prompt, docs FROM bot_configs WHERE user_id = ?", userID). |
| - Scan(&c.Model, &c.SystemPrompt, &c.Docs) |
| + err := db.sql.QueryRow("SELECT model, system_prompt, docs, listen FROM bot_configs WHERE user_id = ?", userID). |
| + Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen) |
| if err == sql.ErrNoRows { |
| return c, false, nil |
| } |
diff --git a/docs/bots.md b/docs/bots.md| index 38729e1..a22b3c4 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -55,10 +55,11 @@ A headless Node sidecar that behaves like a member's browser: |
| - `agent.mjs` — connect `/ws`, unwrap thread keys, on each incoming `msg` |
| decrypt, 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 → @mention always replies, a bare-name mention replies |
| - 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 unknown `thread_id`. |
| + to everything; group → only on @mention by default.** A per-bot **listen mode** |
| + (console `bot_configs.listen`) can widen that to `name`, 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 unknown `thread_id`. |
| - Server + web: additive `users.is_agent` column, roster `BOT` tag, and a |
| dedicated admin **"Add an agent"** section (distinct from inviting a person) |
| that captures **model + system prompt + reference docs** and stores them in a |
| @@ -67,8 +68,9 @@ A headless Node sidecar that behaves like a member's browser: |
| 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 **read-scope / availability** fields (mention-only vs. watcher, Helpers |
| - catalog visibility) — the form captures persona/model/docs only so far. |
| + 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 |
diff --git a/main.go b/main.go| index 9a5f5d7..17814c0 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -1174,6 +1174,9 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // see, and whether the bot shows in everyone's Helpers list (docs/bots.md). |
| Description string `json:"description"` |
| Available bool `json:"available_to_members"` |
| + // Listen mode (only when Agent): when the bot replies in groups — |
| + // ""/"mention" = @mention only (default), "name" = also on its bare name. |
| + Listen string `json:"listen"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1199,11 +1202,15 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if body.Agent { |
| + if body.Listen != "" && body.Listen != "mention" && body.Listen != "name" { |
| + httpError(w, http.StatusBadRequest, "bad listen") |
| + return |
| + } |
| if err := s.db.setUserAgent(userID); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| - if err := s.db.setBotConfig(userID, body.Model, body.SystemPrompt, body.Docs, strings.TrimSpace(body.Description), body.Available); err != nil { |
| + if err := s.db.setBotConfig(userID, body.Model, body.SystemPrompt, body.Docs, body.Listen, strings.TrimSpace(body.Description), body.Available); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index 7b433f2..bcbd981 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -76,6 +76,10 @@ |
| </select> |
| <textarea id="agentPrompt" rows="4" placeholder="Personality / instructions (system prompt). Leave blank for a friendly default. A safety + privacy preamble is always applied on top." style="display:block;width:100%;margin:0 0 8px"></textarea> |
| <textarea id="agentDocs" rows="3" placeholder="Reference the bot should always know — house rules, an FAQ, etc. Optional." style="display:block;width:100%;margin:0 0 8px"></textarea> |
| + <select id="agentListen" style="display:block;width:100%;margin:0 0 8px"> |
| + <option value="">Replies in groups only when @mentioned (default)</option> |
| + <option value="name">Also replies when spoken to by name (“Claude, …”)</option> |
| + </select> |
| <label class="toggle-row" style="margin:0 0 8px"> |
| <input type="checkbox" id="agentAvailable" checked /> |
| <span>Show in everyone's Helpers list (untick to add it to chats yourself)</span> |
diff --git a/web/static/admin.js b/web/static/admin.js| index 93b130d..40a483f 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -113,6 +113,7 @@ function wireCreateAgent() { |
| model: document.getElementById("agentModel").value, |
| system_prompt: document.getElementById("agentPrompt").value.trim(), |
| docs: document.getElementById("agentDocs").value.trim(), |
| + listen: document.getElementById("agentListen").value, |
| description: document.getElementById("agentDescription").value.trim(), |
| available_to_members: document.getElementById("agentAvailable").checked, |
| }; |
| @@ -123,6 +124,7 @@ function wireCreateAgent() { |
| document.getElementById("agentModel").value = ""; |
| document.getElementById("agentPrompt").value = ""; |
| document.getElementById("agentDocs").value = ""; |
| + document.getElementById("agentListen").value = ""; |
| document.getElementById("agentAvailable").checked = true; |
| showInviteModal(minted.link, name || "New agent"); |
| loadUsers(); |