Group mode — one shared agent for a whole group chat

Drop a Pouchy agent into a group chat (Telegram group, Slack channel, Matrix room, 飞书群, 钉钉群, …) and the whole room shares one brain: one memory, one conversation history, one persona. The agent reads everything, knows who said what, and speaks only when called — the "shared group assistant" model (coordinate a dinner plan, summarize the thread, run a skill for the group).

Without group mode a connector treats every sender as their own isolated 1:1 conversation (separate memory + history per person) — replies still land in the room, but the agent can never see the thread as a whole. That remains the default; group mode is opt-in per connector — except on the inherently-group transports, whose inbound is a shared room by nature: the live-stream chats (twitch, kick, youtube-live, bilibili-live), groupme (a bot lives inside one group), and the six developer-platform channels (github, gitlab, bitbucket, jira, linear, discourse — an issue/PR/topic comment thread is a room every participant shares). On all eleven, group mode defaults ON (room-keyed, mention-gated — set config.wakeWords), and config.groupMode: false is the explicit 1:1 opt-out.

Enable it

Dashboard → Channels → New connector → "Group mode" checkbox (plus an optional comma-separated wake-word list), or via the Admin API:

curl -X POST https://pouchy.ai/v1/admin/channels \
  -H 'Authorization: Bearer pchy_admin_…' \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "telegram",
    "agentId": "<agentId>",
    "config": {
      "groupMode": true,
      "wakeWords": ["@dinnerbot", "小助手"]
    },
    "secret": { "token": "<bot token>" }
  }'

The same shape works on the owner-auth surface (POST /v1/projects/{projectId}/channels).

Editing an existing connector (change wake words, toggle the mention gate): the dashboard row's Edit config button opens a JSON editor prefilled from the live row — no delete-and-recreate (which would rotate the inbound URL and force re-registering the webhook). The API equivalent:

curl -X PATCH https://pouchy.ai/v1/projects/{projectId}/channels/{connectorId} \
  -H 'Authorization: Bearer <owner token>' \
  -H 'Content-Type: application/json' \
  -d '{ "config": { "groupMode": true, "wakeWords": ["@dinnerbot"], "groupRequireMention": true } }'

Note the config patch REPLACES the stored config object — send the full config (including transport fields like homeserverUrl), not a delta. Room identity survives connector recreation: the instance is keyed on room:{transport}:{roomId}, so deleting and recreating a connector for the same room reattaches to the same shared memory and history.

Config knobs

Key Type Default Meaning
config.groupMode boolean false (true on the inherently-group transports: twitch / kick / youtube-live / bilibili-live / groupme / github / gitlab / bitbucket / jira / linear / discourse) Key the instance on the ROOM instead of the sender. DMs on the same connector keep 1:1 semantics (a message without a room id is never grouped) — see the Matrix caveat below. On an inherently-group transport, set false to opt back into 1:1.
config.wakeWords string[] Case-insensitive containment match that counts as "the agent was called" (e.g. ["@bot", "小助手"]). Complements the transport's native mention signals. Because it is containment (not word-boundary), a wake word that appears inside ordinary chatter fires a billed turn — pick distinctive strings. A bare string is accepted as a one-word list (defensive normalisation; the array form stays the documented shape).
config.groupRequireMention boolean true With the default ON, unaddressed chatter is recorded into the shared history without running the model — the agent sees the whole room but replies only when @mentioned, replied to, or a wake word matches. Set false to answer every message (noisy + every message is a billed turn).
config.botUsername string Telegram: the bot's @username. With it set, only a mention of THIS bot counts as addressed; without it, any @mention fires (the wire doesn't say who was mentioned). Strongly recommended on group-mode Telegram connectors.
config.botUserId string Discord: the bot's user id — a gateway-bridged content message mentioning <@id> counts as addressed. Matrix: the bot MXID (mention + self-echo detection).
config.botOpenId string 飞书 Feishu: the bot's open_id. With it set, only a mention whose mentions[].id.open_id matches the bot counts as addressed; without it, any @mention fires (the wire doesn't say who was mentioned). Strongly recommended on group-mode Feishu connectors.

