Engineering
Turn-Taking in AI Group Chat: How Two Characters Share One Thread
Two characters in one thread is easy to build badly. Here is the decision ladder we shipped — and why four of its five rungs never call a model.
August 15, 2026 · 9 min read
One-on-one chat with a language model is a solved shape. You keep a message list, you append, you stream the reply. Put a second character in the same thread and a question appears that has no obvious default: when the user says something, who answers?
We shipped multi-character rooms on AmorLink earlier this year. What follows is the turn-taking logic we ended up with, why most of it is deliberately not a model call, and the context problem that turned out to be harder than the routing.
The two obvious approaches both fail
Everyone answers every message.This is the first thing anyone builds, and it reads like a press conference. Ask "how was your day?" and you get two paragraphs stacked on top of each other, neither aware of the other. It also doubles your inference cost and your time-to-first-token on every single turn, forever.
Pick at random.Cheaper, and worse in a more annoying way: the user types "Iris, what do you think?" and the other character answers. One misfire like that and the illusion is gone for the rest of the session. Nothing about a random policy improves with scale.
The useful reframe is that turn-taking is not one problem. It is a stack of them, and the great majority are unambiguous — a name is right there in the message, or the message is three characters long. Only a minority genuinely require judgment. So the policy should be a ladder that spends nothing on the easy cases and reserves the model for the hard ones.
The ladder
Five rungs, cheapest first. The first one that matches wins.
1. Exactly one member named -> that member, solo
2. Both named / room addressed -> both, in mention order
3. Short message after a reply -> the last speaker continues
4. LLM director -> {responder, chimeIn} micro-call
5. Anything else / director died -> least-recent speaker, soloRungs 1 to 3 and rung 5 are string operations. In normal use the model is consulted on a minority of turns, and never on the turns where the answer was obvious anyway.
1. Somebody's name
Scan the message for each member's first name on word boundaries and keep the hits in the order they appear. Order matters: if the user names both characters, the reply order should follow the sentence rather than an arbitrary internal ordering.
The boundaries are the part worth getting right. A naive message.includes(name) matches "Iris" inside "irises" and "Mei" inside "Meiko". We use a Unicode-aware boundary rather than \b, because \b is defined on ASCII word characters and mis-fires the moment a character is called Zoë or Anaïs:
const re = new RegExp(
`(?:^|[^\\p{L}\\p{N}])${escaped}(?=$|[^\\p{L}\\p{N}])`,
'iu',
);2. Addressing the room
"You two", "both of you", "everyone" — the user is talking to the room, so both characters reply. This pattern is kept deliberately conservative. An early draft matched a bare "guys", which cheerfully hijacked sentences like "guys like him never text back" — a message aimed at one character, answered by two. Now the casual plurals only count with a you attached.
When the room is addressed, the character who spoke least recently goes first. Over a long conversation that quietly equalises floor time without anyone tracking a counter.
3. The short follow-up
A very short message immediately after a character spoke is almost always a continuation of that exchange rather than a new opening. "yes", "haha why?", "no way". Bouncing the floor to the other character there is jarring — you answered someone and a third party replied.
Our threshold is 25 characters or three words, and it only applies when a character actually spoke last. It is a blunt rule that is right most of the time, which is exactly the trade you want on a rung that costs nothing.
4. The director
What survives to rung 4 is genuinely ambiguous: a full sentence, no names, not a follow-up. "I had the worst day at work." Which of these two characters is that for? Now a model earns its keep — but on a tight leash.
const raw = await this.ai.generateResponse(
[{ role: 'user', content: prompt }],
{ maxTokens: 30, temperature: 0, purpose: 'classify_multi',
timeoutMs: 5000, userId },
);Thirty tokens, temperature zero, five-second ceiling. The prompt hands it the two personas labelled A and B, who spoke last, the user message, and asks for one JSON object: {"responder":"A"|"B","chimeIn":true|false}.
chimeIn is the interesting field. It is what lets the second character react without being asked — the thing that makes a room feel like a room rather than two parallel DMs. Left undirected, a model says yes to that far too often, so the prompt carries an explicit calibration: roughly one message in three. It is a soft instruction and it is obeyed loosely, which is fine. The requirement is "sometimes", not a rate.
Parse defensively. Take the first {...} blob, check that responder is literally A or B, treat chimeIn as true only on a real boolean, and return null on anything else. Null is not an error path here — it falls through to rung 5 and the user never learns that a model was involved.
5. Round-robin
Least-recent speaker, solo. This is where every failure lands: the director timed out, the provider is down, the JSON was prose, the message defeated every heuristic. The room keeps working. Degrading to a fair default costs one slightly-off speaker choice; degrading to an error costs the conversation.
Replies generate one after another, not at once
When both characters are speaking, the obvious optimisation is to generate the two replies in parallel and cut the wait in half. Do not.
Generate them sequentially, and put the first character's finishedreply into the second character's context before generating hers. That single ordering decision is most of what separates a group chat from two monologues. The second character disagrees with the first, picks up her joke, or tells her she is wrong — because she can actually see what was just said.
Run them in parallel and both characters answer the user while ignoring each other, which is precisely the press-conference failure from the top of this article, arrived at by a different route. You pay for it in latency, additively. It is worth every millisecond.
The context problem nobody warns you about
Here is the part that took longest. A chat model has exactly oneassistant identity. There is no role for "a different character in this conversation". So when you build the context for character B, what role does character A's last line go in?
Not assistant — that tells B those were her own words, and she will continue them as if she said them. The answer is to give each character a private view of the same transcript:
for (const msg of history) {
if (msg.role === 'assistant' && msg.characterId === selfId) {
ctx.push({ role: 'assistant', content: msg.content }); // her own
} else if (msg.role === 'assistant' && msg.characterId) {
ctx.push({ role: 'user',
content: `${nameOf(msg.characterId)}: ${msg.content}` });
} else {
ctx.push({ role: 'user', content: `${userName}: ${msg.content}` });
}
}Her own lines arrive as assistant. Everyone else's — the other character and the human alike — arrive as user, name-prefixed. From B's side the room looks like a single interlocutor who sometimes speaks as Elena and sometimes as the user, and that is a shape models handle well. The same history is rebuilt per speaker, which costs a little memory and saves a great deal of confusion.
One prompt rule you have to shout
Given a transcript with two named characters in it, a model will happily write both parts. It will produce your reply and then helpfully draft the other character's response to it. Politeness does not prevent this; an explicit prohibition mostly does:
- Only ever speak as ${selfName}. NEVER write ${otherName}'s dialogue,
actions, or thoughts — not even one line.
- Lines from the others appear prefixed with their name.
- Do not prefix your reply with your own name.That last line matters more than it looks. Feed a model name-prefixed dialogue and it will mirror the format back, so every reply arrives as "Elena: …" and your UI renders the name twice.
What we would tell you to copy
- The ladder shape, not our thresholds. Cheap deterministic rules first, a small model call for the genuine ambiguity, a fair default underneath everything. Twenty-five characters and three words are ours; yours will differ.
- Record how each decision was made. Every decision carries a
decidedBytag — mention, group_address, continuation, director, fallback. It goes in the logs and the tests assert on it. When someone reports that the wrong character answered, that field turns a debate into a lookup. - Make the director optional by construction. If removing the model call leaves a system that still works — worse, but works — you can ship it before the provider is reliable, and it survives the day the provider is not.
The honest limitation: this is a two-member design. The ladder generalises, but the director prompt is written around a literal A and B, and a room of four needs a different shape for that rung — likely ranked scoring rather than a binary pick. We have not built it, so we are not going to pretend to advise on it.
Group rooms are live on AmorLink: two companions, one thread, taking turns.