> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# LuaVoice

> A voice definition: the speech-to-text, model and text-to-speech engines, voice session tuning, hooks and call-only tools

`LuaVoice` declares how the agent talks on a call: which speech-to-text, model and text-to-speech engines run the [voice session](/concepts/voice), how turns and interruptions are detected, which tools are call-only, and what code runs when a call starts, after each turn, and when it ends. Register instances on [`LuaAgent`](/reference/sdk/luaagent) under `voices`; the prompt comes from the agent's [persona](/concepts/persona) (`persona.voice`), not from the voice. Starting calls from code is [`Voice`](/reference/sdk/voice-runtime).

*Verified against lua-cli 3.33.0.*

```ts theme={null}
import { defineVoice, LuaVoice, LuaVoiceTool, ToolFlag } from 'lua-cli';
```

This page does not list provider models or voice ids. The catalog is in the admin dashboard; `lua voice list` prints the voices in your compiled project.

## Quick example

A cascaded voice (speech-to-text, model, text-to-speech) with a greeting and the three hooks:

```ts src/voices/SupportLine.ts theme={null}
import { defineVoice, User, Data } from 'lua-cli';

export default defineVoice({
  name: 'support-line',
  description: 'Inbound support line',
  llm: 'openai/gpt-5.2-chat-latest',
  stt: 'deepgram/nova-3',
  tts: { model: 'cartesia/sonic-3', voice: '9626c31c-bec5-4cca-baa8-f8ba9e84c8bc' },
  greeting: 'Thanks for calling Acme support. How can I help?',
  sttLanguage: 'en',
  turnDetection: 'vad',
  excludeTools: ['sendPayment', 'sendListItems'],
  async onEnter(ctx) {
    if (ctx.caller?.phoneNumber) {
      const user = await User.get({ phone: ctx.caller.phoneNumber });
      ctx.session.userdata = { returning: user !== null };
    }
  },
  async onUserTurnCompleted(turnCtx, message) {
    const docs = await Data.search('kb', message.content, 3);
    for (const doc of docs) turnCtx.addMessage({ role: 'system', content: String(doc.data.text) });
  },
  async onExit(ctx) {
    await Data.create('call-log', { sessionId: ctx.sessionId, seconds: ctx.duration ?? 0 });
  },
});
```

## Functions

### defineVoice()

Returns a `LuaVoice`; identical to `new LuaVoice(config)`. Keep the config a plain object literal: the compiler reads it from source, and a config built elsewhere or cast with `as` is not detected.

```ts theme={null}
defineVoice(config: LuaVoiceConfig): LuaVoice
```

**Errors** — `LuaVoice requires a non-empty \`name\` (used as the server-side identifier).`when`name\` is missing or blank.

## Engines

`llm`, `stt` and `tts` accept any of these forms. The compiler normalizes them; you never write a `kind`.

| Form                                                      | Example                                                                  | Where                                 |
| --------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------- |
| Descriptor string `'<provider>/<model>'`                  | `stt: 'deepgram/nova-3'`                                                 | Any engine                            |
| Descriptor with voice id `'<provider>/<model>:<voiceId>'` | `tts: 'cartesia/sonic-3:9626c31c-…'`                                     | `tts`                                 |
| Object `{ model, voice }`                                 | `tts: { model: 'cartesia/sonic-3', voice: '9626c31c-…' }`                | `tts`                                 |
| Typed inference class                                     | `llm: new inference.LLM({ model: 'openai/gpt-5.2-chat-latest' })`        | Any engine                            |
| Plugin class instance                                     | `stt: new deepgram.STT({ model: 'nova-3', smartFormat: true })`          | `deepgram` and `elevenlabs` only      |
| Realtime model                                            | `llm: new openai.realtime.RealtimeModel({ model: 'gpt-realtime-mini' })` | `llm` only; `openai`, `google`, `xai` |

The class forms come from the `lua-cli/voice` subpath, which re-exports the `deepgram`, `elevenlabs`, `openai`, `google`, `xai` and `inference` namespaces so your project depends on `lua-cli` alone. Google's realtime class is `google.beta.realtime.RealtimeModel`. A class form for any other provider (`new openai.LLM(…)`, `new cartesia.TTS(…)`) fails `lua compile`; use the descriptor string.

```ts src/voices/SupportLinePlugin.ts theme={null}
import { defineVoice } from 'lua-cli';
// Plugin class forms come from the lua-cli/voice subpath; this is the one
// allowed import besides 'lua-cli' and 'zod' (voice pages only).
import { deepgram, elevenlabs } from 'lua-cli/voice';

