Reported
In hands-free, saying "stop stop" exited hands-free mode. In my preferences "stop stop" is the
stop-speech keyword. My exit command is empty — I never set one. An empty command should
not apply at all.
Correct on every point. Live account config (GET /v1/preferences):
"exitWords": [], ← never set
"stopSpeechKeyword": "stop stop", ← explicitly set, to stop SPEECH
"muteWords": ["mute mute"], "repeatWords": ["Repeat that"], "stopWords": ["Copy Copy"]
What actually happens
Both dispatch sites check stop-speech first, and that check is gated on the agent currently
speaking (convo.ts matchesStopSpeech: if (!s.ttsSpeaking) return false). So:
| when you say "stop stop" |
what runs |
result |
| agent is speaking |
matchesStopSpeech → tts.cancel() |
speech stops — what you configured |
| agent is not speaking (mic hot, thinking, muted) |
falls through to matchVoiceCommand → exit |
hands-free torn down |
The fall-through happens because pick() (convo.ts:384) treats an empty list as unset:
const pick = (custom, table) => custom?.length ? custom : (table[l] ?? table.en);
exitWords: [] → length 0 → built-in EXIT_BY_LANG.en, which contains "stop" and
"stop stop" (added by #331, to fix bare "stop" being sent to the agent as chat).
So one phrase has two meanings, selected by whether TTS happens to be playing at that instant —
and the meaning the user never chose is the destructive one.
Three defects, in order of how much they cost
1. A blank field means the opposite thing on the same panel. VoiceFields.tsx:
:381 exitWords placeholder "exit voice, text mode (blank = built-in defaults)"
:396 stopWords placeholder "e.g. copy, over (blank = off)"
:401 stopSpeechKeyword placeholder "e.g. stop stop (blank = off)"
Three adjacent inputs, two opposite conventions, documented only in placeholder text that
disappears the moment you type. repeat/mute/unmute/exit/next/scrap all fall back to
built-ins; stopWords/stopSpeechKeyword are off. The user's expectation — blank means off — is
the convention two of the five follow.
2. The UI suggests the colliding phrase. The stop-speech placeholder is literally
e.g. stop stop. The product recommends binding a phrase that the built-in exit list already owns,
and nothing anywhere warns that the phrase is taken. There is no collision check at save time and
no ranking rule — the built-in simply wins whenever the gentler check is inactive.
3. Exit cannot be turned off at all. Blank → built-ins. The only way to stop "stop" from
ending your voice session is to bind exitWords to some other phrase, i.e. you must keep the
feature and move it. There is no off.
Why the built-in exists (keep the reason, change the mechanism)
#331 added bare "stop"/"stop stop" for a real failure: the words matched nothing, went to the
agent as chat, and it replied "Got it. Stopped." — confirming an action nobody performed while
the mic stayed open. That reasoning stands. What it did not anticipate is a user binding the same
phrase to a different action, and the destructive one silently outranking it.
Suggested fix
- Blank = off, for every command list. One convention across the panel. Keep the built-ins
discoverable as a "Use suggested" button that fills the field with them — so defaults are
visible, editable and removable, instead of invisible and mandatory.
- Refuse the collision. A phrase already bound to another action should be rejected on save
with the conflict named, or — at minimum — the explicit user binding must win over any built-in.
Today the opposite is true.
- Change the stop-speech placeholder, whichever way (1) goes: suggesting
stop stop is
actively steering users into this.
- Consider making the stop-speech keyword work when TTS is not playing too, as a no-op rather
than a fall-through — the gate is what makes the same phrase mean two things.
The decision inside this is (1): does a blank command field mean "use ours" or "off"? It changes
behaviour for anyone who left a field blank — today they have built-in exit words they never chose,
and after the change they would have none. Recommendation: blank = off, with the "Use suggested"
button, because the current behaviour cannot be turned off and its most common word is "stop".
Files: packages/sdk/src/voice/convo.ts (pick :384, EXIT_BY_LANG :~180, matchesStopSpeech
:471), packages/sdk/src/voice/use-voice.ts:836,850,891,1126,
store/console/src/components/VoiceFields.tsx:381,396,401, packages/sdk/src/voice/config.ts:141.
Reported
Correct on every point. Live account config (
GET /v1/preferences):What actually happens
Both dispatch sites check stop-speech first, and that check is gated on the agent currently
speaking (
convo.tsmatchesStopSpeech:if (!s.ttsSpeaking) return false). So:matchesStopSpeech→tts.cancel()matchVoiceCommand→exitThe fall-through happens because
pick()(convo.ts:384) treats an empty list as unset:exitWords: []→ length 0 → built-inEXIT_BY_LANG.en, which contains"stop"and"stop stop"(added by #331, to fix bare "stop" being sent to the agent as chat).So one phrase has two meanings, selected by whether TTS happens to be playing at that instant —
and the meaning the user never chose is the destructive one.
Three defects, in order of how much they cost
1. A blank field means the opposite thing on the same panel.
VoiceFields.tsx:Three adjacent inputs, two opposite conventions, documented only in placeholder text that
disappears the moment you type.
repeat/mute/unmute/exit/next/scrapall fall back tobuilt-ins;
stopWords/stopSpeechKeywordare off. The user's expectation — blank means off — isthe convention two of the five follow.
2. The UI suggests the colliding phrase. The stop-speech placeholder is literally
e.g. stop stop. The product recommends binding a phrase that the built-in exit list already owns,and nothing anywhere warns that the phrase is taken. There is no collision check at save time and
no ranking rule — the built-in simply wins whenever the gentler check is inactive.
3. Exit cannot be turned off at all. Blank → built-ins. The only way to stop "stop" from
ending your voice session is to bind
exitWordsto some other phrase, i.e. you must keep thefeature and move it. There is no off.
Why the built-in exists (keep the reason, change the mechanism)
#331 added bare
"stop"/"stop stop"for a real failure: the words matched nothing, went to theagent as chat, and it replied "Got it. Stopped." — confirming an action nobody performed while
the mic stayed open. That reasoning stands. What it did not anticipate is a user binding the same
phrase to a different action, and the destructive one silently outranking it.
Suggested fix
discoverable as a "Use suggested" button that fills the field with them — so defaults are
visible, editable and removable, instead of invisible and mandatory.
with the conflict named, or — at minimum — the explicit user binding must win over any built-in.
Today the opposite is true.
stop stopisactively steering users into this.
than a fall-through — the gate is what makes the same phrase mean two things.
The decision inside this is (1): does a blank command field mean "use ours" or "off"? It changes
behaviour for anyone who left a field blank — today they have built-in exit words they never chose,
and after the change they would have none. Recommendation: blank = off, with the "Use suggested"
button, because the current behaviour cannot be turned off and its most common word is
"stop".Files:
packages/sdk/src/voice/convo.ts(pick:384,EXIT_BY_LANG:~180,matchesStopSpeech:471),
packages/sdk/src/voice/use-voice.ts:836,850,891,1126,store/console/src/components/VoiceFields.tsx:381,396,401,packages/sdk/src/voice/config.ts:141.