Hear it before you write a line.
The console's call page puts you on a call from the browser: pick a voice, say who it is, and talk. It is the same endpoint and the same meter as the API.
One address, your key in a header.
| Value | |
|---|---|
| Address | wss://api.tileward.com/v1/voice |
| Key | Authorization: Bearer tw_live_..., or the same key in x-api-key |
voice | One of 18 voices: NATF0 to NATF3, NATM0 to NATM3, VARF0 to VARF4, VARM0 to VARM4. Optional, NATF2 if you leave it out. |
prompt | Who the voice is, up to 2,000 characters. Optional. |
voice and prompt go in the query string. A browser cannot set a header on a WebSocket, so open the socket from your own server and keep the key there. A key holds one call at a time.
Get a key from the console under Settings → API keys.
import asyncio, os
from urllib.parse import urlencode
import websockets
query = urlencode({
"voice": "NATF2",
"prompt": "You work for Acme Dental which is a dental clinic and your name is Sam.",
})
url = f"wss://api.tileward.com/v1/voice?{query}"
headers = {"Authorization": f"Bearer {os.environ['TILEWARD_API_KEY']}"}
async def talk(ws):
async for page in microphone(): # yours: Ogg/Opus pages, 24 kHz mono
await ws.send(b"\x01" + page)
async def main():
async with websockets.connect(url, additional_headers=headers) as ws:
sending = None
try:
async for frame in ws:
if isinstance(frame, str):
print(frame) # JSON: an error, or the bill when the call closes
elif frame[0] == 0:
sending = asyncio.create_task(talk(ws)) # the call is live: start sending
elif frame[0] == 1:
play(frame[1:]) # yours: the voice, Ogg/Opus, 24 kHz mono
elif frame[0] == 2:
print(frame[1:].decode(), end="") # the words it is saying
except websockets.ConnectionClosed:
pass
if sending:
sending.cancel()
print("closed with code", ws.close_code) # see the close codes below
asyncio.run(main())
microphone() and play() are yours to supply: an Opus encoder and decoder at 24 kHz mono. The model answers what it hears, so send your microphone from the moment the call is live: the call bills from then whether or not audio is flowing.
Audio is binary. Everything else is JSON.
The first byte of a binary message says what it carries. Text messages are JSON from Tileward, so the two cannot be confused.
| Message | Direction | What it is |
|---|---|---|
Binary, first byte 0 | To you | The call is live. Billing starts here. |
Binary, first byte 1 | Both ways | Ogg/Opus audio, 24 kHz mono, after the first byte: your microphone going up, the voice coming back. |
Binary, first byte 2 | To you | A piece of the text the model is saying. |
{"type": "error", "code": "...", "message": "..."} | To you | Why a call was refused or ended. The socket closes after it. |
{"type": "closed", "reason": "...", "billed_seconds": 30.0, "cost_micros": "40000.0000"} | To you | The bill. reason is client, host, balance or limit, and cost_micros is in millionths of a dollar. |
The model reads the prompt before the call goes live. That takes a few seconds for a short prompt and longer for a long one, and it is not billed.
Why a socket closed.
| Code | Meaning |
|---|---|
1000 | The call ended normally. |
4400 | Bad request: an unknown voice, a prompt over 2,000 characters, or a seed, which a voice call does not take. |
4401 | The key is missing or invalid. |
4402 | The balance was empty at the start, or ran out during the call. |
4403 | The key has a governance policy bound to it. A voice call takes a key without one. |
4429 | Rate limit, or this key already has a call in progress. |
4503 | No line is free right now. When the error carries retry_after_s, a line is starting: try again after that many seconds. Nothing was billed. |
By the minute, from the moment it is live.
A call is billed by the minute of conversation from your account credit, counted in 80 ms steps from the moment it goes live, with a ten-second minimum. Silence bills the same as speech, and a call that never goes live bills nothing. Each voice model's rate is in the model library.
A call that looks things up.
Tileward VoiceChat 11B answers from your own Tileward Context store while the call is running.
It is ready to serve on request: talk to an engineer to start. A call then connects like this, with the same key, the same close codes and the same one call per key.
| Value | |
|---|---|
| Address | wss://api.tileward.com/v1/voice/chat |
prompt | Who the voice is, up to 2,000 characters. Optional. |
context | The store it answers from: knowledge for your uploaded documents, or the name of one of your Context conversations. Optional. |
| You send | Binary PCM audio: signed 16-bit little-endian, 16 kHz, mono. |
| You receive | Binary PCM audio: signed 16-bit little-endian, 22,050 Hz, mono, 80 ms a message. |
| Text message | What it is |
|---|---|
{"type": "ready"} | The call is live. Billing starts here. |
{"type": "transcript.user", "delta": "..."} | What the caller said. |
{"type": "transcript.agent", "delta": "..."} | What the voice is saying. |
{"type": "lookup", "found": true} | A question was looked up in your store, and whether it found an answer. |
{"type": "closed", ...} | The bill, as above, with lookups and found counts for the call. |
{"type": "error", ...} | Why a call was refused or ended. |
To end the call, send {"type": "session.close"} or close the socket. The call is billed by the minute like the one above, plus the tokens its lookups use, and each lookup counts as one Context recall.
The models, and the rest of the API.
The voice models
The model library lists both voice models with their price and licence, and the write-up says how the PersonaPlex build was measured.
Chat, guard and Context
The endpoints say which address wants which credential, and Connect a client has a recipe for each client.