export default defineVoice({
  name: 'support-line-plugin',
  llm: 'openai/gpt-5.2-chat-latest',
  stt: new deepgram.STT({ model: 'nova-3', smartFormat: true }),
  tts: new elevenlabs.TTS({ voiceId: 'pwMBn0SsmN1220Aorv15', model: 'eleven_flash_v2_5' }),
});
```

A cascaded `llm` requires both `stt` and `tts`; `lua compile` fails with `Voice must declare an \`stt\` model …`or`Voice must declare a \`tts\` model …`when one is missing. A realtime`llm`handles audio itself: omit`stt`(setting it is refused at push with`stt cannot be set with a realtime llm …`), and omit `tts`for full realtime or keep it for half-cascade, where the realtime model emits text and`tts`speaks it.`pronunciations`needs a`tts\` step and is refused on full realtime.

```ts src/voices/RealtimeLine.ts theme={null}
import { defineVoice } from 'lua-cli';
import { openai } from 'lua-cli/voice';

// @ts-expect-error stt and tts are typed as required in 3.33.0; the compiler accepts their omission for a realtime llm
export default defineVoice({
  name: 'realtime-line',
  llm: new openai.realtime.RealtimeModel({ model: 'gpt-realtime-mini', voice: 'alloy' }),
});
```

The `@ts-expect-error` line is required because the published `LuaVoiceConfig` type declares `stt` and `tts` as required; `lua compile` and `lua push` accept their omission.

## Configuration

<ParamField path="name" type="string" required>
  Server-side identifier, and the name `lua voice --voice` and `ctx.voice.handoff()` address. 1 to 64 characters of `a-zA-Z0-9_-`. The `LuaVoiceConfig` type marks it optional; the constructor throws without it.
</ParamField>

<ParamField path="llm" type="string | object" required>
  The model that drives the conversation. A descriptor string, `inference.LLM`, or a realtime model.
</ParamField>

<ParamField path="stt" type="string | object" required>
  Speech-to-text engine. Required for a cascaded `llm`; must be omitted with a realtime `llm`.
</ParamField>

<ParamField path="tts" type="string | object" required>
  Text-to-speech engine. Required for a cascaded `llm`; optional with a realtime `llm`.
</ParamField>

<ParamField path="description" type="string">
  Shown in the compiled manifest and admin listings.
</ParamField>

<ParamField path="greeting" type="string">
  Spoken when the voice session starts, after `onEnter`. An empty string means no greeting.
</ParamField>

<ParamField path="sttLanguage" type="string">
  BCP-47 code such as `en` or `pt-BR`, or `multi` for multilingual transcription. Applies to descriptor-route and Deepgram plugin speech-to-text.
</ParamField>

<ParamField path="turnDetection" type="'multilingual' | 'english' | 'vad' | 'stt' | 'manual'">
  How the end of the caller's turn is detected. `multilingual` and `english` use a turn-detector model; `vad` uses voice activity alone; `stt` uses the transcriber's end-of-speech; `manual` leaves it to your code.
</ParamField>

<ParamField path="vad" type="string">
  Voice activity detection engine. `silero` is the only supported value; omit for the platform default.
</ParamField>

<ParamField path="vadOptions" type="object">
  Silero tuning: `minSpeechDuration` (ms, 0–5000), `minSilenceDuration` (ms, 0–5000), `prefixPaddingDuration` (ms, 0–2000), `activationThreshold` (0–1). Unknown keys are rejected.
</ParamField>

<ParamField path="interruption" type="object">
  `enabled`, `mode` (`'adaptive'` or `'vad'`), `falseInterruptionTimeout` (seconds, ≥ 0), `resumeFalseInterruption`, `minDelay` and `maxDelay` (seconds, `minDelay ≤ maxDelay`).
</ParamField>

<ParamField path="preemptiveGeneration" type="boolean">
  Start generating the reply while the caller is still speaking.
</ParamField>

<ParamField path="maxToolSteps" type="number">
  Sequential tool calls allowed per turn; integer 1–20.
</ParamField>

<ParamField path="userAwayTimeout" type="number">
  Seconds of silence (≥ 0) before the caller is treated as away.
</ParamField>

<ParamField path="krispEnabled" type="boolean">
  Background noise cancellation. Off unless set; billed separately.
</ParamField>

<ParamField path="backgroundAudio" type="object">
  `ambient` and `thinking` clips. Each is a clip name (`'office-ambience'`, `'keyboard-typing'`, `'keyboard-typing-2'`), a `{ source, volume?, probability? }` object (both 0–1), or an array of those for a probabilistic mix.
</ParamField>

<ParamField path="volume" type="number">
  Output volume, integer 0–100. Omit to pass the provider's level through.
</ParamField>

<ParamField path="pronunciations" type="Record<string, string>">
  Whole-word, case-insensitive replacements applied before synthesis, for example `{ HVAC: 'H V A C' }`. Cascaded path only.
</ParamField>

<ParamField path="persistTranscript" type="boolean">
  When `true`, the voice session history is written to `Data` under the key `call:<sessionId>` after the call ends.
</ParamField>

<ParamField path="onToolFailureSay" type="string">
  1–200 characters spoken once when a tool call throws, times out, or returns an unsupported result, before the model's own recovery.
</ParamField>

<ParamField path="excludeTools" type="string[]">
  Tool names withheld from this voice: platform tools (`searchKnowledgeBase`, `searchWeb`, the `send*` family), MCP and device tools, and your skill tools. Names that match nothing are ignored with a warning.
</ParamField>

<ParamField path="tools" type="Array<LuaTool | LuaVoiceTool>">
  Call-only tools, in addition to the agent's skills. See Voice tools.
</ParamField>

<ParamField path="onEnter" type="(ctx: LuaVoiceHookContext) => Promise<void>">
  Runs after the voice session connects and before the greeting. Set `ctx.session.userdata` here.
</ParamField>

<ParamField path="onUserTurnCompleted" type="(turnCtx: LuaVoiceTurnContext, message: { content: string }) => Promise<void>">
  Runs after the caller finishes a turn and before the model is called. `turnCtx.addMessage()` adds context the model sees on this turn.
</ParamField>

<ParamField path="onExit" type="(ctx: LuaVoiceHookContext) => Promise<void>">
  Runs when the voice session is closing; `ctx.duration` is set.
</ParamField>

## Hook contexts

```ts theme={null}
interface LuaVoiceHookContext {
  sessionId: string;
  channel: { kind: string; alias?: string };
  caller?: { phoneNumber?: string; userId?: string; isAnonymous?: boolean };
  duration?: number;
  session: {
    userdata: Record<string, unknown>;
    history: unknown[];
    say(text: string): Promise<void>;
    generateReply(opts?: { instructions?: string }): unknown;
  };
}