How the agent experiences the room

  • Every user turn in the shared history carries a speaker label — Alice: let's do Saturday — so the model can address people by name and track positions across members. The label is sanitized before it enters the transcript (newlines/colons stripped, 40-char cap, reserved voices like "System"/"Assistant" get the sender id's tail appended), and the system prompt explicitly tells the model the label is a platform-added display name, not authentication — a member typing extra Name: lines inside one message is treated as that member's own text, never as another speaker.
  • The session carries a group appContext, so the system prompt tells the model it is in a multi-person room and its replies are visible to everyone.
  • Skills and long-term memory work as in 1:1 — the room accumulates group memory (decisions made, preferences mentioned) under its own namespace. In a group room, both paths that write that memory run under the multi-speaker rules — the background extractor and the agent's own remember tool: every stored fact names its subject ("Alice prefers window seats", "the group decided on Saturday" — never a subjectless "prefers window seats"), facts with an ambiguous subject are skipped, and neither writer may store sensitive personal details (health, finances, contact info, private relationships) about a named member. Recall presents room facts under a header that tells the model to apply each fact only to the person it names. Privacy note: the namespace is still shared by the whole room. Facts the agent learns from one member are recallable in any member's turn — do not enable group mode where members telling the agent things in-room must stay private from each other, and treat the room's memory as visible to every member. There is no per-member erasure today; erasing the room instance erases everyone's entangled data at once.

What counts as "addressed"

Transport Native signal Group id source
Telegram any @mention entity, or a reply to a bot message chat.id when chat.type is group/supergroup
Slack app_mention event event.channel (non-im)
Matrix bot's full MXID appears in the body room_id (rooms are the native model)
Discord slash commands are always explicit (always addressed) guild channel_id
Teams any <at> tag in text conversation.id for groupChat/channel
飞书 Feishu a mentions entry for the bot (config.botOpenId); any entry when unset chat_id when chat_type is group
钉钉 DingTalk group bots only receive @-messages → always addressed conversationId when conversationType is 2
LINE wake words only source.groupId / source.roomId
QQ @-messages only reach the bot → always addressed group group_openid / guild channel_id

Wake words cover every transport, including the ones without a native mention signal. Transports not listed (WhatsApp, WeCom, Signal, XMPP, …) don't surface a group id today — on those, a group message falls back to per-sender 1:1 semantics even with groupMode on.

Known caveats, current behavior:

  • Telegram: with config.botUsername set, the mention gate targets the bot exactly (@alice see you at 7 no longer runs a billed turn). Without it, any mention fires — the historical fallback.
  • Teams: mention entities are matched against the bot's own id, so an <at> mention of a colleague no longer triggers the bot; payloads without entities fall back to the bare <at> test.
  • Discord: slash commands are always addressed. Gateway-bridged content messages are addressed only when they mention the configured config.botUserId — otherwise the wake-word gate applies.
  • 飞书 Feishu: with config.botOpenId set, the mention gate targets the bot exactly (@alice 晚上七点见 no longer runs a billed turn on the shared room brain). Without it, any mention fires — the historical fallback.
  • Matrix: every conversation is a room, including DMs — the runtime therefore asks the homeserver for the room's joined-member count (cached ~10 min): ≤2 members (bot + one human) is treated as a DM and keeps full 1:1 semantics (sender-keyed, no mention gate); 3+ is a group. On a homeserver error the room is treated as a group (fail-open to the gated behavior — a blip never turns a group room into an answer-everything DM). A member joining/leaving mid-window is picked up at the next cache refresh.
  • Bot senders: Telegram, Slack, Matrix, Feishu, Teams (28: bot ids), Discord (author.bot) and Messenger/Instagram (is_echo) drop messages authored by bots/apps (loop protection). On the remaining transports, avoid placing two bots in one room with wake words that could match each other's output.

