Register voice commands
A plugin teaches the voice assistant its own spoken commands. You describe the phrasing in a VoiceDescriptor, attach it to one of your MCP tools, and test it with the same engine the voice assistant runs. The voice assistant itself never mentions your feature.
Rule: the plugin that owns a tool owns its phrases, examples, card summary and tests. Do not add feature words to the voice assistant or any shared code.
Before you start
Section titled “Before you start”- A plugin that registers an MCP tool. See Add an MCP tool.
- Your plugin depends on
@teamhud/transcriptionfor the types and the test engine. - Voice is off until a person turns it on in Preferences, and anything it runs shows a card first unless you say otherwise (step 2).
-
Write the descriptor. One exported constant, in its own file beside your plugin. This is the real one from the AI chat plugin (
plugins/widget-ai-chat/src/ai-chat-voice.ts):import type { VoiceDescriptor } from '@teamhud/transcription/core';const LEAD = "[go ahead and|just|let me|let's|lets|(i|we) (need|want|have) to]";const AI = '(ai|a.i.|a i)';export const ASK_AI_VOICE: VoiceDescriptor = {phrases: [`${LEAD} ask [the|my] ${AI} [chat|assistant] [about|to] {prompt}`,`${AI} chat {prompt}`,`new ${AI} chat`,],args: { prompt: 'prompt' }, // tool argument <- slot name in the phraseconfirm: 'never',summary: '[Ask the AI “{prompt}”|Open a new AI chat].',example: 'ask the ai …',group: 'AI Chat',};The template syntax:
{name}is a slot taken from the speech,[a|b]is optional (first match wins),(a|b)is a required choice. -
Choose how it confirms.
confirmis required:Value Behaviour 'never'Runs at once. Use for harmless, reversible things. 'always'The voice assistant shows a confirm card first. Use when it changes something. 'send'Sends a message to other people. Saying “execute” only shortens the wait. Set
leavesDevice: trueif the person’s words go off the computer (for example a web search). The local backup model then asks before running it. -
Add what helps people and the model. All optional:
summaryis the card’s sentence ([ … ]drops when a slot is empty);exampleandgroupfill the help list;examplesare whole sentences in people’s own words (up to 50, 25 words each);nearMissesare sentences that must not trigger it;vocabularylists product words a recogniser may mishear;reply(result, args)turns a read-only tool’s result into a spoken-style sentence;undosays how to reverse it. -
Register it with the tool. At startup, ask the MCP server for the voice. Plugin start order is not fixed, so retry briefly (this is what the AI chat plugin does):
private registerVoice(tries = 0): void {let mcp: { registerVoice?(tool: string, voice: VoiceDescriptor, owner: string): void } | null | undefined;try { mcp = this.services.get('MCPServerService'); } catch { return; }if (mcp?.registerVoice) { mcp.registerVoice('ask_ai', ASK_AI_VOICE, 'widget-ai-chat'); return; }if (tries < 60) setTimeout(() => this.registerVoice(tries + 1), 500).unref?.();}The first argument is the tool’s name, the last is your plugin slug. The first plugin to register a tool’s voice keeps it. Another plugin that tries to take it is refused with a log warning. If you register the tool yourself in the same place, you can instead pass the descriptor as the sixth argument of
registerTool. CallunregisterVoice(tool, slug)fromshutdown()if your plugin can be switched off. -
Write the phrasing corpus. A table of what people say and what should happen, in a data file next to the descriptor, plus one test that runs it. From
ai-chat-voice.corpus.ts:export type Row = [said: string, tool: string | null, args?: Record<string, unknown>];export const CORPUS: Row[] = [['ask the ai how does egress work', 'ask_ai', { prompt: 'How does egress work' }],['new ai chat', 'ask_ai', {}],['the ai is slow today', null], // uses your words, is NOT your command['open the chat', 'open_chat'], // belongs to someone else];The test runs every row through the shared engine against all registered voices, so it also catches your phrases stealing someone else’s:
import { describe, it, expect } from 'vitest';import { interpret } from '@teamhud/transcription/core';import { CORPUS } from './ai-chat-voice.corpus.js';const run = (said: string) => interpret(`hudson, ${said}`, {actions, assistantName: 'hudson', requireName: true, allowExecute: false,resolveTeam: () => ({ none: true }), activeTeamId: null,});describe('the corpus', () => {it.each(CORPUS)('"%s" -> %s', (said, tool, args) => {const i = run(said);expect(i?.action.name ?? null, said).toBe(tool);if (args) expect(i!.args).toEqual(args);});});In the real file,
actionscomes fromplugins/mcp-server/src/voice-actions.fixture.ts, which lists every owner’s voice in the voice assistant’s order. A plugin inside the repo adds its own line there. For a quick single-descriptor test, pass[{ name: 'ask_ai', description: 'Ask the AI', voice: ASK_AI_VOICE }]instead, asai-chat-voice.test.tsdoes, and usesummarize(i, [])to check the card’s sentence. -
Run it.
Terminal window pnpm --filter @teamhud/widget-ai-chat testUse your own package name.
What you’ll see
Section titled “What you’ll see”Say your name for the assistant (default “Hudson”) then your phrase. The voice assistant shows a card with your summary, or runs the tool at once when confirm is 'never'. The help list shows example under your group.
If it goes wrong
Section titled “If it goes wrong”- Nothing happens. The tool must exist. A voice registered before its tool waits for it, but it never matches if no plugin ever registers that tool name.
- “Refused the voice for … it belongs to …” in the log. Another plugin claimed that tool first. Use your own tool name.
- Your phrase triggers a different action. Actions are tried in registration order. Add a corpus row for the phrase that should win, and make yours more specific.
- Slot is empty or wrong. The slot name in the phrase (
{prompt}) must match the value inargs.