- 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
standin. They live in standin.gate if you would rather import the module.
How the SDK knows it is a group call
is_meeting_thread(thread_id) 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.thread_id, 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.
note_participants(count) is the second signal, and it is deliberately weaker:
1 arriving on a meeting thread adds nothing and takes nothing away: gate.is_group stays true. The count is the signal that goes missing, so it may add certainty and must not remove it.
is_group 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 on_context, which is also where the recording status and keypad presses land. The count behind the sentence is on the session as participant_count, so there is nothing to parse out of the prose. See Call handler.
Wake phrases and the follow-up window
decide(transcript, now_ms) returns a frozen 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.
now_ms is yours to supply, and the gate never reads a clock itself. Pass a monotonic source, time.monotonic() * 1000.0, so a clock that steps sideways cannot open or close the floor at random. It also makes the whole decision testable without waiting twelve seconds.
require_address=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, require_address is on, and at least one non-blank wake phrase is configured.
Matching a name
is_addressed(transcript, wake_phrases) is a case-insensitive match on word boundaries, not substrings.
\w is Unicode-aware by default, so an Arabic wake phrase behaves the same as a Latin one and needs no special case in your configuration. Phrases are stripped and lowercased before matching, regex metacharacters in them are escaped, and an empty phrase never matches.
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
is_verbal_interrupt(transcript, wake_phrases=()) 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: NFKC, lowercased, Arabic diacritics and tatweel removed so a vocalised تَوَقَّف matches, and punctuation collapsed to single spaces.
Then filler and wake phrases are peeled off both ends, repeatedly until nothing changes. “Hermes, please stop” and “hold on, assistant” both reduce to the bare phrase, 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.
Interrupt phrases ship for four languages: English, Arabic, French and German. stop, wait, hold on, hold up, never mind, cancel, توقف, خلاص, arrête, ça suffit, stopp, das reicht and their close neighbours. The set is a module-private constant in standin.gate and there is no hook to extend it, so a phrase your callers use and the set does not is a wrapper around is_verbal_interrupt, not a configuration key.
One consequence 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.
This is the fast path for the unambiguous case, not a replacement for your model’s judgement. “could you stop” and “stop talking” are not in the set and return
False, because widening the rule to catch them is how a set of whole-utterance phrases turns back into substring matching.session.cancel_playback():
cancel_playback() 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.is_addressed and let only that call decide: because the window is a timestamp, a stamp can only ever open the floor, never close it.
gate.is_group 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 TypeScript SDK
The gate itself is.GroupGate, GateDecision, isAddressed, isMeetingThread, isVerbalInterrupt and DEFAULT_FOLLOW_UP_WINDOW_MS exist there under the same names in camelCase, 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 keyword arguments here and a single options object 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, Arabic, French and German; the TypeScript SDK ships English and Arabic. The lists are not translations of each other either: "cancel", "stop stop" and "hold up" match here and not there, and "stop talking", "hang on", "pause" and "one second" match there and not here. The TypeScript implementation also caps the reduced utterance at four words as a second guard against an accidental match; this one has no such cap and leans entirely on the phrase set being exact. 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. This SDK has an EchoGuard class that owns its own playout clock, and because that clock is monotonic milliseconds, a single time.monotonic() * 1000.0 serves both the gate and the guard on the same call. The TypeScript SDK has a shouldSuppressEcho function, no class, and a playout horizon you keep yourself: it must be on the epoch-millisecond scale, so there the shared source has to be 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
cancel_playback reach you.Meeting recap
The other decision the meeting thread id makes for you.