Skip to content

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:

  1. Can multiple assistants coexist in one group?
  2. Who "owns" the mini-app instance?
  3. How do cross-assistant interactions work?
  4. 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

  1. One Host Per Room
  2. First assistant added to a group becomes the "host"
  3. Host manages all group mini-app state and interactions
  4. Host's phone number serves as the group orchestrator instance

  5. Multi-Assistant Collision Prevention

  6. 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 😊"
  7. Original host continues operating normally

  8. Personal Follow-Up (Cross-Assistant Read)

  9. Any user can DM their own assistant: "Summarize the Tokyo trip group"
  10. 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
  11. Example:

    Jenny (to her assistant Echo): "What's the status on the Tokyo trip?"
    Echo: "The group's making moves! You all voted on teamLab Museum (4-1)
           and locked in Hotel B. Expenses at ¥45k total so far. Want me
           to break down your share?"
    

  12. Host Transfer

  13. 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
  14. 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

  1. Zero AI Collisions: No cases of multiple assistants responding in same group
  2. <5% Host Transfer Rate: Most groups stay with original host
  3. >80% Personal Summary Adoption: Users discover they can DM their assistant
  4. 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