# Speko developer docs (/) Explore Speko Router, Gateway, SDKs, and MCP for building and operating production voice AI. # Audio helpers (/adapter-livekit/audio) WAV encode / decode and MIME parsing utilities. The adapter exports the three audio helpers it uses internally. They're stable exports — safe to reuse if you're building custom pipelines or writing tests. ```ts import { framesToWav, parseWav, pcmSampleRateFromContentType, } from '@spekoai/adapter-livekit'; ``` ## `framesToWav` [#framestowav] ```ts function framesToWav(buffer: AudioBuffer): Uint8Array; ``` Encode one or more LiveKit `AudioFrame`s (or an array) into a PCM16 mono WAV byte stream. Used by `SpekoSTT` to wrap each utterance before uploading to `/v1/transcribe`. * Combines frames via `combineAudioFrames` from `@livekit/rtc-node`. * Writes a standard 44-byte RIFF/WAVE header: `fmt ` chunk (PCM, 16-bit, mono, `sampleRate` from frames) + `data` chunk. * Sample rate is pulled from the input frames — whatever LiveKit gives you is what's encoded. **Mono-only.** A multi-channel `AudioBuffer` throws: ``` SpekoSTT: expected mono audio (1 channel), got 2. Configure your LiveKit AgentSession to pass mono audio or pre-mix upstream of the STT. ``` ## `parseWav` [#parsewav] ```ts function parseWav(bytes: Uint8Array): { pcm: Uint8Array; sampleRate: number; channels: number; }; ``` Minimal PCM16 WAV parser. Used by `SpekoTTS` to unwrap WAV-encoded proxy responses into raw samples for `AudioByteStream`. Accepted subset: * Valid `RIFF` / `WAVE` header. * `fmt ` chunk present and of `format = 1` (PCM). * 16-bit samples. * `data` chunk reachable by walking subsequent chunks (tolerates e.g. `LIST` chunks between `fmt ` and `data`). Anything outside this subset throws a coded `SpekoAdapterError`: `MALFORMED_AUDIO` for a truncated or non-RIFF payload (retryable, so the router can fail over) and `UNSUPPORTED_AUDIO_FORMAT` for non-PCM or non-16-bit audio (not retryable — retrying a misconfigured provider changes nothing). `channels` is returned as-is; the caller decides whether stereo is acceptable, and `SpekoTTS` rejects it with `UNSUPPORTED_CHANNELS`. ## `pcmSampleRateFromContentType` [#pcmsampleratefromcontenttype] ```ts function pcmSampleRateFromContentType( contentType: string, fallback: number, ): number; ``` Parse the `rate` parameter out of a Cartesia-style content type: ```ts pcmSampleRateFromContentType('audio/pcm;rate=24000', 16_000); // 24000 pcmSampleRateFromContentType('audio/pcm', 16_000); // 16000 pcmSampleRateFromContentType('audio/pcm;rate=abc', 16_000); // 16000 ``` Falls back when the rate is missing, zero, or unparseable. Case-insensitive on `rate=`. ## `createSampleRateNormalizer` [#createsampleratenormalizer] ```ts function createSampleRateNormalizer( inputRate: number, outputRate: number, channels?: number, ): SampleRateNormalizer; interface SampleRateNormalizer { readonly resampling: boolean; push(frame: AudioFrame): AudioFrame[]; flush(): AudioFrame[]; close(): void; } ``` Converts frames from the rate a provider actually produced to the single rate a stage advertises. `SpekoTTS` uses it so every frame it emits carries the declared `sampleRate`, whatever the router served. * Equal rates return a pass-through: `resampling` is `false`, `push` returns the same frame instance, `flush` returns nothing, and no native handle is allocated. * Differing rates wrap `AudioResampler` from `@livekit/rtc-node`. Call `flush()` to drain the resampler's warm-up tail, then `close()` to release the native handle — `close()` is idempotent, so a `finally` block is the right home for it. ```ts const normalizer = createSampleRateNormalizer(48_000, 24_000); try { for (const frame of incoming) { for (const out of normalizer.push(frame)) emit(out); } for (const out of normalizer.flush()) emit(out); } finally { normalizer.close(); } ``` ## Intended usage [#intended-usage] You shouldn't need these helpers when consuming the adapter through [`createSpekoComponents`](/adapter-livekit/create-speko-components) — they're used internally by `SpekoSTT` and `SpekoTTS`. They're exported for: * **Unit tests** — build canned WAV fixtures with `framesToWav`, round-trip them through `parseWav`. * **Custom STT / TTS pipelines** that need to reuse the same WAV framing Speko uses. * **Debugging** — decode what an upstream provider returned without instantiating a full TTS. # createSpekoComponents (/adapter-livekit/create-speko-components) Build a { stt, llm, tts } bundle ready for voice.AgentSession. `createSpekoComponents` is the one-call wiring helper for `voice.AgentSession`. It constructs `SpekoSTT`, `SpekoLLM`, `SpekoTTS` from a single options object and wraps STT and TTS with LiveKit's `StreamAdapter` so Speko's streaming REST proxy can drive a streaming session. ```ts import { createSpekoComponents } from '@spekoai/adapter-livekit'; const { stt, llm, tts } = createSpekoComponents({ speko, vad, intent: { language: 'en-US', optimizeFor: 'balanced' }, }); const session = new voice.AgentSession({ vad, stt, llm, tts }); ``` ## Signature [#signature] ```ts function createSpekoComponents( options: CreateSpekoComponentsOptions, ): SpekoComponents; ``` ## `CreateSpekoComponentsOptions` [#createspekocomponentsoptions] | Field | Type | Required | Description | | ------------------------ | ----------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `speko` | `Speko` | ✅ | Initialised `@spekoai/sdk` client. | | `intent` | [`Intent`](/adapter-livekit/intent) | ✅ | Routing hint shared by STT, LLM, and TTS. | | `vad` | `VAD` | ✅ | VAD instance used by the `stt.StreamAdapter`. Typically `await silero.VAD.load()`. | | `voice` | `string?` | | Voice id passed to `SpekoTTS` (maps to the Speko proxy's `voice` param). | | `constraints` | `PipelineConstraints?` | | Allow-list constraints applied to all three modalities. | | `sentenceTokenizer` | `tokenize.SentenceTokenizer?` | | Tokenizer for chunking LLM output before TTS. Defaults to `tokenize.basic.SentenceTokenizer`. | | `llm` | `{ temperature?, maxTokens? }?` | | Tuning forwarded to `/v1/complete`. | | `ttsOptions` | `{ sampleRate?, speed? }?` | | Output sample rate and speech speed forwarded to `SpekoTTS`. | | `agentId` | `string?` | | Enables the [registered-tools loader](/guides/tool-calling). When set, the adapter calls `speko.agents.tools.listChatTools(agentId)` once per session — using the `speko` client you pass for auth and base URL — and merges the result with LiveKit's runtime `ToolContext`. Registered tools win on name collision. Omit to keep runtime-only behavior. | | `apiBaseUrl` | `string?` | | **Deprecated and ignored** — the loader reads the base URL from the `speko` client. Safe to omit. | | `apiKey` | `string?` | | **Deprecated and ignored** — the loader reads the API key from the `speko` client. Safe to omit. | | `onRegisteredToolsError` | `(err: Error) => void?` | | Called once if the registered-tools fetch fails. Voice session keeps running with runtime-only tools — this is a soft degradation, not a crash. | ## Registered tools [#registered-tools] When `agentId` is set, `createSpekoComponents` constructs a `RegisteredToolsLoader` for the underlying `SpekoLLM`. The loader lazily calls `speko.agents.tools.listChatTools(agentId)` on the first `chat()` of each session — reusing the `Speko` client you pass for auth and base URL — and caches the result for the LLM's lifetime. Voice sessions live for seconds-to-minutes and `chat()` is called many times — re-fetching every turn would be wasteful. (`apiBaseUrl`/`apiKey` are deprecated and ignored; the `speko` client carries both.) On collision with a runtime tool of the same name, the registered tool wins (it's the customer's authoritative declaration). Fetch failures are non-fatal — the loader returns `undefined` and the agent continues with runtime tools only, calling `onRegisteredToolsError` once. `listChatTools` returns every source kind — `inline`, `webhook`, `builtin`, and `integration` — already in the `ChatTool[]` shape `/v1/complete` accepts. See the [tool calling guide](/guides/tool-calling) for the full picture. ## Returns — `SpekoComponents` [#returns--spekocomponents] ```ts interface SpekoComponents { stt: stt.StreamAdapter; // wraps SpekoSTT + vad llm: SpekoLLM; // used directly tts: tts.StreamAdapter; // wraps SpekoTTS + sentenceTokenizer } ``` Drop the returned object straight into a `voice.AgentSession`. ## Custom sentence tokenizer [#custom-sentence-tokenizer] ```ts import { tokenize } from '@livekit/agents'; const { stt, llm, tts } = createSpekoComponents({ speko, vad, intent, sentenceTokenizer: new tokenize.basic.SentenceTokenizer({ minSentenceLength: 20 }), }); ``` Use a longer minimum sentence length if you want fewer, longer TTS calls at the cost of latency before the first audio chunk. ## Constraints shared across modalities [#constraints-shared-across-modalities] ```ts createSpekoComponents({ speko, vad, intent: { language: 'en' }, constraints: { allowedProviders: { stt: ['deepgram'], llm: ['anthropic'], tts: ['cartesia'], }, }, }); ``` Every underlying call (`/v1/transcribe`, `/v1/complete`, `/v1/synthesize`) receives the same constraints object. ## Opting out — use classes directly [#opting-out--use-classes-directly] If you need finer control, construct the classes yourself. `createSpekoComponents` is a convenience wrapper; nothing stops you from building the pipeline manually. ```ts import { SpekoSTT, SpekoLLM, SpekoTTS } from '@spekoai/adapter-livekit'; import { stt, tts, tokenize } from '@livekit/agents'; const spekoSTT = new SpekoSTT({ speko, intent }); const wrappedSTT = new stt.StreamAdapter(spekoSTT, vad); const spekoLLM = new SpekoLLM({ speko, intent, temperature: 0.7 }); const spekoTTS = new SpekoTTS({ speko, intent, voice: 'sonic-english' }); const wrappedTTS = new tts.StreamAdapter(spekoTTS, new tokenize.basic.SentenceTokenizer()); ``` # Intent (/adapter-livekit/intent) Routing hint type and construction-time validator. `Intent` is the routing hint every adapter class takes. It's a re-export of `RoutingIntent` from `@spekoai/sdk`, so anything you already have typed as a `RoutingIntent` passes through without conversion. ```ts import type { Intent, OptimizeFor } from '@spekoai/adapter-livekit'; ``` ## Type [#type] ```ts type Intent = { language: string; // BCP-47 region?: string; // e.g. "global", "us-east4", "europe-west3" optimizeFor?: 'balanced' | 'accuracy' | 'latency' | 'cost'; }; ``` ## `validateIntent(intent)` [#validateintentintent] Throws a descriptive `Error` when the intent is malformed. Called by every adapter class constructor, so a bad intent fails at construction time rather than deep inside the first STT / LLM / TTS call. ```ts import { validateIntent } from '@spekoai/adapter-livekit'; validateIntent({ language: 'en-US' }); // ok validateIntent({ language: '' }); // throws: SpekoAdapter: intent.language is required (BCP-47 tag) validateIntent({ language: 'en', optimizeFor: 'speed' as any }); // throws: SpekoAdapter: unknown optimizeFor "speed". Expected one of: balanced, accuracy, latency, cost. ``` Validation rules: * `language` must be a non-empty string. * `region`, if set, is forwarded to Speko for region-aware latency ranking. * `optimizeFor`, if set, must be one of `balanced`, `accuracy`, `latency`, `cost`. No BCP-47 syntactic validation beyond "is a non-empty string" — the router accepts short codes (`en`) and region-tagged codes (`es-MX`) and normalises downstream. ## Sharing one intent [#sharing-one-intent] The adapter pattern is "one intent per agent session, shared across modalities": ```ts const intent: Intent = { language: 'en-US', region: 'global', optimizeFor: 'latency' }; const { stt, llm, tts } = createSpekoComponents({ speko, vad, intent }); ``` If you need per-modality divergence (e.g. latency-optimised STT with cost-optimised TTS), construct the classes directly: ```ts const sttAdapter = new SpekoSTT({ speko, intent: { ...intent, optimizeFor: 'latency' } }); const ttsAdapter = new SpekoTTS({ speko, intent: { ...intent, optimizeFor: 'cost' } }); ``` # @spekoai/adapter-livekit (/adapter-livekit/overview) LiveKit Agents adapter — route STT, LLM, and TTS through Speko. `@spekoai/adapter-livekit` bridges a [LiveKit Agents](https://docs.livekit.io/agents/) worker to the Speko proxy. Drop it into a standard agent entry file and the router picks the best STT, LLM, and TTS provider per call. Failover is server-side; you don't ship provider API keys. ## Install [#install] ```sh npm install @spekoai/sdk @spekoai/adapter-livekit \ @livekit/agents @livekit/agents-plugin-silero @livekit/rtc-node ``` `@livekit/agents` and `@livekit/rtc-node` are peer dependencies — pin the versions you actually run against in your own `package.json`. ## Quickstart [#quickstart] ```ts import { type JobContext, type JobProcess, ServerOptions, cli, defineAgent, voice, } from '@livekit/agents'; import * as silero from '@livekit/agents-plugin-silero'; import { Speko } from '@spekoai/sdk'; import { createSpekoComponents } from '@spekoai/adapter-livekit'; import { fileURLToPath } from 'node:url'; const speko = new Speko({ apiKey: process.env.SPEKO_API_KEY! }); export default defineAgent({ prewarm: async (proc: JobProcess) => { proc.userData.vad = await silero.VAD.load(); }, entry: async (ctx: JobContext) => { const vad = ctx.proc.userData.vad as silero.VAD; const { stt, llm, tts } = createSpekoComponents({ speko, vad, intent: { language: 'en-US', optimizeFor: 'balanced' }, }); const session = new voice.AgentSession({ vad, stt, llm, tts }); await session.start({ agent: new voice.Agent({ instructions: 'You are a helpful voice assistant. Be concise.', }), room: ctx.room, }); await ctx.connect(); session.generateReply({ instructions: 'Greet the user and offer your assistance.' }); }, }); cli.runApp( new ServerOptions({ agent: fileURLToPath(import.meta.url), agentName: 'speko-demo', }), ); ``` ## Architecture [#architecture] The adapter exports three `@livekit/agents`-compatible classes — `SpekoSTT`, `SpekoLLM`, `SpekoTTS` — and a convenience factory `createSpekoComponents()` that wraps STT and TTS with `StreamAdapter` helpers so Speko's streaming REST proxy can participate in a streaming `voice.AgentSession`: * **`SpekoSTT`** declares `{ streaming: false }`, so it must be wrapped with `new stt.StreamAdapter(spekoSTT, vad)` to segment utterances with VAD before calling `/v1/transcribe`. * **`SpekoTTS`** is sentence-bounded in LiveKit, so it is wrapped with `new tts.StreamAdapter(spekoTTS, sentenceTokenizer)` before each streaming `/v1/synthesize` call. * **`SpekoLLM`** is used directly — it's a `llm.LLM` backed by streaming `/v1/complete` responses. `createSpekoComponents` handles the wrapping for you and returns `{ stt, llm, tts }` ready to pass to `voice.AgentSession`. ## v1 limitations [#v1-limitations] * **STT request upload is utterance-bounded.** `/v1/transcribe` streams transcript events back, but this adapter still uploads one VAD-segmented WAV per utterance instead of full-duplex microphone audio. * **TTS remains sentence-bounded in LiveKit.** `/v1/synthesize` streams audio bytes; the adapter still calls it once per tokenized sentence. * **Tool calls are supported.** Inline tools return to the LiveKit runtime; registered webhook, builtin, and integration tools run server-side through `/v1/complete`. * **TTS output format.** Accepts `audio/pcm;rate=NNNN` (Cartesia) and `audio/wav`. Throws on `audio/mpeg` (ElevenLabs MP3) — pick a routing intent that prefers Cartesia, or pin a PCM-capable provider via `constraints.allowedProviders.tts`. * **STT input format.** Mono PCM16, encoded into a WAV wrapper per utterance. Multi-channel frames throw. Speko handles sample-rate conversion downstream — whatever the `AudioFrame` carries is what's uploaded. ## Reference [#reference] * [`createSpekoComponents`](/adapter-livekit/create-speko-components) — convenience factory. * [`SpekoSTT`](/adapter-livekit/speko-stt) — STT class. * [`SpekoLLM`](/adapter-livekit/speko-llm) — LLM class. * [`SpekoTTS`](/adapter-livekit/speko-tts) — TTS class. * [`Intent`](/adapter-livekit/intent) — routing hint type and validator. * [Audio helpers](/adapter-livekit/audio) — WAV encode/decode utilities. # SpekoLLM (/adapter-livekit/speko-llm) LiveKit Agents LLM adapter backed by POST /v1/complete. `SpekoLLM` is a `llm.LLM` implementation. It flattens a LiveKit `ChatContext` into Speko's `messages` format and calls the proxy. The router picks the best LLM provider per intent and fails over automatically. ```ts import { SpekoLLM } from '@spekoai/adapter-livekit'; const spekoLLM = new SpekoLLM({ speko, intent: { language: 'en' }, temperature: 0.7, maxTokens: 400, }); ``` Unlike STT and TTS, `SpekoLLM` doesn't need a `StreamAdapter`. It calls the streaming `/v1/complete` endpoint through the SDK and emits a LiveKit `LLMStream` chunk when the routed completion is ready. ## Constructor [#constructor] ```ts new SpekoLLM(options: SpekoLLMOptions) ``` ### `SpekoLLMOptions` [#spekollmoptions] | Field | Type | Required | Description | | ------------------------ | ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `speko` | `Speko` | ✅ | `@spekoai/sdk` client. | | `intent` | [`Intent`](/adapter-livekit/intent) | ✅ | Validated at construction time. | | `temperature` | `number?` | | Forwarded to `/v1/complete`. | | `maxTokens` | `number?` | | Forwarded to `/v1/complete`. | | `constraints` | `PipelineConstraints?` | | Allow-list constraints. | | `agentId` | `string?` | | When set, enables the registered-tools loader. The adapter calls `speko.agents.tools.listChatTools(agentId)` once per session — using the `speko` client for auth and base URL — and merges the result with LiveKit's runtime `ToolContext`. Registered tools win on collision. Omit to keep runtime-only behavior. See [tool calling](/guides/tool-calling). | | `apiBaseUrl` | `string?` | | **Deprecated and ignored** — the loader reads the base URL from the `speko` client. Safe to omit. | | `apiKey` | `string?` | | **Deprecated and ignored** — the loader reads the API key from the `speko` client. Safe to omit. | | `onRegisteredToolsError` | `(err: Error) => void?` | | Called once if the registered-tools fetch fails. Soft degradation — the call continues with runtime-only tools rather than crashing. | ## Properties [#properties] * `label() → 'speko.LLM'` * `provider = 'speko'` * `model = 'speko-router'` ## `.chat(params)` [#chatparams] Standard LiveKit LLM entry point. Returns an `LLMStream` that emits a `ChatChunk` carrying the assistant response or tool calls, then closes. Signature (from `@livekit/agents`): ```ts chat(params: { chatCtx: llm.ChatContext; toolCtx?: llm.ToolContext; connOptions?: APIConnectOptions; parallelToolCalls?: boolean; toolChoice?: llm.ToolChoice; extraKwargs?: Record; }): llm.LLMStream; ``` The emitted chunk includes usage: ```ts { id: '', delta: { role: 'assistant', content: result.text }, usage: { promptTokens: result.usage.promptTokens, completionTokens: result.usage.completionTokens, promptCachedTokens: 0, totalTokens: result.usage.promptTokens + result.usage.completionTokens, }, } ``` ## Context conversion — `chatContextToSpeko` [#context-conversion--chatcontexttospeko] Exported for when you want to reuse the flattening logic (e.g. unit tests, custom pipelines). ```ts import { chatContextToSpeko } from '@spekoai/adapter-livekit'; const messages = chatContextToSpeko(chatCtx); ``` Rules: * Only `llm.ChatMessage` items are considered. Function-call and handoff items are skipped. * Roles are normalised: `developer` → `system`; `system` / `user` / `assistant` pass through; anything else is dropped. * Empty `textContent` messages are skipped. * Ordering is preserved. If the result is empty, `.chat()` rejects with `SpekoAdapterError('INVALID_CONTEXT')`. ## Tool Calls [#tool-calls] Runtime tools from LiveKit's `toolCtx` are forwarded as inline tools. Registered webhook, builtin, and integration tools can also be loaded by `agentId` — the same set `speko.agents.tools.listChatTools(agentId)` returns — and executed server-side by Speko before the final response is returned to LiveKit. ## Errors [#errors] * `SpekoAdapterError` (exported): thrown for adapter-internal problems. `code` is one of: * `'INVALID_CONTEXT'` — `ChatContext` produced no convertible messages. API-layer errors from the underlying `speko.complete()` surface unchanged — `SpekoApiError`, `SpekoAuthError`, `SpekoRateLimitError` from `@spekoai/sdk`. # SpekoSTT (/adapter-livekit/speko-stt) LiveKit Agents STT adapter backed by POST /v1/transcribe. `SpekoSTT` is a `stt.STT` implementation. It encodes each utterance's audio frames into a WAV payload and uploads it to the Speko proxy. The router picks the best STT provider for your `(language, region, optimizeFor)` and handles failover. ```ts import { SpekoSTT } from '@spekoai/adapter-livekit'; import { stt as sttNs } from '@livekit/agents'; const spekoSTT = new SpekoSTT({ speko, intent: { language: 'en-US' }, }); const wrapped = new sttNs.StreamAdapter(spekoSTT, vad); ``` ## Constructor [#constructor] ```ts new SpekoSTT(options: SpekoSTTOptions) ``` ### `SpekoSTTOptions` [#spekosttoptions] | Field | Type | Required | Description | | ------------- | ----------------------------------- | -------- | -------------------------------------------- | | `speko` | `Speko` | ✅ | `@spekoai/sdk` client. | | `intent` | [`Intent`](/adapter-livekit/intent) | ✅ | Validated at construction time. | | `constraints` | `PipelineConstraints?` | | Allow-list constraints passed on every call. | The constructor calls `validateIntent(intent)` — a broken routing hint throws here rather than deep inside the first transcription. ## Properties [#properties] * `label = 'speko.STT'` * `provider = 'speko'` * `model = 'speko-router'` * `streaming = false`, `interimResults = false` ## Streaming requirement [#streaming-requirement] `SpekoSTT.stream()` throws because this adapter uploads one VAD-segmented WAV per utterance. The `/v1/transcribe` response itself streams transcript events, and `speko.transcribe()` aggregates the final result for this class. Wrap the instance: ```ts import { stt } from '@livekit/agents'; const adapter = new stt.StreamAdapter(spekoSTT, vad); ``` Or use [`createSpekoComponents`](/adapter-livekit/create-speko-components) which does this for you. ## Per-utterance flow [#per-utterance-flow] 1. `StreamAdapter` + VAD segment the user's audio into utterances. 2. `SpekoSTT._recognize(frame, abortSignal)` is invoked for each utterance. 3. Frames are combined (`combineAudioFrames`) and encoded into PCM16 mono WAV via [`framesToWav`](/adapter-livekit/audio#framestowav). 4. The WAV is uploaded via `speko.transcribe()` with the intent header and any `constraints`. 5. The result is emitted as a single `FINAL_TRANSCRIPT` event with confidence defaulting to `1` when the upstream provider doesn't report one. Aborts propagate: when the session tears down, the `AbortSignal` passed by `StreamAdapter` cancels the in-flight HTTP request. ## Mono-only [#mono-only] Multi-channel audio throws at the WAV-encode step: ``` SpekoSTT: expected mono audio (1 channel), got 2. … ``` Configure your LiveKit `AgentSession` to pass mono audio, or pre-mix upstream. # SpekoTTS (/adapter-livekit/speko-tts) LiveKit Agents TTS adapter backed by POST /v1/synthesize. `SpekoTTS` is a `tts.TTS` implementation. Each sentence is synthesised via the Speko proxy, decoded into PCM, chunked into `AudioFrame`s at 50 Hz (20 ms frames), and pushed to the LiveKit session. ```ts import { SpekoTTS } from '@spekoai/adapter-livekit'; import { tts as ttsNs, tokenize } from '@livekit/agents'; const spekoTTS = new SpekoTTS({ speko, intent: { language: 'en' }, voice: 'sonic-english', sampleRate: 24_000, }); const wrapped = new ttsNs.StreamAdapter(spekoTTS, new tokenize.basic.SentenceTokenizer()); ``` ## Constructor [#constructor] ```ts new SpekoTTS(options: SpekoTTSOptions) ``` ### `SpekoTTSOptions` [#spekottsoptions] | Field | Type | Required | Description | | ------------- | ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `speko` | `Speko` | ✅ | `@spekoai/sdk` client. | | `intent` | [`Intent`](/adapter-livekit/intent) | ✅ | Validated at construction time. | | `voice` | `string?` | | Voice id forwarded to the proxy. | | `speed` | `number?` | | Speech-speed multiplier forwarded to the proxy. | | `sampleRate` | `number?` | | Output sample rate advertised to LiveKit. Default `24000`. Every emitted frame is at this rate; a provider that emits another rate is resampled to it. | | `constraints` | `PipelineConstraints?` | | Allow-list constraints. | ## Properties [#properties] * `label = 'speko.TTS'` * `provider = 'speko'` * `model = 'speko-router'` * `numChannels = 1`, `streaming = false` ## Streaming requirement [#streaming-requirement] `SpekoTTS.stream()` throws because LiveKit's TTS `StreamAdapter` handles sentence tokenization for this class. `/v1/synthesize` streams audio bytes for each sentence request. Wrap: ```ts import { tts, tokenize } from '@livekit/agents'; const adapter = new tts.StreamAdapter(spekoTTS, new tokenize.basic.SentenceTokenizer()); ``` Or use [`createSpekoComponents`](/adapter-livekit/create-speko-components) which does this for you. ## `.synthesize(text, connOptions?, abortSignal?)` [#synthesizetext-connoptions-abortsignal] Returns a `SpekoTTSChunkedStream` (exported for type use). Internally: 1. Calls `speko.synthesize(text, { ...intent, voice, speed, constraints })`. 2. Reads the response's real audio format from `X-Speko-Audio-Format` (falling back to `Content-Type`) and decodes via [`decodeSynthesisResult`](#decodesynthesisresult). 3. Chunks the PCM into `AudioFrame`s of `round(responseRate / 50)` samples each via `AudioByteStream`. 4. Resamples to the configured `sampleRate` when the response rate differs, so playback is never pitched. Matching rates are a zero-copy pass-through. 5. Pushes frames onto the output queue, marking the last one `final: true`. Empty provider output throws `SpekoTTS: provider returned empty audio`. ## Audio format support (v1) [#audio-format-support-v1] `decodeSynthesisResult(result)` branches on `result.contentType`: | Content type | Behavior | | --------------------------- | ----------------------------------------------------------------------------------------------------------- | | `audio/pcm;rate=NNNN` | Raw PCM, rate parsed from the MIME. Channels pinned to `1` (Cartesia's contract). | | `audio/wav` / `audio/x-wav` | Header stripped via [`parseWav`](/adapter-livekit/audio#parsewav). Stereo WAV throws. | | `audio/mpeg` | Throws — v1 doesn't include an MP3 decoder. Pin Cartesia or another PCM-capable provider via `constraints`. | | anything else | Throws with provider info for debugging. | Work around MP3 by pinning your TTS pool: ```ts new SpekoTTS({ speko, intent, constraints: { allowedProviders: { tts: ['cartesia'] } }, }); ``` ## Sample rates [#sample-rates] The router serves whatever rate the chosen provider produces: 24 kHz for most, 48 kHz for Hume and Gradium, 16 kHz for Amazon Polly. The adapter reads that rate per response from `X-Speko-Audio-Format` and resamples to the `sampleRate` this instance advertises, so routing to (or failing over onto) a provider at another rate is handled rather than fatal. Since v0.1.3 a differing rate is no longer an error. Earlier versions rejected the utterance with `SpekoTTS: provider returned audio at 16000 Hz but the TTS was configured for 24000 Hz`. Set `sampleRate` to the rate your pipeline wants; leaving it at the 24 kHz default is the cheapest option because most providers emit it natively and need no resampling. ## `decodeSynthesisResult` [#decodesynthesisresult] Exported for unit testing. Given a `SynthesizeResult` (and optionally the audio-format string to branch on, normally the response's `X-Speko-Audio-Format`), returns `{ pcm, sampleRate, channels }`. Throws for unsupported content types (see table above). The returned `sampleRate` is whatever the response declares; the caller normalizes it. ```ts import { decodeSynthesisResult } from '@spekoai/adapter-livekit'; ``` # @spekoai/ai-sdk-provider (/ai-sdk-provider/overview) Speko provider for the Vercel AI SDK — routed speech and transcription. `@spekoai/ai-sdk-provider` plugs Speko into the [Vercel AI SDK](https://ai-sdk.dev) as a speech and transcription provider. One provider entry replaces per-vendor voice code, and Speko's router picks the upstream provider per request. ## Install [#install] ```bash npm install @spekoai/ai-sdk-provider ai ``` ## Setup [#setup] Get a key at [platform.speko.ai](https://platform.speko.ai/agents/keys) and set `SPEKO_API_KEY`. The default instance reads it from the environment: ```ts import { speko } from '@spekoai/ai-sdk-provider'; ``` To pass the key explicitly, or point at another base URL: ```ts import { createSpeko } from '@spekoai/ai-sdk-provider'; const speko = createSpeko({ apiKey: process.env.SPEKO_API_KEY, }); ``` ## First call [#first-call] ```ts import { speko } from '@spekoai/ai-sdk-provider'; import { generateSpeech } from 'ai'; const { audio } = await generateSpeech({ model: speko.speech('auto'), text: 'Welcome to Speko.', }); ``` `audio.uint8Array` holds WAV bytes. Speko synthesizes raw PCM and this provider wraps it in a WAV container at the routed provider's sample rate. ## What it implements [#what-it-implements] Provider specification v4 — `SpeechModelV4` and `TranscriptionModelV4` — for `ai` v7. `languageModel`, `embeddingModel`, and `imageModel` throw `NoSuchModelError`. Speko is a voice platform. ## Next [#next] # Routing options (/ai-sdk-provider/routing) Model ids and providerOptions.speko for the AI SDK provider. The model id selects a routing strategy, not a single model. ## Model ids [#model-ids] | Model id | Behavior | | ----------------------------------- | ------------------------------------------------------ | | `auto` | Router default for your organization's routing policy | | `auto-fast` | Bias toward latency | | `auto-quality` | Bias toward accuracy and quality | | `auto-cheap` | Bias toward cost | | `deepgram` | Pin routing and failover to one upstream provider | | `elevenlabs/eleven_multilingual_v2` | Pin the provider and request a specific upstream model | ```ts speko.transcription('auto-quality'); speko.speech('elevenlabs/eleven_multilingual_v2'); ``` ## providerOptions.speko [#provideroptionsspeko] | Option | Applies to | Description | | -------------- | ------------- | ------------------------------------------------------------------------------------ | | `language` | both | BCP-47 tag, for example `en` or `es-MX`. Routing is language-aware. Defaults to `en` | | `region` | both | Region to rank providers in, for example `us-east4` or `eu-west1` | | `optimizeFor` | both | `balanced`, `accuracy`, `latency`, or `cost`. Overrides the model id preset | | `providers` | both | Restrict routing and failover to these upstream providers | | `keywords` | transcription | Domain keywords forwarded to the routed STT provider | | `outputFormat` | speech | `wav` (default) or `pcm` for raw s16le mono | | `model` | speech | Upstream model override, for example `sonic-2` | | `spokenForm` | speech | Deterministic spoken-form normalization before synthesis | ## Failover [#failover] Failover happens server-side. If the routed provider errors or degrades, Speko retries against the next-ranked provider before the call returns. A single `generateSpeech` or `transcribe` call can therefore cross providers without the caller noticing. `providerMetadata.speko.failoverCount` reports how many were tried. Use `providers` to constrain the pool the router and its failover may draw from. ## Unsupported settings [#unsupported-settings] `fetch` and `headers` provider settings are not supported. `@spekoai/sdk` manages its own transport and headers, so custom headers produce an unsupported-setting warning rather than being sent. # Speech (/ai-sdk-provider/speech) Synthesize audio through generateSpeech with routed TTS providers. Pass `speko.speech(...)` to the AI SDK's `generateSpeech`. ```ts import { speko } from '@spekoai/ai-sdk-provider'; import { generateSpeech } from 'ai'; const { audio } = await generateSpeech({ model: speko.speech('auto'), text: 'Welcome to Speko.', voice: 'QtY3JBOUKEB5xzrRfOKc', providerOptions: { speko: { language: 'en', optimizeFor: 'latency' }, }, }); ``` `voice` is optional and provider-specific. Leave it out and the router picks a voice for the resolved language. ## Output format [#output-format] `audio.uint8Array` returns WAV by default. Request raw signed 16-bit little-endian mono PCM instead: ```ts const { audio } = await generateSpeech({ model: speko.speech('auto'), text: 'Welcome to Speko.', providerOptions: { speko: { outputFormat: 'pcm' }, }, }); ``` The sample rate of the routed provider is reported in `providerMetadata.speko.sampleRate`. ## Pinning a provider or model [#pinning-a-provider-or-model] The model id selects a routing strategy rather than a single model: ```ts speko.speech('auto-quality'); // bias toward quality speko.speech('elevenlabs/eleven_multilingual_v2'); // pin provider and model ``` See [Routing options](/ai-sdk-provider/routing) for the full id list and every `providerOptions.speko` field. # Transcription (/ai-sdk-provider/transcription) Transcribe audio through transcribe with routed STT providers. Pass `speko.transcription(...)` to the AI SDK's `transcribe`. ```ts import { speko } from '@spekoai/ai-sdk-provider'; import { transcribe } from 'ai'; import { readFile } from 'node:fs/promises'; const result = await transcribe({ model: speko.transcription('auto'), audio: await readFile('call.wav'), providerOptions: { speko: { language: 'es-MX', keywords: ['Speko', 'Vercel'], }, }, }); console.log(result.text); ``` `keywords` are domain terms forwarded to the routed STT provider, which improves recognition of names and product vocabulary. ## Which provider answered [#which-provider-answered] `providerMetadata.speko` reports what the router did: ```ts console.log(result.providerMetadata.speko); // { provider: 'deepgram', model: 'nova-3', confidence: 0.93, failoverCount: 0, scoresRunId: '...' } ``` `failoverCount` is the number of upstream providers tried before one succeeded. ## Segments and duration [#segments-and-duration] | Input | `durationInSeconds` | `segments` | | -------------- | ------------------- | ---------------------------- | | WAV or raw PCM | reported | one whole-transcript segment | | Other formats | `undefined` | empty | The one-shot transcription endpoint does not report per-word timing yet, so duration is only available when the input length is deterministic. ## Pinning a provider [#pinning-a-provider] ```ts speko.transcription('auto-quality'); // bias toward accuracy speko.transcription('deepgram'); // pin routing and failover to one provider ``` See [Routing options](/ai-sdk-provider/routing) for every model id and option. # Signing in (/cli/auth) How speko-cli authenticates, what it stores, and how to revoke a terminal. ```bash speko-cli login ``` Prints a short code and a URL. A human approves it in a browser — that step cannot be automated, and it is the only one that cannot. ## Why a device grant [#why-a-device-grant] `speko-cli login` uses the [RFC 8628 device authorization grant](https://datatracker.ietf.org/doc/html/rfc8628) rather than a loopback redirect, because the CLI often runs where no browser can reach it: a container, an SSH session, a coding agent's sandbox. A loopback redirect assumes `localhost` is the same machine as the browser. Frequently it is not. ## It never blocks an agent [#it-never-blocks-an-agent] When output is not a terminal — which is the case for every coding agent — or when `--no-wait` is passed, `login` prints the URL, writes the pending grant down, and exits 0. Run it again to finish once a human has approved. ```bash speko-cli login --no-wait # prints the URL, returns immediately speko-cli login # run again to complete ``` This matters: a command that blocks for fifteen minutes hangs an agent's whole session, and the agent will abandon the task rather than wait. ## What it stores [#what-it-stores] A **session token**, in `~/.config/speko/credentials.json` at mode `0600` (or under `XDG_CONFIG_HOME` where set). That token is as powerful as being signed in — treat the file as a secret. ## A CLI session is deliberately weaker than a browser one [#a-cli-session-is-deliberately-weaker-than-a-browser-one] `speko-cli whoami` shows three scopes where a browser session holds six: ``` speko:read speko:write speko:execute ``` Absent are `speko:credentials`, `speko:billing` and `speko:compliance`. So a terminal cannot read the organization's master MCP key or webhook signing secret, even though the same login works in both places. The server marks the session's origin when it is minted, and that marker is not derived from anything the client sends. ## Revoking [#revoking] ```bash speko-cli auth list # every signed-in terminal, current one marked speko-cli auth revoke # end one session speko-cli auth revoke --all # end every CLI session, including this one speko-cli logout # local only — clears the file, session stays valid ``` `logout` is local and says so. Revoking is what actually ends access. `auth list` and the console's CLI devices page read the same endpoints, so the two cannot disagree about what is signed in. # Benchmarks (/cli/benchmarks) The measured provider scores routing decides on, from the terminal. ```bash speko-cli bench # every stage speko-cli bench stt --language nb # ranked by word error rate speko-cli bench llm # ranked by measured latency speko-cli bench tts --provider cartesia speko-cli bench session # what one call actually ran on ``` The stage boards need no credential. These are published measurements, and the person most likely to want them is deciding whether to sign up at all. `bench session` is the one exception — a session belongs to a workspace, so it requires sign-in and exits 3 without it. ## What the columns mean [#what-the-columns-mean] | Column | | | --------- | ------------------------------------------------------------ | | `WER` | Word error rate at p50. Lower is better. Transcription only. | | `LATENCY` | Measured first-response latency at p50, in ms. | | `$/MIN` | Cost per minute, where it has been measured. | Stages rank on the axis that means something for them: transcription on word error rate, everything else on latency. Sorting a language model by a transcription metric would put an arbitrary row on top. ## A dash is not a zero [#a-dash-is-not-a-zero] `—` means the metric was **not measured**. It never means zero. The distinction is load-bearing for cost: a provider nobody has priced would otherwise render as `$0.0000` and look like the cheapest option on the board. Unmeasured values are omitted rather than defaulted, all the way through the API. ## What one call ran on [#what-one-call-ran-on] ```bash speko-cli bench session ``` Joins the session's actual pipeline against the measured rows: what ran, what was measured about it, and the best measured option for the same stage and language. It does **not** explain why that stack was chosen. A session records what ran and no reason beside it, so any explanation would be reconstructed after the fact — and the command says so on every run rather than implying otherwise. # Errors and exit codes (/cli/errors) Exit codes are part of the contract, and every error body says whether to retry. ## Exit codes [#exit-codes] Stable, and part of the contract — a caller in CI must be able to tell these apart without parsing prose. | Code | Meaning | | ---- | ----------------------------------------------- | | 0 | Success | | 1 | Runtime failure (network, server error) | | 2 | Usage error (unknown command, missing argument) | | 3 | Not signed in, or the credential was rejected | | 4 | Resource not found | | 5 | Out of credit, or rate limited | | 6 | An eval suite regressed | `3` is how a script checks before assuming it can act: ```bash speko-cli whoami >/dev/null 2>&1 || { echo "run: speko-cli login"; exit 1; } ``` ## Explaining a code [#explaining-a-code] ```bash speko-cli explain INSUFFICIENT_CREDITS ``` Needs no credential — the catalogue is public, so explaining `UNAUTHORIZED` does not fail with the error it is explaining. Most codes carry only a category so far. Where no explanation has been written, it says so rather than inventing one. ## Error bodies [#error-bodies] Every error response carries: | Field | | | ----------- | -------------------------------------------------------------- | | `code` | Stable identifier, e.g. `INSUFFICIENT_CREDITS` | | `retryable` | Whether retrying could possibly help. **Branch on this.** | | `docs_url` | Where the code is documented | | `hint` | What to do about it, where one has been written | ## Before debugging a failing call [#before-debugging-a-failing-call] ```bash speko-cli doctor ``` Reports credit, provider reachability, the scopes this session holds, and the last failed session. It exits non-zero only when something found will actually stop a call from working, so it is usable as a precondition in a script. It states facts rather than verdicts: it names the provider that needs a key instead of reporting that routing failed, and points at the last failed session without guessing its cause. # Evals (/cli/evals) Prove a prompt change did not break the agent, and fail CI when it did. A voice regression is invisible to every tool a coding agent has. Change one line of a system prompt and the agent quietly stops confirming before it books — no diff, type check or unit test registers it. The user finds out from a real customer. ```bash speko-cli eval generate --agent # propose a suite; prints it, saves nothing speko-cli eval generate --agent --persist # keep it speko-cli eval list --agent # what is stored speko-cli eval run --agent # run it, report what broke speko-cli eval trends --agent # pass rate over time ``` ## Generation writes the suite for you [#generation-writes-the-suite-for-you] `eval generate` builds cases from the agent's own system prompt, tools and knowledge-base titles, so nobody hand-authors test cases. **Preview is the default.** Generation calls a model, so the same agent yields a different suite each run; writing on the first run would leave someone who only wanted to look with rows to clean up. Add `--persist` to keep them. ## Exit 6 means the agent is wrong [#exit-6-means-the-agent-is-wrong] ```bash speko-cli eval run --agent echo $? # 6 on a failing case ``` Not 1. A CI step has to tell "the agent's behaviour is wrong" from "the network was down" — collapse them and the first failure that was really a flake teaches everyone to ignore the gate. ```bash speko-cli eval run --agent "$AGENT" || { status=$? [ "$status" -eq 6 ] && { echo "behaviour regressed"; exit 1; } echo "eval could not run (exit $status)"; exit 0 } ``` ## A queued run is not a failed run [#a-queued-run-is-not-a-failed-run] Runs are queued for a worker that places the simulated call and scores it. A run that never leaves `queued` means nothing is consuming the queue — reported as exactly that, separately from a slow run that did reach `running`. The two need opposite responses: one is missing infrastructure, the other is a prompt to fix. Calling an unclaimed run a test failure would send someone to edit a prompt that was never tested. ## Reading a failure [#reading-a-failure] `eval run` prints the broken case, what the worker said about it, and the command to re-run that one case alone — with the real ids, so it can be copied. # @spekoai/cli (/cli/overview) Build and operate voice agents from the terminal, including from a coding agent. `speko-cli` covers the Speko API from a shell: sign in, create agents, place calls, read transcripts, and prove a prompt change did not break anything. It exists so a coding agent can build on Speko without a person clicking through the console. ## Install [#install] ```bash npm install -g @spekoai/cli speko-cli login ``` Or without installing: ```bash npx @spekoai/cli login ``` The command is `speko-cli`, not `speko` — `@spekoai/mcp-calls` already uses that name. ## Teach a coding agent about it [#teach-a-coding-agent-about-it] ```bash speko-cli init ``` Writes `.claude/skills/speko/SKILL.md`, a marked block in `AGENTS.md`, and `.env.example`. It never writes a credential into the project, and never overwrites an existing file without `--force`. Every command also takes `--help`, and asking never performs the action — so an agent can explore the surface without side effects. ## Commands [#commands] | Command | What it does | | ----------------------------- | -------------------------------------------------------------- | | `login` / `logout` / `whoami` | Sign this device in, out, and check who it is | | `auth list` / `auth revoke` | See and revoke every signed-in terminal | | `init` | Write Speko guidance for coding agents into the repo | | `call --to ` | Place a call, wait for it, print the transcript | | `logs ` | Call events, `--follow` to stream | | `doctor` | Why calls are failing: credit, providers, scopes, last failure | | `explain ` | What an error code means, and whether retrying helps | | `bench [stage]` | Measured provider scores — the numbers routing decides on | | `eval` | Generate a test suite for an agent, run it, report regressions | | `mcp` | Point an MCP client at Speko | Plus every operation in the [OpenAPI document](https://docs.speko.ai/api), generated rather than hand-written: ```bash speko-cli agents # list a group's operations speko-cli agents list # every agent in the workspace speko-cli agents get speko-cli agents create --data '{"name":"Front desk"}' ``` Groups come from the spec's tags: `agents`, `call-control`, `providers`, `sms`, `telephony`, `voice`, `webhooks`. The canonical name is the operationId, kebab-cased (`listAgents` → `list-agents`); `list`, `get`, `create`, `update` and `delete` are aliases where a group makes them unambiguous. Path parameters are positional in URL order, query parameters are `--flags`, and a request body comes from `--data JSON`, `--file PATH`, or stdin. Add `--json` to any command for machine-readable output. ## The development loop [#the-development-loop] ```bash speko-cli call --to +15551234567 --agent # place it, wait, print the transcript speko-cli logs --follow # events as they arrive speko-cli eval run --agent # prove the change is safe ``` `call` prints the session id before it starts waiting, so the id survives a timeout or a Ctrl-C. `--no-wait` returns immediately. A call that ends `failed` exits 1, so a script placing calls in a loop can notice. The transcript is the only way to verify a prompt change altered behaviour — a diff cannot show that an agent stopped confirming before it booked. ## What the CLI will not do [#what-the-cli-will-not-do] Some acts belong to a person, so the CLI prints the console URL instead of doing them: | Operation | Why | | ----------------------------------- | ----------------------------------------------- | | `telephony submit-phone-number-kyb` | Asserts you are authorized to bind the business | | `telephony create-phone-number` | Spends money and starts a recurring charge | | `telephony delete-phone-number` | Returns the number to the carrier permanently | There is **no command for API keys at all**. A key is a long-lived organization credential destined for production, so it is issued in the console by a person. `agents delete-agent` and `sms redact-sms-conversation` are destructive and deliberately stay: the first is how anyone iterating discards a test agent, and the second is plausibly how a data-deletion request gets serviced, which wants to be scriptable rather than clicked. ## Environment [#environment] | Variable | Default | | --------------------- | --------------------------- | | `SPEKO_API_URL` | `https://api.speko.dev` | | `SPEKO_DASHBOARD_URL` | `https://platform.speko.ai` | | `XDG_CONFIG_HOME` | `~/.config` | Source: [github.com/SpekoAI/cli](https://github.com/SpekoAI/cli) # Callbacks & events (/client/callbacks) Every hook VoiceConversation exposes, and when they fire. All callbacks are optional. Pass them inside the `ConversationOptions` object. They're invoked synchronously on the media transport event loop — keep them fast or defer work with `queueMicrotask`. ## `ConversationStatus` [#conversationstatus] ```ts type ConversationStatus = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'; ``` Transitions: * **`connecting`** — the initial state, set the moment the `WebRTCConnection` is constructed. * **`connected`** — after `room.connect()`, `createLocalAudioTrack()`, and `publishTrack()` all succeed. * **`disconnecting`** — `endSession()` has been called but the room hasn't acknowledged yet. * **`disconnected`** — the transport has fired `Disconnected`, OR an error during `connect()` (connection, mic) short-circuited to this state. `onStatusChange` fires only on actual transitions; duplicate transitions are deduped. ## `ConversationMode` [#conversationmode] ```ts type ConversationMode = 'listening' | 'speaking'; ``` Mirrors transport active-speaker events: `speaking` when any remote participant is in the active-speakers set, `listening` otherwise. Useful for UI states like "agent talking now — show the voice animation". Deduped on transition — `onModeChange` won't fire twice for the same mode. ## `ConversationMessage` [#conversationmessage] ```ts interface ConversationMessage { source: 'agent' | 'user'; text: string; isFinal: boolean; segmentId?: string; } ``` `onMessage` fires from two sources — live transcriptions (the common case when talking to a Speko agent) and custom data-channel packets: | Inbound event | Becomes | | -------------------------- | -------------------------------------------------------------------------------------------------------- | | Transcription segment | `{ source, text, isFinal, segmentId }` — `source` is `user` for the local participant, `agent` otherwise | | `transcript` packet | `{ source: packet.source, text, isFinal: packet.isFinal ?? true }` | | `agent_message` packet | `{ source: 'agent', text, isFinal: packet.isFinal ?? true }` | | `user_message_echo` packet | `{ source: 'user', text, isFinal: true }` | Transcription updates are **cumulative per segment**: the same `segmentId` is re-delivered with growing `text` (the agent's transcript streams word-by-word; the user's utterance is re-published in full on every recognizer update, and the final text can arrive more than once). Render by **upserting on `(source, segmentId)`** — replace that message's text in place, and only append when you see a new `segmentId`. Appending every message duplicates text, and keying only by `source` corrupts the transcript whenever user and agent updates interleave (which is normal). Messages from custom data packets carry no `segmentId`; append those. See [Data channel protocol](/client/data-channel) for the raw wire format. ## `DisconnectionDetails` [#disconnectiondetails] ```ts interface DisconnectionDetails { reason: DisconnectionReason; message?: string; } type DisconnectionReason = 'user' | 'agent' | 'error' | 'timeout' | 'unknown'; ``` The SDK maps transport disconnect reasons into a smaller, intent-oriented set: | Transport disconnect reason | Mapped `reason` | | ------------------------------------------------ | --------------- | | Client initiated | `user` | | Participant removed / room deleted / room closed | `agent` | | Join failure | `error` | | everything else (including `undefined`) | `unknown` | `message` is the raw transport enum name when available (useful for debugging / logging). ## `onConnect` [#onconnect] ```ts onConnect?: (details: { conversationId: string }) => void; ``` Fires exactly once, after the mic is publishing and status is `connected`. `conversationId` is the transport conversation id (same value as `conversation.getId()`). ## `onError` [#onerror] ```ts onError?: (error: Error) => void; ``` Non-fatal errors: * Media device errors from the transport. * Output device selection failures (`setSinkId` rejections). Malformed or unrecognised inbound data packets are silently ignored — rooms carry data from other publishers (server control topics, future participants), so a packet that isn't part of the SDK protocol is not an error. Fatal errors during `create()` are **thrown**, not routed to `onError`. See [Errors](/client/errors). # Data channel protocol (/client/data-channel) Wire format for packets exchanged between browser and agent over the media data channel. Transcripts, agent messages, and the browser's outbound packets travel as JSON-encoded bytes on the reliable media data channel. `@spekoai/client` handles encoding and decoding internally; this page documents the wire format so server / agent implementations can interoperate. ## Encoding [#encoding] * UTF-8 JSON, one message per `publishData` call. * Reliable ordering (`reliable: true`). * No framing beyond JSON — each `DataReceived` event is one complete packet. ## Outbound (browser → agent) [#outbound-browser--agent] **No agent consumes the three packets below.** The SDK publishes them with no data-channel topic; the agent worker's one data handler accepts the `speko.control` topic alone and discards the rest. This is a wire format, not a working feature. Working alternatives: `sendChatMessage(text)` for typed turns (native `sendText` on `lk.chat`, not this protocol), and [per-session variables](/client/overview#per-session-variables-dynamic-variables) for prompt and routing values. ### `overrides` [#overrides] Sent once, immediately after the mic publishes, if the browser passed an `overrides` option. ```json { "type": "overrides", "overrides": { "agent": { "prompt": "You are a helpful receptionist.", "firstMessage": "Hi, how can I help?", "language": "en-US" }, "tts": { "voiceId": "sonic-english", "speed": 1.0 } } } ``` Any subfield is optional. Nothing applies them — see the warning above. ### `user_message` [#user_message] Sent by `conversation.sendUserMessage(text)`. For typed user input that the agent actually answers, call `sendChatMessage(text)` instead — it uses `lk.chat`, not this protocol. ```json { "type": "user_message", "text": "I'd like to reschedule." } ``` ### `contextual_update` [#contextual_update] Sent by `conversation.sendContextualUpdate(text)`. Out-of-band context that shouldn't be treated as a turn. Set context known before the call as a [per-session variable](/client/overview#per-session-variables-dynamic-variables) instead. ```json { "type": "contextual_update", "text": "user switched to the checkout page" } ``` ## Inbound (agent → browser) [#inbound-agent--browser] ### `transcript` [#transcript] STT output for either speaker. ```json { "type": "transcript", "source": "user", "text": "Hello there.", "isFinal": true } ``` `isFinal` defaults to `true` when omitted. ### `agent_message` [#agent_message] An assistant message emitted by the agent — typically streamed token-by-token as `isFinal: false` and closed with `isFinal: true`. ```json { "type": "agent_message", "text": "Happy to help!", "isFinal": true } ``` ### `user_message_echo` [#user_message_echo] Echo of a typed `user_message` so the UI can render it in the same transcript stream. `isFinal` is always implicitly `true`. ```json { "type": "user_message_echo", "text": "I'd like to reschedule." } ``` ## Forwarding to `onMessage` [#forwarding-to-onmessage] The SDK converts each inbound packet into a [`ConversationMessage`](/client/callbacks#conversationmessage): ```ts // pseudocode switch (packet.type) { case 'transcript': return { source: packet.source, text: packet.text, isFinal: packet.isFinal ?? true }; case 'agent_message': return { source: 'agent', text: packet.text, isFinal: packet.isFinal ?? true }; case 'user_message_echo': return { source: 'user', text: packet.text, isFinal: true }; } ``` Unknown packet types are ignored (no message fired, no error). Malformed JSON is ignored the same way — rooms carry data published for other consumers (server control topics, future participants), so a packet that isn't part of this protocol is not an error. ## Extending the protocol [#extending-the-protocol] If you need a new packet type, add it on both sides: 1. Agent worker publishes a new `type` value. 2. Extend `InboundPacket` in `@spekoai/client` and handle it in `packetToMessage` (or ship a wrapper that subscribes to `room.on('dataReceived')` directly). Outbound is not symmetric with inbound. `WebRTCConnection.publish(packet)` accepts any `OutboundPacket`, so widening the type in a fork compiles — and still delivers nothing, because the packet carries no topic for a worker to match on. A new outbound type needs a topic chosen, `publish()` changed to send it, and a handler on the agent that subscribes to that topic. # Embed element (/client/embed-element) The custom element — a voice widget for any page. `@spekoai/embed` ships one custom element, ``. It renders a docked launcher, a transcript panel and mute, connects through [`VoiceConversation`](/client/voice-conversation), and takes no framework. Point it at a URL on your own backend that mints the session: ```html ``` There is no CDN build yet, so the import above needs a bundler. A `