Confirmations in a group

Sensitive actions (credentialed/mutating custom skills, outbound friend messages) go through the same confirm pipeline as 1:1, adapted to IM:

  • The agent's reply asks the requester to reply exactly 确认 / confirm (or 取消 / cancel). There is no card over IM — the keyword reply IS the approval surface.
  • In a group room, only the member whose message triggered the action can resolve it (requester binding), and only the strict keyword set counts — an incidental "ok" typed by someone else never approves anything.
  • Outbound friend messages (send_friend_message / message_friends) are confirm-gated in group rooms — in solo (1:1) instances they auto-run, but in a shared room any member can drive turns, so the send waits for the requester's keyword approval. Read-only built-in skills and credential-free GET-only custom skills still auto-run in rooms.
  • Confirmations recorded by a scheduled turn on a room (a proactive job's action) have no requesting member — ANY member's strict keyword reply can resolve those.
  • Money-class confirmations are refused over chat entirely — they need a stronger surface than an IM keyword (and instances never hold wallet.spend in the first place).
  • The confirmation summary is visible to the whole room (replies are room messages) — don't route actions whose summaries must stay private through a group.

Safety in a group

  • Addressed turns run the full moderation pipeline exactly as 1:1: the classifier, the agent's safety.level / blockedCategories, literal blockedTerms, and the redactPii opt-in.
  • Ambient (unaddressed) messages are screened with the agent's static rules before entering the shared history: a blockedTerms hit drops the row entirely (audited as moderation.blocked_ambient, content-free), and redactPii masks before the store. The LLM moderation classifier does not run per ambient message (ambient's cost contract is "no model/classifier call") — classifier-grade screening happens on the addressed turn that would surface that history.

Limits & billing

  • The room shares one inbound rate bucket (default 60/min, PLATFORM_CHANNEL_RATE_LIMIT) and each sender gets a smaller per-sender bucket inside it (default 15/min, PLATFORM_CHANNEL_SENDER_RATE_LIMIT), so one member flooding the room exhausts their own budget before starving everyone else.
  • A room meters as one instance / one MAU today. If group mode needs per-member pricing, that is a metering change — flag it before relying on the current behavior contractually.
  • Ambient (unaddressed) messages never cost a model call. They do cost storage work (the history append plus the shared pipeline's bookkeeping reads/writes) — bounded per message and capped by the rate buckets above.
  • While the agent is answering one member, another member's addressed message answers 429 — but the text is preserved into the shared transcript first (screened, no model call), so even if the transport never redelivers, the next turn has the context. Transports that DO redeliver get a normal answered retry (the line then appears twice in the transcript — accepted over losing it). Rapid-fire @-ing is still not queued.

Where replies land

Replies post into the room on every group-capable transport (Feishu group replies target the chat, not a DM to the asker). Threading is per-transport: Slack replies in the asker's thread, Teams uses replyToId, QQ replies ride the asker's message, Telegram group replies thread onto the asker's message (reply_parameters, tolerant of a deleted original), and Matrix group-mode replies carry an m.in_reply_to rich reply; Feishu / DingTalk / LINE post plain room messages (no auto-@ of the asker).

Implementation pointers

Runtime: src/lib/server/platform/channels/runtime.ts (search "Group mode"); instance keying room:{transport}:{roomId}; adapter fields roomId / senderName / addressed on InboundMessage (channels/types.ts); speaker-label sanitizer sanitizeSpeakerLabel (exported from runtime.ts); keyword approvals channels/confirm-relay.ts; the room-aware confirm gating rides sharedRoom through runAgentTurnisInstanceAutoRunSocial ($lib/skills/catalog.ts). End-to-end tests: channels/group-mode.test.ts.