eleven / messenger Public

Admin: edit an existing bot's config (+ BOT badges in People list) (#62)

* admin+server: edit an existing bot's config; badge agents in People list

- New admin endpoints GET/POST /api/admin/bots/{id}/config (FullBotConfig:
  model, system prompt, docs + catalog description and Helpers visibility).
  Both refuse non-agents; GET returns zero values for a pre-console bot.
  The sidecar re-pulls config every reconnect, so edits apply within ~a
  minute; catalog changes show in rosters immediately.
- Admin People list: agents get a BOT badge and an "Edit bot" button that
  opens a prefilled editor (same fields as the create form).
- listUsers now carries is_agent.
- docs/bots.md: record Paul's decision that mention-triggered bots KEEP
  reading recent thread history for context (mention-only ingestion is not
  planned; watchers stay the separate every-message mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pin chats to the top of the list (#66)

web: "Pin chat / Unpin chat" in the thread kebab menu; pinned chats float
to the top of the list (stable pinnedFirst helper in ui.js, unit-tested)
with a small tilted pushpin badge next to the timestamp.

server: per-user pinned_threads marker (same shape as hidden_threads),
POST /api/threads/{id}/pinned, and pinned-first ordering in threadsForUser
so every client syncs the same order across devices.

ios: decode the pinned flag and sort the native sidebar pinned-first
(toggle stays web/Mac-side for now — the embed hides the web header).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* web: native-gated hook to drive Eleven desktop notifications (#65)

chat.js posts a decrypted message preview to the Mac app via window.webkit.messageHandlers.lchatNotify when running inside the Eleven wrapper. Completely inert on the web and mobile (the bridge only exists in the native app), so no behaviour change for browser/PWA users. Pairs with the native handler in PR #47. Suppresses your own messages and the thread you're actively reading.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ios: fix keyboard insets at the root, Chats header + search on the main index (#63)

The inset probe measured against UIScreen.main and, worse, the "full bleed"
reference overlay was itself safe-area constrained — children of a full-bleed
view still respect the safe area — so both frames matched and every inset
computed to zero: short threads sat under the composer the moment the keyboard
rose. Insets now diff the safe region against the webview's own measured frame
(Display-Zoom-proof, no UIScreen), with a settle re-apply after the keyboard
animation.

The main index gains the Signal-style header: large "Chats" title and a
search field that filters the list by chat name (mirrors the web sidebar
filter), with the standard no-results state.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* APNs push notifications: ciphertext-only payloads, on-device decrypt in a notification extension (#64)

* server+ios: APNs push notifications with on-device decryption (E2EE-preserving)

Server: apns.go — HTTP/2 + ES256 provider-JWT sender, configured entirely by
LCHAT_APNS_* env (a no-op when unset), apns_tokens table + token-gated
/api/apns/subscribe|unsubscribe, fan-out hooked into pushNewMessage under the
same suppression rules as Web Push. The payload carries ONLY the ciphertext
blob + thread id.

App: PushManager registers + posts the APNs token to every joined instance
(permission requested the moment the first chat is added), suppresses banners
for the on-screen chat, and deep-links notification taps to their chat.
ThreadStore parks each thread's key + title in the shared keychain
(SharedThreadKeys); the LChatNSE notification extension decrypts the pushed
blob on-device and rewrites the banner — plaintext never leaves the phone.

Verified: TestAPNsSubscribe, the PushBlobDecryptTest cross-implementation gate
(crypto.js blob → GroupKey), permission + banner delivery + full UI regression
in the simulator. The simulator does NOT run NSEs for simctl push, so the
banner rewrite itself is a device checkbox behind the paid-signing go-live
steps documented in ios/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+deploy: push entitlements (aps-environment + shared keychain group) and APNs env forwarding in deploy-instance.sh

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Own messages never mark a chat unread (cross-device) (#67)

The sender's user id now rides inside the encrypted payload (uid) — the
server never sees it, but your other devices decrypt it and recognise the
message as yours. A chat is unread only when its latest message is from
someone else, so replying from one device clears the dot on the rest.

web: shared ownMessage helper (ui.js, unit-tested) drives both the bubble
mine/theirs rendering and the live unread dot; legacy payloads without a
uid fall back to the screen-name comparison the renderer always used.
Edits keep the uid since they re-encrypt the original payload.

ios: same rule in ThreadStore — the sidebar pip skips threads whose last
message is ours.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* agent: bots only reply when actually addressed (bare-name classifier gate) (#70)

A bare "Claude" anywhere in a group message used to trigger a reply, so
talking *about* a bot summoned it. Group triggers are now layered:

- @Name -> always replies (deterministic, no extra API call)
- bare name -> a cheap Haiku yes/no call decides whether the bot is being
  spoken to or merely mentioned; errors/ambiguity default to silence
- DMs unchanged (always reply)

Classifier model is claude-haiku-4-5, overridable via LCHAT_CLASSIFIER_MODEL.
Docs, the SAFETY preamble, and Eleven's app guide updated to match; changelog
entry added for the Announcer.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* CSS: remove duplicate background from code in pre blocks for my messages

* server: stable chat-list order for same-second activity (#73)

The thread sort tiebroke on second-resolution timestamps, so two threads
with activity in the same second ordered arbitrarily — visible as list
jitter and as the TestThreadPinned flake. Message ids are monotonic, so
they break the tie deterministically.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* 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>

* web: keep the feed pinned to the bottom when its viewport resizes (#76)

The composer stack (autogrow textarea, typeahead, reply/edit banners,
keyboard inset) sits in normal flow, so growing it shrinks the feed
without a scroll event — leaving the chat wedged not-quite-at-bottom
while typing. Track near-bottom on scroll and re-pin from a
ResizeObserver on the feed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Native macOS app (Eleven) — rebuilt on main, shared-core aligned (#68)

* mac: rebuild Eleven on current main (Tier A shared-core alignment)

Re-homes the LChatMac target onto current main instead of a stale base, so it merges cleanly. Shares the platform-neutral core (LChat/Models.swift + LChat/LChatAPI.swift) with the iOS target; drops the unused Crypto/Keychain from the Mac target (the webview does all crypto). Eleven keeps its own identity model — SavedChat/Session + legacy invite parsing now live in LChatMac/MacInstance.swift, since main moved the shared model to the native-keypair SavedInstance. No structural change to the Mac app; desktop stays webview-holds-keys + local-only notifications by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: fix rail drag-to-reorder (ServerIcon was a Button, which ate the drag)

On macOS a Button swallows .onDrag, so dragging a rail icon never started. Make ServerIcon a plain tappable view (contentShape + onTapGesture) so the drag initiates; reorder + persistence were already wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: cure the WebKit feed drift the ResizeObserver fix (#76) missed (#78)

The composer autogrow (height:auto + scrollHeight read) forces an
intermediate layout with a shorter textarea; the feed is momentarily
taller there and WebKit clamps its scrollTop down — on every keystroke —
overwriting the observer's re-pin (Chrome doesn't clamp, which is why
the observer alone looked sufficient). Reproduced and verified with a
Playwright WebKit e2e (join → send 25 → type a wrapping draft).

- input handler: judge was-at-bottom before the height dance, re-pin
  after — the last write in the turn wins, every keystroke.
- observer's next-frame re-assert made unconditional: the decision is
  made at observe time, and WebKit's restoration scroll event can flip
  stickBottom false before the frame fires (e.g. the 216px typeahead).
- typing while scrolled up into history verified untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* server+ios: APNs fan-out logging; fix notification-tap race showing "Chat not found" (#71)

The tap deep-link pushed the chat before the freshly-created ThreadStore had
loaded, so the destination resolved against an empty list and bailed. The
destination now observes the store and waits (spinner) while it loads, keeping
"not found" for genuinely unknown threads. Also: apns[msg] summary logging
(tokens / after exclusions / sent) mirroring web push — the silent fan-out made
this morning's key corruption undiagnosable — and the temporary registration
debug prints removed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* ios: interactive edge-drag for the server drawer (#75)

The drawer is now positioned by a progress value rather than toggled: drag
from the left edge and it slides in under your finger, with a velocity-aware
snap on release; drag or tap the scrim to close. @GestureState (not @State)
carries the live offset so a system-cancelled drag can never strand the drawer
half-open with hit-testing wedged, and hit-testing never depends on the live
drag (flipping it mid-gesture cancels the gesture doing the closing). Closed
drawer is hidden from VoiceOver.

UI test drives the drag both ways; asserts use hittability — offscreen
elements never leave the XCUITest tree, so exists-checks lie.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* ios: show the server host under the drawer title instead of "as <name>" (#79)

The screen name added nothing (you know who you are); the host is what
distinguishes servers. Suppressed when the title already is the host (no group
name loaded yet) so it isn't shown twice.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: James Adam <james@lazyatom.com>
Paul Campbell 9850240a88a8d4bfcab4b45f8ab33950fc8f5285 parent cddca36
7 files changed, +292 −4
  • bots_test.go +51 −0
  • changelog/2026-07-02-edit-bots.md +1 −0
  • db.go +38 −3
  • docs/bots.md +13 −1
  • main.go +55 −0
  • web/static/admin.js +129 −0
  • web/static/style.css +5 −0
diff --git a/bots_test.go b/bots_test.go
index b0074da..f9d3287 100644
--- a/bots_test.go
+++ b/bots_test.go
@@ -248,3 +248,54 @@ func TestBotConfigListen(t *testing.T) {
t.Fatalf("unknown listen mode: want 400, got %d", resp.StatusCode)
}
}
+
+// Admin bot-config editing: GET returns the full config (zero-value before any
+// row exists), POST updates it in place, and both refuse non-agents.
+func TestAdminEditBotConfig(t *testing.T) {
+ ts, srv := newTestServer(t)
+ person, _ := srv.db.createUser("Paul")
+ bot, _ := srv.db.createUser("Sunny")
+ srv.db.setUserAgent(bot)
+
+ if r := adminPost(t, ts.URL+"/api/admin/bots/"+person+"/config", `{"model":""}`); r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("person: want 400, got %d", r.StatusCode)
+ }
+
+ get := func() FullBotConfig {
+ req, _ := http.NewRequest("GET", ts.URL+"/api/admin/bots/"+bot+"/config", nil)
+ req.Header.Set("Authorization", "Bearer admintok")
+ resp, _ := http.DefaultClient.Do(req)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("get config: %d", resp.StatusCode)
+ }
+ var c FullBotConfig
+ json.NewDecoder(resp.Body).Decode(&c)
+ return c
+ }
+
+ // Pre-console bot: no config row yet — GET still succeeds with zero values.
+ if c := get(); c.Model != "" || c.Available {
+ t.Fatalf("expected zero-value config, got %+v", c)
+ }
+
+ if r := adminPost(t, ts.URL+"/api/admin/bots/"+bot+"/config",
+ `{"model":"claude-opus-4-8","system_prompt":"be brief","docs":"house rules","listen":"name","description":"A test bot","available_to_members":true}`); r.StatusCode != http.StatusOK {
+ t.Fatalf("update: want 200, got %d", r.StatusCode)
+ }
+ c := get()
+ if c.Model != "claude-opus-4-8" || c.SystemPrompt != "be brief" || c.Docs != "house rules" ||
+ c.Listen != "name" || c.Description != "A test bot" || !c.Available {
+ t.Fatalf("config after update: %+v", c)
+ }
+
+ // The edit endpoint enforces the same listen allowlist as invites.
+ if r := adminPost(t, ts.URL+"/api/admin/bots/"+bot+"/config", `{"listen":"watcher"}`); r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("bad listen on edit: want 400, got %d", r.StatusCode)
+ }
+
+ // The sidecar-facing view picks up the same row.
+ bc, ok, _ := srv.db.botConfig(bot)
+ if !ok || bc.SystemPrompt != "be brief" {
+ t.Fatalf("sidecar view: ok=%v %+v", ok, bc)
+ }
+}
diff --git a/changelog/2026-07-02-edit-bots.md b/changelog/2026-07-02-edit-bots.md
new file mode 100644
index 0000000..0b21cf8
--- /dev/null
+++ b/changelog/2026-07-02-edit-bots.md
@@ -0,0 +1 @@
+Admins can now edit a bot after creating it — its personality, model, description, when it replies in groups, and whether it shows under Helpers.
diff --git a/db.go b/db.go
index 3e7e021..940b0ff 100644
--- a/db.go
+++ b/db.go
@@ -458,6 +458,8 @@ type User struct {
// in-app invite), vs a bare admin invite. The admin doesn't re-send DM invites —
// the inviting member does, in-app.
PendingDM bool `json:"pending_dm"`
+ // IsAgent marks a bot, so the console can badge it and offer "Edit bot".
+ IsAgent bool `json:"is_agent,omitempty"`
}
func (db *DB) createUser(label string) (string, error) {
@@ -472,7 +474,7 @@ func (db *DB) createUser(label string) (string, error) {
func (db *DB) listUsers() ([]User, error) {
now := time.Now().Unix()
rows, err := db.sql.Query(`
- SELECT u.id, u.label, u.created_at,
+ SELECT u.id, u.label, u.created_at, COALESCE(u.is_agent, 0),
(SELECT COUNT(*) FROM device_tokens d WHERE d.user_id = u.id),
(SELECT MIN(d.created_at) FROM device_tokens d WHERE d.user_id = u.id),
(SELECT COUNT(*) FROM key_invites k WHERE k.user_id = u.id
@@ -488,8 +490,8 @@ func (db *DB) listUsers() ([]User, error) {
for rows.Next() {
var u User
var joinedAt sql.NullInt64
- var pending, pendingDM int
- if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &u.Devices, &joinedAt, &pending, &pendingDM); err != nil {
+ var pending, pendingDM, isAgent int
+ if err := rows.Scan(&u.ID, &u.Label, &u.CreatedAt, &isAgent, &u.Devices, &joinedAt, &pending, &pendingDM); err != nil {
return nil, err
}
if joinedAt.Valid {
@@ -498,6 +500,7 @@ func (db *DB) listUsers() ([]User, error) {
}
u.Pending = pending > 0
u.PendingDM = pendingDM > 0
+ u.IsAgent = isAgent != 0
out = append(out, u)
}
return out, rows.Err()
@@ -863,6 +866,14 @@ func (db *DB) setUserAgent(userID string) error {
return err
}
+// isAgent reports whether a user is a bot (the admin bot-config endpoints
+// refuse to attach config to a person).
+func (db *DB) isAgent(userID string) bool {
+ var n int
+ db.sql.QueryRow("SELECT COALESCE(is_agent, 0) FROM users WHERE id = ?", userID).Scan(&n)
+ return n != 0
+}
+
// isVettedAgent reports whether a user is an agent the admin marked available
// to members. Vetted bots are instance-wide infrastructure: they appear in
// everyone's Helpers catalog and read the full joined roster (so e.g. Eleven
@@ -909,6 +920,30 @@ func (db *DB) botConfig(userID string) (BotConfig, bool, error) {
return c, err == nil, err
}
+// FullBotConfig is BotConfig plus the Helpers-catalog metadata — what the
+// admin console edits (the sidecar's own view stays BotConfig).
+type FullBotConfig struct {
+ BotConfig
+ Description string `json:"description"`
+ Available bool `json:"available_to_members"`
+}
+
+// fullBotConfig returns everything the console shows for a bot; ok=false when
+// no config row exists yet (a pre-console bot — fields default to empty).
+func (db *DB) fullBotConfig(userID string) (FullBotConfig, bool, error) {
+ var c FullBotConfig
+ var av int
+ err := db.sql.QueryRow(
+ `SELECT model, system_prompt, docs, COALESCE(listen, ''), COALESCE(description, ''), COALESCE(available_to_members, 0)
+ FROM bot_configs WHERE user_id = ?`, userID).
+ Scan(&c.Model, &c.SystemPrompt, &c.Docs, &c.Listen, &c.Description, &av)
+ if err == sql.ErrNoRows {
+ return c, false, nil
+ }
+ c.Available = av != 0
+ return c, err == nil, err
+}
+
func (db *DB) setAvatar(userID, blobID string) error {
var b any
if blobID != "" {
diff --git a/docs/bots.md b/docs/bots.md
index a22b3c4..1c4de6c 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -204,7 +204,19 @@ server awareness.
## How much a bot reads in a group
-Default: **a group bot does NOT read the whole conversation.** It ingests only
+> **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
diff --git a/main.go b/main.go
index 17814c0..a57eed6 100644
--- a/main.go
+++ b/main.go
@@ -173,6 +173,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/admin/users", s.listUsers)
mux.HandleFunc("POST /api/admin/users/{id}/remove", s.removeUser)
mux.HandleFunc("POST /api/admin/users/{id}/label", s.renameUser)
+ mux.HandleFunc("GET /api/admin/bots/{id}/config", s.getAdminBotConfig)
+ mux.HandleFunc("POST /api/admin/bots/{id}/config", s.setAdminBotConfig)
// A member can mint a fresh invite for their own account (add a device).
mux.HandleFunc("POST /api/device-invites", s.createDeviceInvite)
// A member can invite a new person straight into a DM (gated by an admin setting).
@@ -1290,6 +1292,59 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
+// getAdminBotConfig returns a bot's full console-managed config (persona,
+// model, docs + Helpers-catalog metadata) so the admin can edit it after
+// creation. 400s for a non-agent — people don't have bot config.
+func (s *Server) getAdminBotConfig(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ id := r.PathValue("id")
+ if !s.db.isAgent(id) {
+ httpError(w, http.StatusBadRequest, "not a bot")
+ return
+ }
+ c, _, err := s.db.fullBotConfig(id) // ok=false → zero-value config, still editable
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, c)
+}
+
+// setAdminBotConfig updates a bot's config in place. The sidecar re-pulls its
+// config on every reconnect (~a minute), so edits take effect without a
+// redeploy; catalog changes (description/availability) show in rosters
+// immediately.
+func (s *Server) setAdminBotConfig(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ id := r.PathValue("id")
+ if !s.db.isAgent(id) {
+ httpError(w, http.StatusBadRequest, "not a bot")
+ return
+ }
+ var body FullBotConfig
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if len(body.Description) > 300 {
+ httpError(w, http.StatusBadRequest, "description too long")
+ return
+ }
+ if body.Listen != "" && body.Listen != "mention" && body.Listen != "name" {
+ httpError(w, http.StatusBadRequest, "bad listen")
+ return
+ }
+ if err := s.db.setBotConfig(id, body.Model, body.SystemPrompt, body.Docs, body.Listen, strings.TrimSpace(body.Description), body.Available); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// renameUser changes a user's roster label (admin roster management — e.g.
// renaming a bot after its default changes). Display only: keys and history
// are untouched, and messages keep the name they were sent under (it's inside
diff --git a/web/static/admin.js b/web/static/admin.js
index 40a483f..25a8037 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -182,6 +182,12 @@ function renderUsers(users) {
const label = document.createElement("div");
label.className = "label";
label.textContent = u.label || "(no name)";
+ if (u.is_agent) {
+ const tag = document.createElement("span");
+ tag.textContent = "BOT";
+ tag.className = "bot-tag";
+ label.appendChild(tag);
+ }
const joined = u.devices > 0;
const date = (t) => new Date(t * 1000).toLocaleDateString();
const status = document.createElement("div");
@@ -222,6 +228,15 @@ function renderUsers(users) {
row.appendChild(reissue);
}
+ // Bots are editable after creation: persona, model, docs + catalog entry.
+ if (u.is_agent) {
+ const edit = document.createElement("button");
+ edit.className = "secondary";
+ edit.textContent = "Edit bot";
+ edit.onclick = () => showEditBotModal(u);
+ row.appendChild(edit);
+ }
+
// Remove a person from the instance (roster management).
const remove = document.createElement("button");
remove.className = "linkish danger";
@@ -238,6 +253,120 @@ function renderUsers(users) {
}
}
+// Edit an existing bot's config in place. The sidecar re-pulls config on its
+// next reconnect (~a minute), so persona/model/docs edits apply without a
+// redeploy; catalog changes (description / Helpers visibility) are immediate.
+async function showEditBotModal(u) {
+ const res = await api("GET", `/api/admin/bots/${encodeURIComponent(u.id)}/config`);
+ if (!res.ok) {
+ toast("Couldn't load the bot's config");
+ return;
+ }
+ const cfg = await res.json();
+
+ const backdrop = document.createElement("div");
+ backdrop.className = "qr-screen"; // same full-screen takeover as the QR modal
+ const card = document.createElement("div");
+ card.className = "card qr-modal";
+ const h = document.createElement("h2");
+ h.textContent = `Edit bot: ${u.label}`;
+ card.appendChild(h);
+
+ const field = (el, value) => {
+ el.style.cssText = "display:block;width:100%;margin:0 0 8px";
+ el.value = value || "";
+ card.appendChild(el);
+ return el;
+ };
+ const model = field(document.createElement("select"), "");
+ for (const [v, t] of [
+ ["", "Default model (Sonnet)"],
+ ["claude-opus-4-8", "Opus — most capable"],
+ ["claude-sonnet-5", "Sonnet — balanced"],
+ ["claude-haiku-4-5", "Haiku — fast & cheap"],
+ ]) {
+ const o = document.createElement("option");
+ o.value = v;
+ o.textContent = t;
+ model.appendChild(o);
+ }
+ model.value = cfg.model || "";
+ const desc = field(document.createElement("input"), cfg.description);
+ desc.placeholder = "What this helper does, in one line — members see this in the Helpers list";
+ desc.maxLength = 200;
+ const prompt = field(document.createElement("textarea"), cfg.system_prompt);
+ prompt.rows = 4;
+ prompt.placeholder = "Personality / instructions (system prompt). Leave blank for the bot's default.";
+ const docs = field(document.createElement("textarea"), cfg.docs);
+ docs.rows = 3;
+ docs.placeholder = "Reference the bot should always know. Optional.";
+ const listen = field(document.createElement("select"), "");
+ for (const [v, t] of [
+ ["", "Replies in groups only when @mentioned (default)"],
+ ["name", "Also replies when spoken to by name (“Claude, …”)"],
+ ]) {
+ const o = document.createElement("option");
+ o.value = v;
+ o.textContent = t;
+ listen.appendChild(o);
+ }
+ // 'mention' and '' are the same mode; the form only mints '' and 'name'.
+ listen.value = cfg.listen === "name" ? "name" : "";
+ const availRow = document.createElement("label");
+ availRow.className = "toggle-row";
+ availRow.style.margin = "0 0 8px";
+ const avail = document.createElement("input");
+ avail.type = "checkbox";
+ avail.checked = !!cfg.available_to_members;
+ const availText = document.createElement("span");
+ availText.textContent = "Show in everyone's Helpers list";
+ availRow.appendChild(avail);
+ availRow.appendChild(availText);
+ card.appendChild(availRow);
+
+ const rowEl = document.createElement("div");
+ rowEl.className = "row";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ const save = document.createElement("button");
+ save.textContent = "Save";
+ rowEl.appendChild(cancel);
+ rowEl.appendChild(save);
+ card.appendChild(rowEl);
+
+ const close = () => {
+ backdrop.remove();
+ document.removeEventListener("keydown", onKey);
+ };
+ const onKey = (e) => {
+ if (e.key === "Escape") close();
+ };
+ document.addEventListener("keydown", onKey);
+ cancel.onclick = close;
+ save.onclick = async () => {
+ save.disabled = true;
+ const r = await api("POST", `/api/admin/bots/${encodeURIComponent(u.id)}/config`, {
+ model: model.value,
+ system_prompt: prompt.value.trim(),
+ docs: docs.value.trim(),
+ listen: listen.value,
+ description: desc.value.trim(),
+ available_to_members: avail.checked,
+ });
+ if (r.ok) {
+ toast("Saved — the bot picks it up within a minute");
+ close();
+ } else {
+ toast("Couldn't save");
+ save.disabled = false;
+ }
+ };
+
+ backdrop.appendChild(card);
+ document.body.appendChild(backdrop);
+}
+
// Show a scannable QR of an invite link, for joining in person. The QR encodes
// the FULL join link — including the #k= key fragment — so it's rendered here in
// the browser (never server-side, which would leak the key) and must be treated
diff --git a/web/static/style.css b/web/static/style.css
index 1501367..1605805 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1117,6 +1117,11 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* admin: a labelled checkbox row (e.g. "let members invite people") */
.toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
+/* BOT badge on admin People rows (chat.js has its own inline-styled agentTag) */
+.bot-tag {
+ margin-left: 6px; font-size: 0.7em; font-weight: 600; letter-spacing: 0.03em;
+ padding: 1px 5px; border-radius: 6px; background: var(--accent, #888); color: #fff; vertical-align: middle;
+}
.toggle-row input { width: 18px; height: 18px; }
/* pending DM: waiting for an invited person to join (web/static/chat.js). While