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:

No new collections. No middleman documents.

Canonical write order (cross-DB consistency)

ai-api's ChatSession is canonical. On whistle:

  1. Write participants[] + isGroup to ChatSession (ai-api) first
  2. Create/update GroupConversation (vitto-api) second
  3. If step 2 fails, the group session still functions (messages flow, Socket.IO works). GroupConversation is the intelligence overlay, not the persistence layer.
  4. A background reconciliation cron (every 5min) checks for ChatSessions with isGroup: true but no valid groupConversationId and creates the missing GroupConversation.

Tech Stack


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:

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)

  1. Is the target a member of this space? → GET /spaces/:spaceId/members via backend-api (new endpoint)
  2. Target's whistle preferences → UserNotificationPrefsDoc.whistlePreferences (local vitto-api read)
  3. If availability === 'admins_only': check whistler's role in space.members[] — must be 'owner' or 'admin'
  4. If availability === 'allowlist': check whistler's userId in whistlePreferences.allowlist
  5. Is target in quiet hours? → dossier.proactivePreferences (and urgency !== 'ring')
  6. If blocked → Vitto tells whistler: "Alex isn't available right now" with reason
  7. If allowed → proceed

Step 3: Session upgrade (ai-api)

The existing ChatSession is updated (ai-api is canonical):

Then vitto-api creates a GroupConversation (follower write):

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:

Step 6: Group conversation

Step 7: Conversation close → Dossier extraction

Trigger mechanisms:

Extraction pipeline:

  1. Genome compiles for the group session
  2. 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)
  3. Each participant's Dossier gets enriched with group context via UserDossierService.addSpaceContext()
  4. 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)

Observer

Facilitator

Suggestion engine


4. Socket.IO Changes

Modified: emitNewMessages() in ChatGateway

When session has participants[], broadcast to BOTH:

  1. chat:{sessionId} room (existing — for clients that have opened this session)
  2. 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

  1. A user can whistle a space member into a conversation via UI button or natural language
  2. Vitto checks permissions (space membership + whistle preferences) and delivers via the smartest channel
  3. The whistled user can join from web or WhatsApp
  4. Group messages show sender identity and are broadcast to all participants in real-time
  5. Vitto adapts its behavior based on conversation dynamics (active → observer → facilitator)
  6. On conversation close (explicit or 30min inactivity), key facts are extracted into each participant's Dossier
  7. Group sessions are browsable in every participant's session list
  8. All existing 1:1 chat functionality is unaffected
  9. Cross-DB consistency is maintained via canonical write order + background reconciliation