eleven / ocho Public

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>
Paul Campbell 28108239b9677f66be131fa3e5af55335b71c174 parent 6d15db1
10 files changed, +91 −32
  • agent/README.md +9 −7
  • agent/eleven.mjs +1 −1
  • agent/runtime.mjs +11 −6
  • bots_test.go +33 −5
  • changelog/2026-07-02-helpers-mention-only.md +4 −0
  • db.go +11 −6
  • docs/bots.md +8 −6
  • main.go +8 −1
  • web/pages/admin.html +4 −0
  • web/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 (&ldquo;Claude, &hellip;&rdquo;)</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();