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 extraName: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
remembertool: 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 |
| @-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.botUsernameset, the mention gate targets the bot exactly (@alice see you at 7no 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.botOpenIdset, 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.spendin 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, literalblockedTerms, and theredactPiiopt-in. - Ambient (unaddressed) messages are screened with the agent's static
rules before entering the shared history: a
blockedTermshit drops the row entirely (audited asmoderation.blocked_ambient, content-free), andredactPiimasks 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
runAgentTurn → isInstanceAutoRunSocial ($lib/skills/catalog.ts).
End-to-end tests: channels/group-mode.test.ts.