interface LuaVoiceTurnContext {
  items: unknown[];
  addMessage(message: { role: 'system' | 'user' | 'assistant'; content: string }): void;
}
```

## Voice tools

`LuaVoiceTool` is a [`LuaTool`](/reference/sdk/luatool) whose `execute` receives a `LuaVoiceToolCtx` with the live call's controls. A plain `LuaTool` placed in `tools` receives the same `ctx` and may carry flags under `voice: { flags }`.

```ts src/voices/tools.ts theme={null}
import { LuaVoiceTool, ToolFlag } from 'lua-cli';
import { z } from 'zod';

export const endCall = new LuaVoiceTool({
  name: 'end_call',
  description: 'Hang up once the caller confirms they are done',
  inputSchema: z.object({}),
  flags: [ToolFlag.DISALLOW_INTERRUPTION],
  async execute(_input, ctx) {
    await ctx?.voice?.endCall?.({ announce: 'Thanks for calling. Goodbye.' });
    return { ended: true };
  },
});

export const toBilling = new LuaVoiceTool({
  name: 'transfer_to_billing',
  description: 'Hand the call to the billing voice',
  inputSchema: z.object({ reason: z.string() }),
  async execute(input, ctx) {
    await ctx?.voice?.handoff?.('billing-line', { context: { reason: input.reason } });
    return { handedOff: true };
  },
});
```

**`LuaVoiceToolConfig`**

<ParamField path="name" type="string" required>Tool name the model calls.</ParamField>
<ParamField path="description" type="string" required>When to call it.</ParamField>
<ParamField path="inputSchema" type="ZodType" required>Input schema.</ParamField>
<ParamField path="execute" type="(input, ctx?: LuaVoiceToolCtx) => Promise<any>" required>Tool body.</ParamField>
<ParamField path="condition" type="() => Promise<boolean>">Offered to the model only when it resolves `true`.</ParamField>

<ParamField path="flags" type="ToolFlag[]">
  `ToolFlag.IGNORE_ON_ENTER` hides the tool during the first turn; `ToolFlag.DISALLOW_INTERRUPTION` stops caller speech from interrupting while it runs; `ToolFlag.NONE`. Stored on the instance as `voice.flags`.
</ParamField>

**`LuaVoiceToolCtx`** — every member is optional, so call them with `?.`. `ctx` is `undefined` when the same tool runs outside a voice session.

<ResponseField name="toolCallId" type="string">Identifier of this tool invocation.</ResponseField>
<ResponseField name="voice.say(text)" type="Promise<void>">Speaks `text` to the caller, for example while a slow lookup runs.</ResponseField>
<ResponseField name="voice.endCall(opts?)" type="Promise<void>">Ends the call. With `{ announce }`, speaks it first and waits for playout.</ResponseField>

<ResponseField name="voice.handoff(voiceName, opts?)" type="unknown">
  Hands the call to another voice on the same agent by `name`. The receiving voice starts from its own greeting with no history; `{ context }` is surfaced to it on its first turn. An unknown name is spoken as a fallback, not thrown.
</ResponseField>

<ResponseField name="voice.transferToHuman(msisdn, opts?)" type="Promise<void>">
  Transfers the caller to a phone number. `mode: 'refer'` (default) uses SIP REFER on the inbound leg; `mode: 'bridge'` dials the number into the room as a second participant, which works regardless of carrier REFER support. `announce` is spoken first.
</ResponseField>

<ResponseField name="voice.disallowInterruptions()" type="void">Declared but not implemented in 3.33.0.</ResponseField>

## Instance properties

A `LuaVoice` exposes every config field as a read-only property: `name`, `description`, `llm`, `stt`, `tts`, `vad`, `vadOptions`, `turnDetection`, `greeting`, `maxToolSteps`, `userAwayTimeout`, `preemptiveGeneration`, `interruption`, `sttLanguage`, `excludeTools`, `tools` (a frozen array), `onEnter`, `onUserTurnCompleted`, `onExit`. The class has no methods.

## Registration

List voices on the agent; a channel bound to the agent picks one by name, and falls back to the first entry. The voice-specific prompt is the `voice` branch of the agent's persona.

```ts src/index.ts theme={null}
import { LuaAgent } from 'lua-cli';
import supportLine from './voices/SupportLine';

