ADR 003: Single Assistant Per Group vs. Multi-Assistant Collaboration¶
Date: 2025-11-10 Status: Accepted Deciders: Engineering Team, Product Lead Related: System Overview, Constitution
Context¶
With multiplayer mini-apps in group chats, we need to decide how assistants participate when multiple users (each with their own personal assistant) are in the same group. Key questions:
- Can multiple assistants coexist in one group?
- Who "owns" the mini-app instance?
- How do cross-assistant interactions work?
- How do users get personalized summaries of group state?
Requirements¶
- Avoid AI Chaos: Multiple AIs responding creates noise and confusion
- Preserve Personalization: Each user has their own assistant relationship (Sage vs. Echo)
- Enable Collaboration: Groups need shared state and coordination
- Privacy Boundaries: Group data separate from personal 1:1 memories
- Smooth Onboarding: Easy to add assistant to existing groups
Decision¶
We will implement Single Assistant Per Group (Host Model) with cross-assistant read-only access for personal summaries.
Core Rules¶
- One Host Per Room
- First assistant added to a group becomes the "host"
- Host manages all group mini-app state and interactions
-
Host's phone number serves as the group orchestrator instance
-
Multi-Assistant Collision Prevention
- If user tries to add second assistant to group:
- New assistant auto-mutes itself
- Sends DM to its owner: "Heads up! Sage is already managing this group. I'll stay in your DMs if you need me 😊"
-
Original host continues operating normally
-
Personal Follow-Up (Cross-Assistant Read)
- Any user can DM their own assistant: "Summarize the Tokyo trip group"
- User's assistant:
- Fetches group state (read-only) via
roomId - Formats summary in user's preferred persona voice (Echo vs. Sage)
- Cannot modify group state
- Fetches group state (read-only) via
-
Example:
-
Host Transfer
- If host leaves group or becomes inactive:
- Any participant can request: "transfer host to [user]"
- New host's assistant takes over orchestration
- Room state preserved across transfer
- Command:
/transfer host @mike(internal handling, not exposed to users)
Alternatives Considered¶
Option 1: Multi-Assistant Collaboration¶
Model: All assistants in group operate simultaneously, coordinate via consensus.
Pros: - ✅ Each user keeps their personalized assistant - ✅ No "host" bottleneck
Cons: - ❌ AI chaos: Multiple AIs responding to same message - ❌ Complex coordination protocol needed - ❌ Users confused about who to talk to - ❌ Duplicate responses clog the chat
Example failure scenario:
User A: "Plan a trip to Tokyo"
Sage (User A's assistant): "Let's do it! Where should we stay?"
Echo (User B's assistant): "Awesome! What are your dates?"
Vex (User C's assistant): "Tokyo! Have you considered flights?"
[User A]: "wtf is happening"
Verdict: ❌ Creates terrible UX, rejected
Option 2: Silent Followers (All Assistants Present, Only One Speaks)¶
Model: All assistants join group, but only one responds publicly. Others listen and update personal context.
Pros: - ✅ All assistants can learn from group interactions - ✅ No visible chaos
Cons: - ❌ Wasteful: Multiple instances processing same events - ❌ Still need coordination for "who speaks" - ❌ Sync complexity if assistants drift - ❌ Privacy leak: All assistants see group messages
Verdict: ❌ Over-engineered, privacy concerns
Option 3: No Group Mode (Users Use Personal Assistants Individually)¶
Model: Each user interacts with their own assistant in 1:1 about the group. No assistant in group chat.
Pros: - ✅ Simple: No group orchestration needed - ✅ Full personalization
Cons: - ❌ Defeats purpose of multiplayer mini-apps - ❌ No shared state visible to all - ❌ Group coordination breaks down (everyone sees different info)
Verdict: ❌ Doesn't solve the problem
Option 4: Single Assistant Per Group (Host Model) ✅ CHOSEN¶
Model: One assistant joins group as host. Others stay in DMs, can read group state.
Pros: - ✅ Clear single voice in group chat - ✅ No AI collision or confusion - ✅ Users can still get personalized summaries via DM - ✅ Simple: One orchestrator instance per room - ✅ Host transfer allows flexibility - ✅ Privacy-preserved: Host assistant doesn't access other users' personal memories
Cons: - ⚠️ Host user has slight power (but can transfer) - ⚠️ Users must remember to DM their own assistant for updates
Mitigation: - Host transfer command available - Personal assistant proactively offers: "Want me to summarize the group trip?" - Group assistant mentions: "Anyone can DM me for updates!"
Verdict: ✅ Best balance of simplicity and functionality
Implementation Details¶
Data Model¶
@dataclass
class GroupRoom:
room_id: str
app_id: str
host_user_id: str # Who added the host assistant
host_assistant_phone: str # The assistant's phone number
participants: List[str] # All group members
created_at: datetime
last_active: datetime
metadata: Dict[str, Any]
@dataclass
class AssistantInstance:
phone_number: str # Assistant's iMessage number
owner_user_id: str # Who this assistant belongs to
persona_id: str # "sage", "echo", etc.
active_rooms: List[str] # Rooms where this assistant is host
Group Initiation Flow¶
# User in group chat: "Sage, help us plan a trip"
async def handle_group_message(message: str, chat_guid: str, sender: str):
# 1. Detect if this is a group chat
participants = await get_chat_participants(chat_guid)
is_group = len(participants) > 2
if not is_group:
# Solo mode (existing)
return await handle_solo_message(message, sender)
# 2. Check if assistant already in group
existing_host = await get_room_host(chat_guid)
if existing_host:
# Assistant already managing this group
return await route_to_host(message, existing_host, chat_guid)
# 3. User wants to add assistant
if "help us" in message or is_assistant_invitation(message):
# Create room with this assistant as host
room_id = await create_room(
app_id="trip_planner",
host_user_id=sender,
host_assistant=get_user_assistant(sender),
participants=participants,
chat_guid=chat_guid
)
# Assistant joins group and introduces itself
await send_to_group(chat_guid,
"Hey everyone, I'll help manage this trip plan! You can add ideas, vote, or check updates here. 🗺️"
)
return room_id
Multi-Assistant Collision Handling¶
async def handle_assistant_added_to_group(assistant_phone: str, chat_guid: str):
# Check if another assistant is already host
existing_host = await get_room_host(chat_guid)
if existing_host and existing_host != assistant_phone:
# Collision detected
owner_user_id = await get_assistant_owner(assistant_phone)
# New assistant mutes itself
await mark_assistant_muted(assistant_phone, chat_guid)
# DM the owner
await send_dm(owner_user_id,
f"Heads up! {existing_host.persona_name} is already managing that group. "
f"I'll stay in your DMs if you need me 😊 You can always DM me for a summary!"
)
# Don't respond in group
return
Personal Summary (Cross-Assistant Read)¶
async def handle_personal_summary_request(user_id: str, query: str):
# User DMs their assistant: "Summarize the Tokyo trip"
# 1. Parse query to find room
room_id = await extract_room_from_query(query) # "Tokyo trip" → room_id
if not room_id:
return "Which group are you asking about? I can summarize any group you're in!"
# 2. Verify user is participant
is_participant = await room_manager.is_participant(room_id, user_id)
if not is_participant:
return "I don't think you're in that group 🤔"
# 3. Fetch group state (read-only)
state = await state_coordinator.get_current_state("trip_planner", room_id)
# 4. Format in user's persona voice
user_persona = await get_user_persona(user_id)
summary = await persona_styler.format_summary(state, user_persona)
return summary
Host Transfer¶
async def handle_host_transfer(room_id: str, current_host: str, new_host_user: str):
# Triggered by: "transfer host to @mike"
# 1. Verify requester is current host or admin
if not await is_host_or_admin(current_host, room_id):
return "Only the current host can transfer ownership"
# 2. Get new host's assistant
new_host_assistant = await get_user_assistant(new_host_user)
if not new_host_assistant:
return f"{new_host_user} doesn't have an assistant set up yet"
# 3. Update room ownership
await update_room_host(room_id, new_host_user, new_host_assistant)
# 4. Notify group
await send_to_group(room_id,
f"Host transferred to @{new_host_user}! Their assistant is now managing this room."
)
# 5. Notify old and new hosts
await send_dm(current_host, "You've transferred host of the Tokyo trip. You can still participate!")
await send_dm(new_host_user, "You're now host of the Tokyo trip group! I've got it from here 💪")
Message Types¶
New Message Types for Group Mode¶
class GroupMessageType(Enum):
# Initialization
GROUP_INITIATION = "group_initiation" # User adds assistant to group
ASSISTANT_JOIN = "assistant_join" # Assistant confirms joining
ASSISTANT_COLLISION = "assistant_collision" # Multiple assistants detected
# Interactions
POLL_VOTE = "poll_vote" # User votes in group poll
ACTIVITY_UPDATE = "activity_update" # Activity added/removed
SUMMARY_POST = "summary_post" # Assistant posts summary
# Management
HANDOFF_REQUEST = "handoff_request" # Request to transfer host
HANDOFF_COMPLETE = "handoff_complete" # Host transferred
# Personal
PERSONAL_SUMMARY_REQUEST = "personal_summary_request" # User DMs their assistant
Scalability Implications¶
Container Orchestration¶
# Deployment model per assistant phone number
Service: group-orchestrator
Instances: 1 per active assistant phone number
Scaling: Auto-scale based on active room count
Resources: 512MB RAM, 1 vCPU per instance
Example:
- Sage's assistant (+15551234567): Handles 5 active rooms
- Echo's assistant (+15559876543): Handles 3 active rooms
- Each assistant runs in its own container
- Container lifecycle tied to last activity (5-min scale-down)
Load Distribution: - 1000 rooms ÷ 100 active assistants = ~10 rooms per assistant - Each room: 5-10 participants - Load balancing: Rooms assigned to assistant that initiated them
Cost: - Active assistant container: $0.05/hour - 100 concurrent assistants: $5/hour = \(3,600/month at scale - Cost per room: ~\)3.60/month (assuming 10 rooms per assistant)
Rate Limiting & Digest Mode¶
class RateLimiter:
async def check_message_rate(self, room_id: str) -> bool:
# Get recent message count
window = 60 # seconds
recent_messages = await count_messages(room_id, since=time.time() - window)
threshold = 20 # messages per minute
if recent_messages > threshold:
# Activate digest mode
await enable_digest_mode(room_id)
await send_to_group(room_id,
"Whoa, lots happening! I'll batch my updates for the next few minutes so we don't clog the chat 😅"
)
return False # Don't send immediate response
return True # OK to respond
# Digest mode: Buffer updates, send every 5 minutes
User Experience Examples¶
Example 1: Clean Group Initiation¶
[Group Chat: Alice, Bob, Charlie]
Alice: "Sage, help us plan our Tokyo trip"
Sage (added to group): "Hey everyone, I'll help manage this trip plan!
You can add ideas, vote, or check updates here. 🗺️"
Bob: "Add teamLab Museum"
Sage: "Added! Want to vote on it?
[Yes] [No] [Maybe]"
Charlie: Yes
Sage: "1 vote for teamLab! Waiting on Alice and Bob..."
Example 2: Assistant Collision (Avoided)¶
[Group Chat: Alice, Bob]
Alice adds Sage to group
Sage: "I'll help you plan! What's the trip?"
Bob tries to add his assistant (Echo)
Echo (via DM to Bob): "Heads up! Sage is already managing that group.
I'll stay in your DMs if you need me 😊"
[Group chat remains clean - only Sage responds]
Example 3: Personal Summary¶
[Bob's 1:1 DM with Echo]
Bob: "Summarize the Tokyo trip group"
Echo: "The group's making moves! You all voted on teamLab Museum (4-1)
and locked in Hotel B for ¥15k/night. Total expenses so far: ¥45k.
Your share: ¥15k (3 nights hotel + 2 activities).
Want me to remind you about the cancellation deadline?"
Example 4: Host Transfer¶
[Group Chat]
Alice: "I'm going offline for a week. @Bob can you take over?"
Alice: "transfer host to @bob"
Sage: "Host transferred to @Bob! His assistant is now managing this room."
[Bob's DM with Echo]
Echo: "You're now host of the Tokyo trip group! I've got it from here 💪"
Privacy & Security¶
Group Data Isolation¶
# When host assistant reads group state
async def get_group_state(assistant_phone: str, room_id: str):
# 1. Verify assistant is host
is_host = await room_manager.is_host(room_id, assistant_phone)
if not is_host:
raise PermissionError("Only host can access group state")
# 2. Return group state (no personal memories)
return await state_coordinator.get_current_state(room_id)
# When personal assistant reads for summary (read-only)
async def get_personal_summary(user_id: str, room_id: str):
# 1. Verify user is participant
is_participant = await room_manager.is_participant(room_id, user_id)
if not is_participant:
raise PermissionError("User not in room")
# 2. Return sanitized state (no personal assistant access)
return await state_coordinator.get_persona_snapshot(room_id)
Key Invariants: - ✅ Host assistant NEVER accesses personal 1:1 memories of participants - ✅ Personal assistants can read group state but NOT modify - ✅ Group state stored separately from user Vault memory - ✅ No cross-pollination between group and personal contexts
Consequences¶
Positive¶
- Simple UX: One voice in group, no AI chaos
- Clear Ownership: Users know who manages the group
- Personalization Preserved: Users can always DM their own assistant
- Scalable: One container per assistant, not per user
- Privacy-Safe: Group and personal data isolated
Negative¶
- Power Asymmetry: Host has slight advantage (but can transfer)
- Discovery Friction: Users must remember to DM their assistant
- Host Dependency: If host leaves, need explicit handoff
Neutral¶
- Container Costs: More expensive than shared orchestrator, but manageable
- Assistant Variability: Different groups have different assistant personas (Sage vs. Echo)
Validation¶
Success Criteria¶
- Zero AI Collisions: No cases of multiple assistants responding in same group
- <5% Host Transfer Rate: Most groups stay with original host
- >80% Personal Summary Adoption: Users discover they can DM their assistant
- Clean Group Chat: No technical errors or confusion about "who is talking"
Metrics to Monitor¶
- Assistant collision attempts (should be ~0)
- Host transfer frequency
- Personal summary requests vs. group interactions
- User satisfaction ("group assistant was helpful" rating)
References¶
- System Overview
- Room Manager Component
- Refactoring Plan
Revision History¶
- 1.0 (2025-11-10): Initial decision based on group mode PRD