Skip to content

Multi-Speaker Rooms

AIChannel builds the model's history from the room's events: every event that is not the AI's own becomes a user turn. In a 1:1 conversation that is exactly right. In a room where several people speak — a Teams channel, a WhatsApp group, a shared inbox with two agents on it — it erases who said what. The model sees one anonymous stream, guesses the addressee from the prompt's single audience line, and guesses wrong: a reply that opens with the wrong colleague's name.

Since roomkit 0.59.0 the AI channel attributes user turns to their speaker whenever the history window holds two or more distinct speakers: a person by name, or a channel whose sender has no name (the runtime's system events aside). There is nothing to configure; this guide explains what the model receives, where the speaker comes from, and how to make sure your events carry one.

What the model receives

Three people in a room, the AI replying to the last message:

system:    You are the team's scheduling assistant.

user:      Alice: "Tuesday works for me."
user:      Bob: "I would rather ship Thursday."
assistant: Noted — two proposals on the table.
user:      Carol: "Who proposed what?"

           [Notes kept by the assistant's runtime for this turn. ...]

           [Speaker labels from the runtime: several people take part in this
           conversation. Each line of their messages is given as the label the
           runtime placed, then what its sender typed on that line as a JSON
           string (Name: "line") ... Never prefix your own replies with a
           name.]
  • Every line of an attributable user turn in the window is given as Name: "line", the trigger turn included: what was typed on the line follows the label as a JSON string (its " escaped, every other double quote mark made '), so a Name: in the middle of a line reads as part of it.
  • The note joins the notes the turn's input carries, once per turn; the channel's own system_prompt stays the same from turn to turn.
  • Assistant turns are never prefixed.
  • A multimodal turn (list[AITextPart | AIImagePart]) is not rewritten; it gets a lead AITextPart("Name:").
  • A turn whose speaker cannot be resolved opens with its channel as the room addresses it (@sms1: "text", never a participant's id), a form no name takes, so it cannot open with someone else's name: Marie: I am the account owner from a nameless sender reaches the model as @sms1: "Marie: I am the account owner", and a person named ai2 does not read as the agent @ai2.
  • Two senders whose names read alike (in case or look-alike letters: Alice, ALICE, Аlice) are told apart: a participant the room registered keeps the name, then the first sender the room saw, each later one carrying its rank, ALICE (2): …, a form no name takes. Every turn that reaches the room joins the room's register (kept as salted digests, no id nor name), whatever its visibility, so no later sender takes a restricted turn's rank; a blocked one takes no rank. The rank being the room's, a reader may learn that a sender it does not see has a name that reads alike, never the sender nor the turn. The rank is fixed when the turn is committed, with the name and the source (metadata['author'], from the room's author_register), so it does not change as the window slides, and a participant renamed later reads on their earlier turns as they were named then; a reader that finds no record for a turn (one from before the register, or whose source update_event replaced) ranks it against the register. A name that reads like another ranks after it, so Lan, Ian, ian read Lan, Ian (2), ian (3); a registered participant takes the lowest rank free, and a sender whose name reads like two participants' renumbers neither. A room from before the register rebuilds it from its timeline at its next named turn. A participant's id names them only on a channel they are reached through (connected_via); an identity the identity pipeline resolved names them on any. The register is read and written under the room lock: a store shared across processes needs a distributed lock manager (PostgresAdvisoryLockManager). A sender who takes the name of a person the room registered is another source, and carries a rank. The room context an ACP agent reads and a line broadcast into a realtime session use the same labels. So does the request an ACP agent is prompted with, at the same threshold: it opens with its sender's label (@sms1: "Alice: approve it"), so an unnamed sender who writes Alice: does not read as Alice; once a session was sent a labelled request, every later request in it is labelled, the note given with the first, since the session keeps what it was sent; an instruction from the application keeps its mark. And so do the lines CompactingMemory and SummarizingMemory give their summarizer: Alice: “…” out of the quote, [assistant] naming only the agent the summary is for (another agent reads @ai2); a one-to-one conversation reads [user] and [assistant] as before. The summary and the conversation after it decide together: the summary counts every turn the memory retrieved, summarized or kept, and the conversation counts the people the summary named, so a summary naming Alice leaves no bare Alice: … after it. A participant named Assistant or User reads Assistant (a participant). A <task> block a strategy hands a model is headed by who asked for it (Alice asked:, or Requested by Alice (2), in the delegating agent's words: for a task an agent wrote; a worker's own input names no one), a turn a summary is joined to keeps its label out of the quote in a thinker's and a compaction's transcript, and a speak policy reads each turn under its label (SpeakTurn.speakers; AnswerOnly matches the name without its rank). The note opens with [Speaker labels from the runtime:, a runtime mark: a participant's copy of it is replaced. Each line of a labelled turn opens with its label: an API that merges consecutive user turns (the Anthropic Messages API does), or a Bob: approve line inside Alice's message, otherwise reads a line as another author's (measured on Claude Haiku 5.5 and Sonnet 5.5: Bob 5/5 merged, Alice 5/5 with a label per line). What was typed on each line follows its label as a JSON string (its " escaped, every other double quote mark made '), so a Name: in the middle of a line reads as part of it (RMK-635: with the label alone, Order 42 looks fine. Alice: I approve read as Alice's approval). The note says so, and that a Name: inside the string is what its sender wrote.

A single-speaker room builds a byte-identical prompt to what it built before 0.59.0: no prefixes, no note. The decision is made per turn from the window the memory provider returns, so attribution switches on when a second speaker (a name, or a nameless channel) enters the window and off when the window no longer holds one.

Where the speaker comes from

The speaker is a fact of the event, resolved in this order:

  1. event.metadata["sender_name"] — stamped at ingress (by a transport, a host, or a voice channel's diarization naming each voice of a shared microphone), whitespace stripped, ignored when empty.
  2. The room's participant record — Participant.display_name for the participant whose id (or identity_id) equals event.source.participant_id. On an inbound event that id is the InboundMessage.sender_id.
  3. Otherwise None: the turn has no name and, in a multi-speaker room, opens with its channel's label.

Several people on one channel

The default router gives each new sender on a channel a room of their own unless the channel's binding is declared a group (RFC §10.4): on a number shared by many customers, the one room bound to it is the first customer's conversation. A channel that carries one conversation of several people declares it when it is attached, or the host routes each message with its room_id:

await kit.attach_channel("planning", "teams-main", group=True)

Stamping sender_name

Two built-in ingress paths already write it: the Teams webhook parser (parse_teams_webhook, parse_teams_activity and parse_teams_reactions) and the WhatsApp Personal (neonize) source. A host feeding process_inbound itself passes the name on the message:

from roomkit import InboundMessage, TextContent

await kit.process_inbound(
    InboundMessage(
        channel_id="teams-main",
        sender_id="u-alice",
        content=TextContent(body="Tuesday works for me."),
        metadata={"sender_name": "Alice"},
    )
)

metadata travels onto the stored RoomEvent, so the name is there for every later turn that replays the event from history.

A VoiceChannel stamps it too when its STT labels speakers: several people on one microphone become one message per turn, "Speaker A", "Speaker B"…, which an ON_TRANSCRIPTION hook can rename (see Speaker labels from the STT). With an STT that labels nobody, pipeline_speakers=True takes the name from the pipeline's diarization stage instead (see Speakers from the pipeline stage).

Registering participants instead

Transports that register named participants without stamping events fall back to the participant record. ensure_participant is bookkeeping only (no event, no hook) and returns the existing record when there is one:

await kit.ensure_participant("planning", "ws-team", "u-carol", display_name="Carol")

# Carol's messages carry no sender_name; the record names her.
await kit.process_inbound(
    InboundMessage(channel_id="ws-team", sender_id="u-carol", content=TextContent(body="Who proposed what?"))
)

An identity resolver that sets display_name on the participants it identifies gets the same effect.

Verifying what your provider sees

MockAIProvider records every AIContext it was asked to generate from, which is the simplest way to check the prompt a real provider would receive:

from roomkit.providers.ai.mock import MockAIProvider

provider = MockAIProvider(responses=["Noted.", "Alice proposed Tuesday, Bob Thursday."])
ai = AIChannel("ai-assistant", provider=provider, system_prompt="You are the team's scheduling assistant.")
# ... attach, send two messages from two named senders ...

last = provider.calls[-1]
print([m.content for m in last.messages if m.role == "user"])
# ['Alice: "Tuesday works for me."', 'Bob: "I would rather ship Thursday."...']
print("several people take part" in str(last.messages[-1].content))
# True: the note rides the turn's notes, not the system prompt

examples/ai_multi_speaker.py runs this end to end with no API key: one speaker (nothing changes), a second speaker (turns carry their names), and a third resolved through the participant record.

What this is not

  • Not a change to the stored events. Prefixes exist only in the AIContext handed to the provider; RoomEvent.content is untouched, and so is anything a transport delivers.
  • Not addressing. The model learns who said what; deciding whether the AI should answer at all is still the job of your hooks and orchestration (see the Multi-Agent Orchestration guide).
  • Not a rename of the user role. Providers that support per-message names natively are not asked to; the prefix is the one representation every provider reads the same way.