export default new LuaAgent({
  name: 'support-agent',
  persona: {
    base: 'You are the Acme support agent.',
    voice: 'Keep answers to two sentences. Spell out numbers.',
    text: 'Use short paragraphs and lists.',
  },
  voices: [supportLine],
});
```

`lua push voice --name <name>` uploads the voice; `lua voice --voice <name>` opens a live test call from the browser, terminal or a phone. Binding a phone number is described under [voice calls](/channels/voice-calls).

## Testing

`lua-cli/voice/test` drives a `LuaVoice` offline with a scripted model, and `lua voice test` runs `*.voice.test.ts` files with Jest or Vitest.

```ts src/voices/support-line.voice.test.ts theme={null}
import { runVoice, expectCalledTool, expectContainsMessage } from 'lua-cli/voice/test';
import supportLine from './SupportLine';

export async function firstTurn(): Promise<void> {
  const session = await runVoice(supportLine, {
    llm: [{ input: 'Where is order 123456?', toolCalls: [{ name: 'lookup_order', args: { orderId: '123456' } }] }],
    mockTools: { lookup_order: () => ({ status: 'shipped' }) },
    caller: { phoneNumber: '+15551234567' },
    channel: 'phone',
  });
  const result = await session.run('Where is order 123456?');
  expectCalledTool(result, 'lookup_order', { orderId: '123456' });
  expectContainsMessage(result, /shipped/);
  await session.close();
}
```

<ResponseField name="runVoice(voice, options?)" type="Promise<TestSession>">
  Starts a session. `RunVoiceOptions`: `llm` (scripted responses keyed by user input, or a model instance), `mockTools` (replacements for named tools), `caller`, `channel` (`'phone' | 'meeting' | 'whatsapp' | 'webchat'`), `initialUserdata`, `handoffTargets` (voices reachable by `handoff`), `onTransferToHuman` (a spy; no real transfer fires), `dispatchedContext`, `sessionId`.
</ResponseField>

<ResponseField name="TestSession" type="interface">
  `run(userInput)` drives one turn and returns a `RunResult`; `runMany(inputs)` drives several; `session` is the underlying agent session; `history` the accumulated items; `close()` runs `onExit`.
</ResponseField>

<ResponseField name="expectCalledTool(result, toolName, expectedArgs?)" type="void">Throws unless the turn called the tool; `expectedArgs` is an object or a predicate.</ResponseField>
<ResponseField name="expectContainsMessage(result, matcher)" type="void">Throws unless an assistant message matches the substring, regex, or predicate.</ResponseField>
<ResponseField name="expectContainsHandoff(result, voiceName)" type="void">Throws unless the turn handed off to the named voice.</ResponseField>

<ResponseField name="judge(response, criterion, options)" type="Promise<boolean>">
  Asks a model whether `response` meets `criterion`. `JudgeOptions`: `llm` (required), `systemInstruction`, `context`. Returns `false` on disagreement and on any judge failure.
</ResponseField>

<ResponseField name="expectJudge(response, criterion, options)" type="Promise<void>">Throwing form of `judge`.</ResponseField>

The module also re-exports the `llm` and `voice` namespaces for typing scripted models and results.

## Types

From `'lua-cli'`: `LuaVoice`, `defineVoice`, `LuaVoiceConfig`, `LuaVoiceTool`, `LuaVoiceToolConfig`, `LuaVoiceToolCtx`, `LuaVoiceHookContext`, `LuaVoiceTurnContext`, `ToolFlag`. From `'lua-cli/voice'`: the `deepgram`, `elevenlabs`, `openai`, `google`, `xai` and `inference` namespaces. From `'lua-cli/voice/test'`: `runVoice`, `RunVoiceOptions`, `TestSession`, `expectCalledTool`, `expectContainsMessage`, `expectContainsHandoff`, `judge`, `expectJudge`, `JudgeOptions`, `llm`, `voice`.

## See also

* [Voice](/concepts/voice) — phone numbers, voice definitions, widget voice, the catalog
* [`Voice`](/reference/sdk/voice-runtime) — placing calls and creating voice sessions from code
* [`lua voice`](/reference/cli/voice) — live calls, `list`, `test`
* [Persona](/concepts/persona) — the `{ base, voice, text }` prompt shape
* [Voice calls](/channels/voice-calls) — binding a phone number to a voice
