2026 03 14 Whistle Group Chat Design
Documentation for Plans
Whistle: Intra-Space Group Chat with Vitto
Session: S218 | Date: 2026-03-14 | Status: Design approved
Goal
Let any user in a space "whistle" a coworker into their conversation with Vitto. The conversation seamlessly upgrades from 1:1 to group. Vitto is always a participant, loads all Dossiers, and adapts its behavior based on conversation dynamics. Works cross-channel (web + WhatsApp + Slack).
Architecture
Two documents, two databases, each doing one thing:
- ChatSession (ai-api) — message store, persistence, Socket.IO broadcast, genome. Gets
participants[],isGroup,groupTypefields. Unchanged for 1:1 chats. - GroupConversation (vitto-api) — orchestration context for Vitto's group behavior, channel routing, whistle metadata. The existing
GroupOrchestrationServiceis reused for external channel group creation (WhatsApp/Slack) when participants are on those channels. The whistle orchestration logic itself is new code — aWhistleOrchestratorServicein vitto-api.
No new collections. No middleman documents.
Canonical write order (cross-DB consistency)
ai-api's ChatSession is canonical. On whistle:
- Write
participants[]+isGroupto ChatSession (ai-api) first - Create/update GroupConversation (vitto-api) second
- If step 2 fails, the group session still functions (messages flow, Socket.IO works). GroupConversation is the intelligence overlay, not the persistence layer.
- A background reconciliation cron (every 5min) checks for ChatSessions with
isGroup: truebut no validgroupConversationIdand creates the missing GroupConversation.
Tech Stack
- Angular 20+, standalone components, signals, OnPush
- NestJS (backend-ai-api, backend-vitto-api, backend-api)
- MongoDB (chat_sessions, group_conversations, user_notification_prefs)
- Socket.IO (ChatGateway /chat namespace)
- Existing: ProactiveDispatcher, Dossier, Genome, cross-channel bridge (S217)
1. Data Model
1.1 ChatSession (ai-api) — 4 new optional fields + 1 sub-document schema
The participants sub-document must be defined as a proper NestJS/Mongoose schema:
@Schema({ _id: false })
export class ParticipantDoc {
@Prop({ required: true })
userId!: string;
@Prop({ required: true })
displayName!: string; // Snapshot at join time (no lookup on render)
@Prop({ required: true, enum: ['originator', 'member'] })
role!: 'originator' | 'member';
@Prop({ required: true })
joinedAt!: number;
@Prop({ required: true, enum: ['full', 'summary', 'none'], default: 'summary' })
historyAccess!: 'full' | 'summary' | 'none';
@Prop()
readCursor?: number; // Last read timestamp (unread badge)
@Prop()
channel?: string; // Preferred channel for this participant
}
New fields on ChatSession:
@Prop({ type: [ParticipantDoc], default: undefined })
participants?: ParticipantDoc[];
@Prop({ sparse: true })
isGroup?: boolean;
@Prop({ enum: ['intra-space', 'open'] })
groupType?: string;
@Prop()
groupConversationId?: string;
// New index:
ChatSessionSchema.index({ 'participants.userId': 1, isGroup: 1, updatedAt: -1 });
Group sessions use context: 'vitto' (already in the enum). No new enum value needed.
1.2 IntelligentMessageDoc (ai-api) — 2 new fields
@Prop()
senderId?: string; // null = Vitto, otherwise participant userId
@Prop()
senderName?: string; // Display name snapshot (no user lookup on render)
Backward compatible: existing messages without senderId render as before (user or assistant based on role).
1.3 Whistle preferences — stored on UserNotificationPrefsDoc (vitto-api)
Whistle preferences live on UserNotificationPrefsDoc in vitto-api's vitto-mind database (same document that already stores preferredChannel, quietHoursStart/End, dailyAlertCap, disabledTriggers). This avoids cross-service reads — vitto-api already has this document in its own MongoDB.
// Add to UserNotificationPrefsDoc schema:
@Prop({ type: Object, default: { availability: 'anyone', overrideDelivery: 'smart' } })
whistlePreferences?: {
availability: 'anyone' | 'admins_only' | 'allowlist' | 'nobody';
allowlist?: string[]; // userIds (only when availability = 'allowlist')
overrideDelivery?: 'smart' | 'always_notify' | 'always_silent';
};
Default: { availability: 'anyone', overrideDelivery: 'smart' }.
The admins_only option checks the whistler's role in space.members[].role — specifically 'owner' or 'admin' roles from the Space schema. This is the space-level role, not the system tier.
1.4 GroupConversation (vitto-api) — 2 changes
// 1. Add link to ai-api ChatSession:
@Prop()
chatSessionId?: string;
// 2. Make externalGroupId optional (was required):
// Changed from: @Prop({ required: true }) externalGroupId!: string;
// To:
@Prop()
externalGroupId?: string; // Only set when an external WhatsApp/Slack group is created
For web-only whistles, externalGroupId is null. When a participant joins from WhatsApp, GroupOrchestrationService.createGroup() is called to create the external channel group and externalGroupId is backfilled.
Already has: participants[], spaceId, channelType, settings, purpose.
2. Whistle Flow
Step 1: Initiation
User says "bring Alex in" (Vitto detects via whistle_user tool) or clicks the whistle button in the chat header. Whistler chooses:
- History access: full / summary / none (default: summary)
- Urgency override: smart / ring / notify / silent (default: smart — Vitto decides)
When choosing full history access, the UI shows a confirmation: "Alex will see the entire conversation history. Continue?" — since the originator is sharing their own 1:1 Vitto conversation.
Step 2: Permission check (vitto-api WhistleOrchestratorService)
- Is the target a member of this space? →
GET /spaces/:spaceId/membersvia backend-api (new endpoint) - Target's whistle preferences →
UserNotificationPrefsDoc.whistlePreferences(local vitto-api read) - If
availability === 'admins_only': check whistler's role inspace.members[]— must be'owner'or'admin' - If
availability === 'allowlist': check whistler's userId inwhistlePreferences.allowlist - Is target in quiet hours? →
dossier.proactivePreferences(and urgency !== 'ring') - If blocked → Vitto tells whistler: "Alex isn't available right now" with reason
- If allowed → proceed
Step 3: Session upgrade (ai-api)
The existing ChatSession is updated (ai-api is canonical):
isGroup = truegroupType = 'intra-space'participants = [{ originator }, { new member with historyAccess }]- System message appended: "Franco whistled Alex into the conversation."
Then vitto-api creates a GroupConversation (follower write):
chatSessionIdlinks back to ChatSessionexternalGroupIdis null (web-only) — backfilled if external channel group is later neededgroupConversationIdis written back to ChatSession
If GroupConversation creation fails, the group session still works. A background cron reconciles orphans every 5 minutes.
Step 4: Smart delivery (vitto-api → ProactiveDispatcher)
Vitto reads the target's Dossier and decides delivery method:
| Signal | Delivery | Example |
|---|---|---|
| Urgency override = ring | All channels + sound | Franco explicitly marked urgent |
| Deploy/prod keyword in conversation | Notify (warning severity) | "The deploy is failing" |
| Normal work discussion | Notify (info severity) | "Want your input on the schema" |
| FYI / low urgency | Silent add | Session appears in sidebar |
Uses existing ProactiveDispatcher with quiet hours, frequency caps, dedup.
Step 5: Target receives whistle
Web: Socket.IO chat:whistled event → whistle-notification component appears (Join / Later / Decline). On join, frontend fetches session and joins Socket.IO room.
WhatsApp: Message via ProactiveDispatcher includes the sessionId in a quick-reply button payload: "Franco wants your input: [context summary]." The S217 cross-channel bridge is extended with a targetSessionId parameter — when the whistle message includes a sessionId, replies route to that specific session instead of resolving by (userId, spaceId).
History access:
full: Target sees all messages from before they joined (with originator confirmation at initiation)summary: Vitto posts a pinned context brief as first visible message (viasummarize_for_joinertool using Genome compiler, personalized with target's Dossier)none: Target only sees messages from join point forward
Step 6: Group conversation
- All messages tagged with
senderId+senderName - Socket.IO broadcasts to BOTH
chat:{sessionId}room AND each participant'suser:{userId}room (not replacing one with the other — ensures all connection types receive messages) - Vitto loads all participant Dossiers into context
- Vitto adapts behavior based on conversation dynamics (see Section 3)
- Anyone can whistle more people in (respecting their preferences)
- Cross-channel: each participant uses their preferred channel, messages merge in one timeline
Step 7: Conversation close → Dossier extraction
Trigger mechanisms:
- Explicit close:
POST /sessions/:id/closeendpoint — any participant can close the group - Inactivity: A NestJS
@Cron('*/5 * * * *')job in ai-api scans group sessions whereupdatedAt < now - 30minand triggers extraction - Manual: Vitto tool
close_groupcalled via natural language ("wrap this up")
Extraction pipeline:
- Genome compiles for the group session
- For each participant, Vitto extracts relevant decisions, facts, and action items via a Haiku LLM call (personalized per participant's Dossier — a developer gets technical decisions, a PM gets timeline/scope items)
- Each participant's Dossier gets enriched with group context via
UserDossierService.addSpaceContext() - Session stays browsable in everyone's sidebar (marked as group, status:
'closed')
3. Vitto Adaptive Behavior
Not a state machine — prompt engineering with conversation dynamics.
Active Participant (default)
- Contributes data, answers questions, provides suggestions
- Loads all participant Dossiers: knows each person's expertise, communication style, preferences
- Adapts tone per person being addressed (Franco: direct, Alex: detailed)
- Trigger: Conversation start, direct questions, tool-worthy requests
Observer
- Fades when humans are flowing well
- Only speaks when directly addressed or detects a factual error
- Trigger: 3+ consecutive human-to-human messages without a question to Vitto
Facilitator
- Summarizes progress, suggests next steps, tracks action items
- Flags when conversation is looping on the same topic
- Trigger: 2min silence, repeated topics, explicit "Vitto, summarize"
Suggestion engine
- Proactively suggests who to whistle based on conversation topic + space member Dossiers
- "Alex has expertise in MongoDB — want me to bring them in?"
- Only suggests when relevant expertise gap is detected
4. Socket.IO Changes
Modified: emitNewMessages() in ChatGateway
When session has participants[], broadcast to BOTH:
chat:{sessionId}room (existing — for clients that have opened this session)- Each participant's
user:{userId}room (new — ensures delivery to all devices)
This is additive — existing broadcast is not replaced.
New events (2)
| Event | Room | Payload | When |
|---|---|---|---|
chat:whistled |
user:{targetUserId} |
{ sessionId, whistledBy, displayName, contextSummary, urgency } |
Someone is whistled |
chat:participant-joined |
chat:{sessionId} |
{ sessionId, userId, displayName, joinedAt } |
Participant accepts whistle |
5. API Endpoints
ai-api (persistence + real-time)
IMPORTANT: GET /sessions/my/groups must be declared BEFORE @Get(':id') in the controller to prevent NestJS from matching "my" as a sessionId parameter.
| Method | Path | Purpose |
|---|---|---|
| POST | /sessions/:id/whistle |
Add participant to session, broadcast chat:whistled |
| GET | /sessions/:id/participants |
List participants with read cursors |
| PATCH | /sessions/:id/read-cursor |
Update read position for unread badges |
| GET | /sessions/my/groups |
All group sessions for authenticated user |
| POST | /sessions/:id/close |
Close group session, trigger Dossier extraction |
vitto-api (intelligence + orchestration)
| Method | Path | Purpose |
|---|---|---|
| POST | /engine/whistle |
Full orchestration: check perms → smart delivery → update session |
| GET | /engine/whistle/suggest/:sessionId |
Vitto suggests who to whistle based on topic |
| PATCH | /engine/whistle/preferences |
Update whistle availability settings (writes to UserNotificationPrefsDoc) |
backend-api (user data)
| Method | Path | Purpose |
|---|---|---|
| GET | /spaces/:spaceId/members |
List space members with roles and basic profile (NEW endpoint) |
6. Frontend Components
New components (4)
| Component | Location | Purpose |
|---|---|---|
whistle-button |
@libs-ai/vitto-chat |
Button in chat header, opens member dropdown |
whistle-modal |
@libs-ai/vitto-chat |
Confirmation with history access + urgency options |
participant-strip |
@libs-ai/vitto-chat |
Avatar strip below header showing participants + online status |
whistle-notification |
@libs-ai/vitto-chat |
Toast/modal for incoming whistles (Join / Later / Decline) |
Modified components (2)
| Component | Changes |
|---|---|
message-bubble |
Show sender avatar + name when isGroup. Color per participant. |
session-list |
Group sessions show avatar stack + group icon overlay. Unread per participant. |
New service
| Service | Purpose |
|---|---|
WhistleService |
Frontend orchestration: call vitto-api /engine/whistle, handle Socket.IO events, manage group state |
7. LLM Tools (vitto-api)
| Tool | Purpose | Params |
|---|---|---|
whistle_user |
Whistle a user into the conversation | targetUserId | targetName, historyAccess?, urgency? |
suggest_whistle |
Proactively suggest who to invite | (no params — reads conversation context) |
summarize_for_joiner |
Generate personalized context brief | targetUserId — uses Genome + target's Dossier |
close_group |
Explicitly close a group conversation | (no params — triggers extraction pipeline) |
8. What We Build vs What We Reuse
New code (WhistleOrchestratorService + supporting)
| Component | What | Why new |
|---|---|---|
WhistleOrchestratorService |
Permission check → delivery → session upgrade | Core whistle logic doesn't exist |
whistle_user tool |
LLM-callable whistle action | New tool plugin |
POST /engine/whistle endpoint |
Orchestration HTTP entry point | New controller route |
POST /sessions/:id/whistle endpoint |
Persistence + Socket.IO for participant add | New controller route |
GET /spaces/:spaceId/members endpoint |
Space member list for UI + permission checks | Missing from backend-api |
| 4 Angular components | Whistle UI surface | No group chat UI exists |
| Group inactivity cron | Triggers Dossier extraction on inactive groups | No existing mechanism |
Reused systems
| Existing System | How It's Used |
|---|---|
| ProactiveDispatcher | Smart whistle delivery (quiet hours, frequency caps, channel routing) |
| Cross-channel bridge (S217) | WhatsApp replies merge into group ChatSession (extended with targetSessionId) |
| Dossier system | User preferences, expertise matching, personality adaptation |
| GroupOrchestrationService | External channel group creation when participants join from WhatsApp/Slack |
| Genome compiler | Context summary for joiners, group session compression |
| Socket.IO ChatGateway | Multi-room broadcast, existing room infrastructure |
| ChatBackendSyncService | Message persistence, retry queue, reconciliation |
| Channel adapters | WhatsApp/Slack/Telegram message delivery |
| UserNotificationPrefsDoc | Storage for whistle preferences (same document as notification prefs) |
9. Future: Open Vitto Groups (separate spec)
The groupType: 'open' field and spaceId: null pattern are reserved for the future "Vitto Groups" product — cross-tenant groups where each user brings their own Vitto. This is a separate spec with multi-agent orchestration, but the data model supports it without schema changes.
A dedicated future spec has been written: docs/superpowers/specs/2026-03-14-vitto-groups-future-vision.md
10. Success Criteria
- A user can whistle a space member into a conversation via UI button or natural language
- Vitto checks permissions (space membership + whistle preferences) and delivers via the smartest channel
- The whistled user can join from web or WhatsApp
- Group messages show sender identity and are broadcast to all participants in real-time
- Vitto adapts its behavior based on conversation dynamics (active → observer → facilitator)
- On conversation close (explicit or 30min inactivity), key facts are extracted into each participant's Dossier
- Group sessions are browsable in every participant's session list
- All existing 1:1 chat functionality is unaffected
- Cross-DB consistency is maintained via canonical write order + background reconciliation