- who gets answered, so the agent stays out of a conversation it was not invited into
- what stops playback, so “stop” stops it whether or not the model would have chosen to
@komaa/standin-sdk is the import.
How the SDK knows it is a group call
isMeetingThread(threadId) is the primary signal, and it is a string test:
19:. A 1:1 call has no such thread at all, so the presence of one is the signal. It reaches you as session.start.threadId, on every call, before the caller has said a word. See Call handler.
That is not a hypothetical. The same mistake in the recap path sent every meeting recap to one attendee’s private chat instead of to the meeting it summarised, because the count was pinned at 1 there too. See Meeting recap.
noteParticipants(count) is the second signal, and it is deliberately weaker:
1 arriving on a meeting thread adds nothing and takes nothing away: gate.isGroup stays true. The count is the signal that goes missing, so it may add certainty and must not remove it.
isGroup is true when either signal says so: a meeting thread, or a count of two or more. That covers the group call that is not a meeting thread as well as the meeting whose count never came.
Context sentences arrive at onContext, which is also where the recording status and keypad presses land. The count behind the sentence is on the session as participantCount, so there is nothing to parse out of the prose. See Call handler.
Wake phrases and the follow-up window
wakePhrases is the only required field.
decide(transcript, nowMs) returns a readonly GateDecision with two fields:
addressed is reported separately from respond rather than folded into it. A turn inside the window is answered without having been addressed, and only an addressed turn reopens the window. Those are different facts about the same turn, and conflating them costs you both: you cannot log who actually named the agent, and a follow-up would keep the floor open forever by being answered.
The window is stored as a timestamp rather than a latched boolean. A missed wake phrase therefore self-heals by the clock instead of stranding the agent silent, or talkative, for the rest of the meeting.
nowMs is yours to supply, and the gate never reads a clock itself. It only ever subtracts one of your timestamps from another, so any consistent millisecond source works: the SDK’s own LiveKit plugin passes Date.now(), and performance.now() is the monotonic option if you would rather a system clock adjustment could not open or close the floor. Pick one and use it for every decide on that call. It also makes the whole decision testable without waiting twelve seconds.
requireAddress: false turns the gate off entirely and answers everything, on a meeting thread or not.
On a 1:1 call the gate returns respond: true for every turn, and addressed still tells you truthfully whether the caller used your name. That is worth having even when nothing is being gated.
A gate with no phrase never opens
gate.active is the question “is this gate muting anything right now?”, and it is false unless all three of these hold: the call is a group, requireAddress is on, and at least one non-blank wake phrase is configured.
Matching a name
isAddressed(transcript, wakePhrases) is a case-insensitive match on word boundaries, not substrings.
Choose a wake phrase that is not an ordinary word in the language the call will be held in. Word boundaries stop
assistant matching assistants; they do not stop a name that happens to be a common noun from matching every time somebody uses it.Stop means stop
isVerbalInterrupt(text, wakePhrases?) matches the whole normalised utterance, never a substring. “stop by the store” is a sentence somebody said in a meeting, and a substring rule would cut the agent off mid-answer every time it came up. Whole-utterance is the rule that makes this safe to run on every turn.
Before the comparison the utterance is normalised: lowercased, apostrophes deleted rather than split on so that's becomes thats, combining marks and the Arabic tatweel deleted so a vocalised تَوَقَّف matches توقف, and everything that is not a letter collapsed to a space. Letters in any script survive, which is what lets an Arabic interrupt cut as instantly as an English one.
Then filler and wake phrases are peeled off both ends, repeatedly until nothing changes, because the name can sit outside the filler or inside it and both orders are things people say. Pass your wake phrases in, or the name you chose will be the thing that defeats the match.
The second parameter is typed
string[], while GroupGate.wakePhrases is readonly string[]. Handing the gate’s own list straight in is a compile error. Spread it: isVerbalInterrupt(text, [...gate.wakePhrases]).stop, stop it, stop talking, wait, hold on, hang on, never mind, pause, one second, توقف, خلاص, لحظة and their close neighbours. The set is a module-private constant in the gate module and there is no hook to extend it, so a phrase your callers use and the set does not is a wrapper around isVerbalInterrupt, not a configuration key.
Two consequences worth knowing before you rely on it. The wake phrase on its own is an address and not a cut, because "assistant?" peels down to nothing and nothing never matches. And phrases that are merely close to the set are not in it: "could you stop" returns false, because widening the set to catch it is how a whole-utterance rule turns back into substring matching.
An interrupt is worth nothing on its own. Pair it with session.cancelPlayback(). onCallerTurn below is your own method and not a CallHandler one: wire it to whatever your provider calls a finished caller transcript. Nothing in Call handler delivers a transcript, because only your provider produces one, so a method named for it on a handler is never called by anything.
cancelPlayback() first, because it is the only lever that un-sends audio StandIn already holds. Cancelling the model first stops it producing more, and the caller still hears every buffered sample of the answer they just interrupted. A caller who has said “stop” has stopped listening, and several seconds of the agent carrying on afterwards reads as the agent ignoring them. See Barge-in.
Suppress the reply to the interruption itself too. “stop” does not want an answer, it wants silence, and an agent that says “of course, I will stop” has not stopped.
Where to call it
On a finished transcript, once, before you spend a model call.isAddressed and let only that call decide: because the window is a timestamp, a stamp can only ever open the floor, never close it.
gate.isGroup is true and create the response yourself when decision.respond is true. A response cancelled after it has started generating is one the caller has already heard the beginning of, and a meeting hears the agent start to speak and then stop, which is more disruptive than either answering or staying quiet.
There is one thing to check before any of this: whether your provider gives you a caller transcript at all. Without one there is nothing to gate on and nothing to match an interrupt against. Log that loudly at startup rather than shipping a gate that silently never fires, and leave auto-response on, because an ungated assistant is at least an assistant.
What is not the same in the Python SDK
The gate itself is.GroupGate, GateDecision, is_addressed, is_meeting_thread, is_verbal_interrupt and DEFAULT_FOLLOW_UP_WINDOW_MS exist there under the same names in snake_case, with the same twelve second default, the same thread-id rule and the same “no phrase means no gate” catch. Porting a gate between the languages changes the casing, and one thing that is language-shaped rather than a decision: the constructor takes a single options object here and keyword arguments there.
Two things around it are not the same, and both will bite a port that assumes otherwise.
The interrupt phrase set differs by language and by membership. This SDK ships English and Arabic; the Python SDK ships English, Arabic, French and German. The lists are not translations of each other either: "stop talking", "hang on", "pause" and "one second" match here and not there, and "cancel", "stop stop" and "hold up" match there and not here. There is also no four-word cap in the Python implementation. Test the phrases your callers actually use, in the SDK you are actually running, rather than porting a test suite across.
The echo guard is a different shape entirely. The Python SDK has an EchoGuard class that owns its own playout clock, and because that clock is monotonic, one monotonic source serves both the gate and the guard there. This SDK has a shouldSuppressEcho function, no class, and a playout horizon you keep yourself: it must be on the epoch-millisecond scale, so the source that serves both here is Date.now(), and the monotonic option the gate would otherwise accept stops the guard firing at all. Realtime providers spells the difference out in full.
Next
Realtime providers
The startup buffer and the echo guard, for a speech-to-speech agent.
Turn-taking
For an agent that is not speech to speech: segmentation, WAV and paced playback.
Call handler
Where the transcript, the context and
cancelPlayback reach you.Meeting recap
The other decision the meeting thread id makes for you.