Skip to content

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.

  • A plugin that registers an MCP tool. See Add an MCP tool.
  • Your plugin depends on @teamhud/transcription for 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).
  1. 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 phrase
    confirm: '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.

  2. Choose how it confirms. confirm is 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: true if the person’s words go off the computer (for example a web search). The local backup model then asks before running it.

  3. Add what helps people and the model. All optional: summary is the card’s sentence ([ … ] drops when a slot is empty); example and group fill the help list; examples are whole sentences in people’s own words (up to 50, 25 words each); nearMisses are sentences that must not trigger it; vocabulary lists product words a recogniser may mishear; reply(result, args) turns a read-only tool’s result into a spoken-style sentence; undo says how to reverse it.

  4. 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. Call unregisterVoice(tool, slug) from shutdown() if your plugin can be switched off.

  5. 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, actions comes from plugins/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, as ai-chat-voice.test.ts does, and use summarize(i, []) to check the card’s sentence.

  6. Run it.

    Terminal window
    pnpm --filter @teamhud/widget-ai-chat test

    Use your own package name.

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.

  • 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 in args.