---
name: lokutor-voice-ai
description: >-
  Build real-time voice features with Lokutor — the voice-AI platform for
  text-to-speech (TTS), speech-to-text (STT), and full-duplex conversational
  voice agents over WebSocket, plus a phone Call Center API. Use this when adding
  streaming speech synthesis, live transcription, noise removal, a talking AI
  agent, or outbound/inbound phone calls to an app, using the @lokutor/sdk
  (JS/TS), the lokutor Python package, or the REST/WebSocket API directly.
license: MIT
---

# Building with Lokutor Voice AI

Lokutor is a real-time voice-AI platform with four product surfaces (**TTS**,
**STT**, **noise removal** and a full-duplex **Voice Agent**) plus a phone
**Call Center API**. This skill is the
integration playbook: reach for the SDK first, drop to the WebSocket/REST API
only when the SDK can't express what you need.

## Orientation

- **Base URL:** `https://api.lokutor.com` (REST + WebSocket).
- **Auth:** every non-discovery call needs an API key from
  [app.lokutor.com](https://app.lokutor.com), prefixed `sk_`. Read it from
  `LOKUTOR_API_KEY` and never hard-code it. Every authenticated endpoint
  accepts `Authorization: Bearer <key>`, `X-API-Key: <key>` or `?api_key=<key>`;
  prefer a header. **Never ship the key to a browser or mobile app.** Instead,
  your backend calls `POST https://api.lokutor.com/ws-ticket` (key in the
  `Authorization` or `X-API-Key` header) and gets `{"ticket":"wst_…","expires_in":600}`;
  the client passes the ticket wherever a key goes (it works for 10 minutes and
  can be reused until then). In the JS SDK, give `VoiceAgentClient` an
  `auth: () => fetchTicketFromYourBackend()` callback (called before every
  connect and reconnect); `TTSClient` / `STTClient` / `SpeechToTextClient` take
  the ticket as `apiKey`.
- **Billing:** prepaid balance in USD, charged as you use it — voice agent
  $0.042/min (per second) plus the language model per token ($0.44 / $0.94 per
  million input / output tokens), about 5.2¢ a typical minute; TTS $4.10 per
  million characters; STT $0.0014/min; phone lines per started minute by country.
  The live rate card is `GET https://api.lokutor.com/v1/pricing` (public JSON, no
  key) — read prices from it rather than hard-coding them. An account holds 3
  sessions at once before its first top-up, 10 after (agent calls, `/ws/tts` sockets and
  `/ws/stt` streams together).
- **SDKs:** `npm install @lokutor/sdk` (JS/TS, 1.6.0; browsers and Node.js 22+,
  which has the global `WebSocket`) · `pip install lokutor` (Python 3.8+, 1.5.0;
  add `"lokutor[audio]"` plus PortAudio only to play/record locally). The two SDKs
  have the same classes.
- **Voices:** slots `F1`–`F5` (female), `M1`–`M5` (male). Each language has its
  own native speaker per slot, so `F1` in Spanish is a different person from `F1`
  in English: always send `voice` and `language` together. The SDKs default to
  `F1`; `POST /tts/synthesize` requires `voice`; `/ws/tts` without a voice uses
  `M5` for English, `F3` for Spanish, else the language's first voice.
- **Languages:** 9 spoken, as ISO codes — `en es ca gl eu pt fr it de`. Default `en`.
  `GET /languages` is the live list; check it rather than assuming. Speech
  recognition (voice agents, STT) understands all 9 with one model; pass the
  language to STT when you know it (one-word replies, no drift).
- **Audio format:** agent/STT **input** is Int16 PCM, 16 kHz, mono; all **output**
  is Int16 PCM, 44.1 kHz, mono. The SDK handles resampling for you — you only deal
  with raw PCM if you bypass the SDK's audio managers.

Pick the surface by shape of the problem:

| You want to… | Use |
| :-- | :-- |
| Turn text into speech (one shot or streamed) | `TTSClient` / `simpleTTS`, or `POST /tts/synthesize` |
| Transcribe a recorded clip | `STTClient`, or `POST /stt/transcribe` |
| Transcribe a live mic stream | `SpeechToTextClient` (`WS /ws/stt`) |
| A talking assistant (mic in → speech out) | `VoiceAgentClient` (`WS /ws/agent`) |
| A drop-in browser voice UI | `ConversationalPanel` |
| Phone calls (buy numbers, dial out) | Call Center API — **see the safety note** |

## Text-to-Speech

The SDK streams audio chunks (`Uint8Array` of Int16 PCM @ 44.1 kHz) to `onAudio`
as they generate — start playing/writing on the first chunk; don't wait for the
promise to resolve.

```typescript
import { TTSClient, VoiceStyle, Language } from '@lokutor/sdk';
import fs from 'fs';

const tts = new TTSClient({ apiKey: process.env.LOKUTOR_API_KEY! });
const chunks: Buffer[] = [];

// Resolves at end of stream; rejects with a LokutorError on a server error or a stall.
await tts.synthesize({
  text: 'Hello from Lokutor.',  // max 5,000 characters
  voice: VoiceStyle.F1,         // optional, default F1
  language: Language.ENGLISH,   // optional, default en
  onAudio: (pcm16) => chunks.push(Buffer.from(pcm16)),
  onTTFB: (ms) => console.log(`first audio in ${ms}ms`),
});

// Raw PCM16 → WAV: ffmpeg -f s16le -ar 44100 -ac 1 -i out.raw out.wav
fs.writeFileSync('out.raw', Buffer.concat(chunks));
```

One-liner when you just need audio out: `simpleTTS({ apiKey, text, onAudio })`.

**Parameters that do not change the audio on `versa-2.0`:** `speed` and `steps`
(accepted for compatibility; no effect), `cfg` / `cfg_text` (same), `quality`
(legacy REST alias, ignored), `version` on `/ws/tts` (ignored). Do not add them
to new code. If you must send them: REST resets `speed` outside 0.5–2.0 to 1.0
and treats `steps` above 8 as 8; `/ws/tts` rejects `speed` outside 0.5–2.0 and
`steps` above 128 with an error frame. **Visemes (lip-sync) are not available**
on the current model: `visemes: true` produces nothing.

## Speech-to-Text

```typescript
import { STTClient, SpeechToTextClient, Language } from '@lokutor/sdk';

// Batch — one complete WAV clip. Pass WAV as a Blob (a raw buffer is sent as PCM16):
const stt = new STTClient({ apiKey: process.env.LOKUTOR_API_KEY! });
const { text } = await stt.transcribe({ audio: new Blob([fs.readFileSync('clip.wav')]) });

// Live — stream a mic; server VAD segments utterances for you:
const live = new SpeechToTextClient({
  apiKey: process.env.LOKUTOR_API_KEY!,
  language: Language.ENGLISH,
  onPartialTranscript: (t) => console.log('…', t),  // interim, while speaking
  onFinalTranscript: (t) => console.log('final:', t), // once the user stops
});
```

For raw (non-WAV) audio, pass `format: "pcm16"` **and** `sampleRate`. WAV reads its
rate from the header and must be 8/16-bit PCM, mono or stereo, at 8000, 11025,
12000, 16000, 22050, 24000, 32000, 44100 or 48000 Hz, at most 10 MB (else `400`
`validation.invalid_request_format`). Recognition understands `en es pt fr it de`
only. `endUtterance()` finalizes the current utterance **and ends the live
session**.

## Noise removal

`POST /denoise` with `multipart/form-data` field `audio` (same WAV rules) returns
the cleaned WAV:

```bash
curl -X POST https://api.lokutor.com/denoise -H "Authorization: Bearer $LOKUTOR_API_KEY" \
  -F "audio=@noisy.wav" --output clean.wav
```

## Voice Agent (full-duplex conversation)

`VoiceAgentClient` runs the whole loop — mic capture → STT → LLM → TTS → playback,
with barge-in (the user can interrupt). `startManaged()` is the golden path in the
browser: it wires up the microphone and speaker for you. **It must be called from
inside a user gesture** (e.g. a click handler) because browsers gate mic/audio on
user activation.

```typescript
import { VoiceAgentClient, VoiceStyle, Language } from '@lokutor/sdk';

const agent = new VoiceAgentClient({
  // Browser: never an API key. Your backend returns a wst_ ticket from POST /ws-ticket.
  auth: async () => (await fetch('/api/lokutor-ticket', { method: 'POST' }).then((r) => r.json())).ticket,
  prompt: 'You are a warm, concise support agent for Acme.',
  voice: VoiceStyle.F1,
  language: Language.ENGLISH,
});

agent.on('transcription', (t) => {});  // final user line
agent.on('transcription_partial', (t) => {}); // interim user text (not stored in getTranscript())
agent.on('response', (t) => {});        // agent text
agent.on('status', (s) => {});          // listening | thinking | speaking | interrupted
agent.on('error', (e) => {});           // LokutorError

await agent.startManaged();             // inside a click handler
```

Mid-conversation you can `agent.updatePrompt(...)`, `updateVoice(...)`,
`updateLanguage(...)`, `updateTools(...)`, `toggleMute()`, and read
`getTranscript()` / `getTranscriptText()` (final lines only). Call
`agent.disconnect()` to end. Register error handlers with `agent.on('error', …)`
or the `onError` constructor option; `agent.onError = …` does not work.

**Run a dashboard agent** with `agentId: '<uuid>'` (raw API: `?agent_id=`): it
brings its own prompt, voice, language, tools and knowledge base, so `prompt`,
`voice` and `language` become optional overrides. An id that is not on the
account is refused at the handshake (`404` `agent.not_found`).

A session where nobody speaks for **5 minutes** ends: the agent says goodbye and
the socket closes with `1000` (`nobody has spoken for 5 minutes`). Barge-in:
`status: listening` while the agent talks is tentative, keep playing; stop and
clear audio only on `status: interrupted`.

### Tool calling (function calling)

Tools use the OpenAI function-calling shape. Your code executes them: when the
model calls one, the `tool_call` event fires **once** with
`{ name, arguments, call_id }` (`arguments` is a JSON string); run it and return
the result with `submitToolResult(call_id, result)`. Python:
`on_tool_call(name, arguments, call_id)` and `submit_tool_result(call_id, result)`.

```typescript
const agent = new VoiceAgentClient({
  apiKey, prompt: 'You can check order status.',
  tools: [{
    type: 'function',
    function: {
      name: 'get_order_status',
      description: 'Look up an order by its ID',
      parameters: {
        type: 'object',
        properties: { order_id: { type: 'string' } },
        required: ['order_id'],
      },
    },
  }],
});

agent.on('tool_call', async ({ name, arguments: argsJson, call_id }) => {
  const args = JSON.parse(argsJson);
  const status = await lookup(args.order_id);
  agent.submitToolResult(call_id, { status });
});
```

### Drop-in browser UI

`ConversationalPanel` renders a full voice widget (animated orb, mic/mute, timer)
into a container element, for a demo or an embed with little UI code.

## REST API (no SDK)

Discovery GETs need no auth: `/voices`, `/languages`, `/models`, `/config`,
`/status`, `/v1/pricing`, and `/ready` (200 once the server can serve). Every
route also works under `/v1/`.

`POST /tts/synthesize` returns a WAV (16-bit, 44.1 kHz, mono). `voice` is
required. Limits: 5,000 characters counted across `text` and all `segments`,
at most 200 segments, `pause_ms` 0–10,000 each and 60,000 total, body ≤ 1 MB
(`400` `validation.text_too_long` / `validation.invalid_request_format`);
unknown voice → `validation.invalid_voice`, unknown language →
`validation.invalid_language`. At capacity it answers `503` with `Retry-After`
and a body `{"error","voice","hint"}` (not the error shape): wait and retry.

```bash
curl -X POST https://api.lokutor.com/tts/synthesize \
  -H "Authorization: Bearer $LOKUTOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"Hello world","voice":"F1","language":"en"}' \
  --output out.wav
```

> **One vocabulary across every surface:** `voice`, `language` (and the
> no-effect `speed`, `steps`). The legacy names `voice_id` and `lang` are
> accepted aliases and `quality` is ignored; write new code with the canonical
> names.

The full machine-readable contract is the OpenAPI spec at
`https://docs.lokutor.com/api-reference/openapi.json`, and the entire docs corpus
as one file for LLMs is `https://docs.lokutor.com/llms-full.txt`.

## Errors

The SDK throws/emits `LokutorError` with a stable `.code` (from a fixed
`ErrorCode` set — e.g. `auth.invalid_key`, `validation.text_too_long`,
`auth.rate_limited`, `billing.insufficient_balance`, `agent.not_found`,
`session.ended`), a `.message`, an optional `.detail`, and a boolean `.retryable`. Use
`isRetryable(err)` to decide whether to back off and retry versus surface a hard
failure. REST returns the same shape as JSON (`ErrorResponse`: `code`, `message`,
`detail`, `retryable`) with the matching HTTP status.

`402` means the account cannot pay, and retrying will not help:
`billing.insufficient_balance` (under $0.25 of balance to start a session or
request), `billing.topup_required` (outbound calls and phone numbers need a first
top-up) or `billing.account_disputed` (a disputed payment paused the account).
Surface them to a human who can top up at app.lokutor.com/billing. On the
WebSocket endpoints the `402` comes at the handshake, as plain text, before the
socket opens. Too many sessions at once is `429` `auth.rate_limited` at the
`/ws/agent` and `/ws/stt` handshakes; on `/ws/tts` the socket opens, sends an
`auth.rate_limited` error frame and closes. Error frames on `/ws/agent` and
`/ws/stt` may carry a plain string in `data` instead of the structured object.

## Phone Call Center API — handle with care

The Call Center API (`/callcenter/*`) buys phone numbers and places **real
outbound calls to real people, paid from the account's balance**. Its only
built-in gate is a `consent_confirmed` flag — there is no second confirmation. **Never wire these
endpoints to an autonomous agent that can fire them without a human approving each
call**; put a human-in-the-loop confirmation in front of `dial` and `bulk-dial`.
Lokutor's MCP server deliberately does **not** expose them for exactly this
reason. Flow: `GET /callcenter/numbers/search?country_code=ES` → `POST
/callcenter/numbers` `{"phone_number","agent_id"}` (rents it, $5/month, needs a
first top-up) → callers reach that agent. `POST /callcenter/dial` needs
`consent_confirmed: true`; `POST /callcenter/hangup/{call_id}` ends a call.

A phone agent can **transfer a call to a person** on your team (dashboard → agent →
Tools → Transfer to a person: one number or a pool, rung in turn; if nobody answers
the agent takes a message). Only numbers the owner set are ever dialled. Once the
person answers, only the phone lines are billed. See `/guides/call-transfer`.

## MCP servers

Two hosted MCP servers, nothing to install (Streamable HTTP):

- `https://api.lokutor.com/mcp` — Lokutor's API as tools: list voices, languages,
  models, request limits and status (no key), and `synthesize_speech` (requires
  `text` up to 1,000 characters and `voice`; needs
  `Authorization: Bearer $LOKUTOR_API_KEY`; billed from the account's balance).
- `https://docs.lokutor.com/mcp` — search and read these docs.

`claude mcp add --transport http lokutor https://api.lokutor.com/mcp --header "Authorization: Bearer $LOKUTOR_API_KEY"`.
See [the MCP guide](https://docs.lokutor.com/guides/mcp-server).

## Gotchas checklist

- No `sk_` key in browser code, including bundler env vars (`VITE_*`,
  `NEXT_PUBLIC_*`): mint `wst_` tickets on a backend.
- `startManaged()` / any mic access must run inside a user gesture in the browser.
- Start consuming `onAudio` on the first chunk — it streams; don't await the whole
  synthesis before playing.
- Output is 44.1 kHz, agent/STT input is 16 kHz — don't cross the rates.
- `client.on(event, cb)` in Python is a **method taking a callback**, not a
  `@decorator`.
- Keep `LOKUTOR_API_KEY` server-side; the discovery GETs are the only keyless
  calls.
- Don't tune `steps`, `speed` or `cfg`: no effect on `versa-2.0`. Don't build
  lip-sync on visemes: none are sent.
- Every language an agent can speak, it also understands (all nine, one recognition model).
