Voice agents
https://api.suisse-speech.ch/v1 · X-API-Key6.0 What a voice agent is
A voice agent is a conversation partner in realtime. You open one WebSocket and stream the audio of the person speaking: a caller on a telephone line, or a user with a microphone in a browser or app. The agent listens, understands and answers with natural speech, following the instructions you give it. You build the phone bot or voice assistant around it; the listening, understanding and speaking run on this one stream.
What you get:
- Swiss dialects and regional varieties, both ways. The agent understands and
speaks Swiss German dialects (Zürich, Bern, Basel, Lucerne and general Swiss
German), Swiss French (
fr-CH) and Swiss Italian (it-CH), and also Standard German, French, Italian and English. - Your own instructions: persona, knowledge and rules, and the names and terms it should recognise.
- Tools: the agent asks your backend to look something up or book something, and speaks the result.
- Natural interruptions (barge-in), telephone audio (G.711 µ-law and A-law at 8 kHz) and PCM at 8, 16, 24 and 48 kHz.
- Call control: the agent can end a conversation on the caller’s goodbye, and hand a caller over to a person (6.5).
- Live transcripts of both sides, and the whole conversation in
session.complete. - Browser-safe access: short-lived single-use tokens, so a page never holds your API key (6.1).
Voice agents are billed per second of connected session time, at CHF 0.33 per minute (CHF 19.80 per hour), excluding VAT, from the same prepaid credit as the other services (6.7). Sandbox keys run a synthetic session and are never billed.
Use speech-to-text and text-to-speech directly (5, 3) when you want to run the conversation logic yourself; use a voice agent when you want the conversation itself.
6.1 Connect: GET /agent/stream (WebSocket)
Endpoint: wss://api.suisse-speech.ch/v1/agent/stream
Authentication
Authenticate like the other sockets (Authentication):
| where you connect from | how |
|---|---|
| your server (a telephone gateway, a backend) | the X-API-Key request header |
| a browser or app | a short-lived token from POST /agent/tokens (below), in the subprotocol Sec-WebSocket-Protocol: suisse.v1, key.<token> |
A browser cannot set handshake headers, so it passes the credential in the
subprotocol; list suisse.v1 as well, it is the subprotocol the server
answers with. The query parameter ?api_key= is accepted too, but avoid it:
query strings end up in logs. Never put your API key into code that runs in a
browser or an app you ship: your server mints a token, the browser connects
with it.
A handshake that is refused has no body, so it answers with the HTTP status
alone: 401 (no valid key, or a token that is unknown, already used or
expired), 429 (no capacity, the plan’s simultaneous sessions, or the session
rate; read Retry-After and X-Suisse-Capacity-Reason) or 503 (tokens are
unavailable just now). Voice agents are released per account: for an account
without them the socket opens, sends an error with agent.not_enabled and
closes with 1008. If agent.not_enabled comes back, contact us and we enable voice agents for your account. GET /capabilities says whether your key may open a session
(agent.enabled_for_your_account).
Browser tokens: POST /agent/tokens
Your server asks for a token with its API key and hands it to the page. The token opens one session and is worth nothing afterwards.
curl -X POST https://api.suisse-speech.ch/v1/agent/tokens \
-H "X-API-Key: $SUISSE_SPEECH_KEY" \
-H 'Content-Type: application/json' \
-d '{"lock": true, "session": {"language": "de-CH", "dialect": "zurich",
"voice": "anna", "instructions": "Du bist die Rezeption der Garage Muster."}}'
{"token":"sat_Q2xhc3NpY0Jsb2NrU3RyZWFtMTIzNDU2","expires_at":"2026-11-03T09:01:00.000Z","expires_in":60}
- A token is
sat_followed by 32 characters. It is valid for 60 seconds and for one handshake: it is used up by the first connection attempt, even one that is then refused with429. Mint a new token for every session. - It belongs to the account and the environment of the key that minted it (a sandbox key mints a sandbox token). If that key is revoked, the token stops working.
- The body is optional, and strict: JSON only (
Content-Type: application/json), no query parameters, and onlysessionandlockat the top level.sessionpresets anysession.startfields; it is checked with the rules ofsession.startwhen the token is minted, so an unknown field, a wrong type, or an unknown voice, language or dialect is refused at once with400 agent.invalid_request, not later in the browser.lock(defaultfalse) fixes some of them. - Merging. The browser’s own
session.startis applied over the preset, field by field: a field the browser sends replaces the preset’s field as a whole, every other preset field stays. - Lock. With
"lock": truethe preset’sinstructions,tools,voice,language,dialectandgreetingare fixed; one the preset leaves out stays unset, at its default. Asession.startthat sends one of them with another value, or one the preset leaves out, is refused withagent.forbiddenand the socket closes with1008; sending exactly the preset’s value is fine. During the session,config.updateandreplywithinstructionsare answered withagent.forbiddenand ignored; the session goes on. Uselocktogether withsession: it is what keeps the page from rewriting your agent. - Errors:
400 agent.invalid_request,401 agent.unauthenticated,403 agent.not_enabled,429 agent.rate_limited,503 agent.unavailable.
A secure browser flow, in two small pieces. Your server mints the token and should only do so for a visitor you have authenticated yourself:
// Node.js (Express); `requireLogin` is your own check
app.post('/agent-token', requireLogin, async (req, res) => {
const r = await fetch('https://api.suisse-speech.ch/v1/agent/tokens', {
method: 'POST',
headers: { 'X-API-Key': process.env.SUISSE_SPEECH_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
lock: true,
session: {
language: 'de-CH', dialect: 'zurich', voice: 'anna',
instructions: 'Du bist die Rezeption der Garage Muster. Antworte kurz.',
greeting: 'Grüezi, Garage Muster. Wie kann ich helfen?',
tools: [{ name: 'get_time', description: 'Returns the current time.',
parameters: { type: 'object', properties: {} } }],
},
}),
});
res.status(r.status).json(await r.json());
});
The browser fetches a token and connects with it. The agent’s character is locked in the token, so the page sends only what is its own, the audio formats:
const { token } = await (await fetch('/agent-token', { method: 'POST' })).json();
const ws = new WebSocket('wss://api.suisse-speech.ch/v1/agent/stream', ['suisse.v1', 'key.' + token]);
ws.onopen = () => ws.send(JSON.stringify({
type: 'session.start',
input: { encoding: 'pcm16', sample_rate_hz: 16000 },
output: { encoding: 'pcm16', sample_rate_hz: 24000, transport: 'binary' },
}));
session.start
The first message must be session.start, and it is sent once, within 10
seconds of connecting. Control messages are JSON text frames with a type field.
Every field is optional.
{
"type": "session.start",
"language": "de-CH",
"dialect": "zurich",
"voice": "anna",
"instructions": "Du bist die Rezeption der Garage Muster. Antworte kurz und freundlich.",
"greeting": "Grüezi, Garage Muster. Wie kann ich helfen?",
"input": { "encoding": "pcm16", "sample_rate_hz": 16000 },
"output": { "encoding": "pcm16", "sample_rate_hz": 24000, "transport": "binary" },
"turn_taking": { "mode": "auto", "interruptions": true, "patience": "normal" },
"vocabulary": ["Muster", "Bremgarten"],
"pronunciations": [{ "text": "SIT", "say": "Suisse I T" }],
"max_duration_s": 600
}
| field | type, default and range | meaning |
|---|---|---|
instructions |
string, at most 16,000 characters; default none | the agent’s persona, knowledge and rules |
language |
string, default de-CH: a code listed in agent.languages of GET /capabilities |
the language of the session: de-CH (Swiss German, with dialect), fr-CH (Swiss French), it-CH (Swiss Italian), de-DE, fr, it, en and the further regional and other languages the list holds |
languages |
array of 1 to 4 codes from agent.languages; default only the session language |
the languages the user may speak and switch between (6.3). The session language comes first; without language, the first listed is the session language |
dialect |
standard (default), zurich, bern, basel or luzern; with de-CH only (any other language refuses it) |
the Swiss German dialect the agent speaks; see 3.4 |
voice |
default anna: anna, vera, lea, nora, mia, stefan, reto, lukas, andrin or felix |
character of each voice: 3.3 |
greeting |
string, at most 1,000 characters | text the agent speaks first, exactly as written |
input |
encoding pcm16 (default), mulaw or alaw; sample_rate_hz 8000, 16000, 24000 or 48000 (mulaw and alaw: 8000 only); default pcm16 at 16000 |
the audio you send (6.2) |
output |
the same encoding and sample_rate_hz; transport json (default) or binary; default pcm16 at 24000 |
the audio you want back, in frames of 40 ms (6.2) |
turn_taking |
object, see below | who speaks when (6.3) |
tools |
array of up to 32 functions, at most 16,000 characters together | the functions the agent may call (6.4) |
vocabulary |
array of up to 200 strings, each at most 100 characters | names and terms to recognise |
pronunciations |
array of up to 100 objects { "text", "say" }: text at most 64, say at most 128 characters |
how the agent speaks a written term: SIT as “Suisse I T” |
speed |
number from 0.85 to 1.20, default 1.0 | speaking rate, on top of the voice’s own tempo |
end_conversation |
true (default) or false |
whether the agent may end the conversation itself (6.5) |
transfer_targets |
array of up to 10 objects { "name", "description" }: name unique, at most 64, description at most 256 characters; default none |
the places the agent may hand a caller to (6.5) |
max_duration_s |
whole number from 60 to 1800, default 900 | the session ends after this many seconds |
transcripts |
true (default) or false |
send the transcripts of both sides |
turn_taking: mode auto (default) or manual; interruptions true
(default) or false; patience short (default), normal or long;
idle_prompt_s, a whole number from 5 to 60, default 10; idle_end_s, a whole
number from 15 to 300, default 45, and longer than idle_prompt_s. The two idle
settings apply in auto mode only (6.5).
instructions and tools together take at most 24,000 characters. A field
that is not listed is refused with agent.invalid_request, and so is a value
outside its range; a typo never silently does nothing. detail names the
position of the problem, never the value you sent. A session.start that is
refused ends the connection with 1008.
The server answers with session.ready once the session is set up:
{"type":"session.ready","request_id":"req_…","session_id":"req_…","voice":"anna",
"language":"de-CH","dialect":"zurich",
"input":{"encoding":"pcm16","sample_rate_hz":16000},
"output":{"encoding":"pcm16","sample_rate_hz":24000,"transport":"binary","frame_ms":40,"frame_bytes":1920},
"turn_taking":{"mode":"auto","interruptions":true,"patience":"normal","idle_prompt_s":10,"idle_end_s":45},
"languages":["de-CH"],"speed":1,"end_conversation":true,"transfer_targets":0,"pronunciations":1,
"tools":0,"transcripts":true,"max_duration_s":600,"limited_by":"max_duration_s"}
session.ready repeats the settings the session runs with. max_duration_s is
the length this session can really have: if the account’s remaining credit is
shorter than the max_duration_s you asked for, it is shortened and
limited_by is credit (otherwise max_duration_s). Billing starts at
session.ready (6.7). The greeting, if you set one, is spoken straight away.
6.2 Audio in and out
Sending audio. Send the audio in the encoding and rate you declared in
input, either as binary frames (raw samples, nothing else; at most 64 KB
each) or as JSON:
{"type":"audio.append","data":"<base64>"}
Send audio at real-time pace, from session.ready on. The service accepts at
most 1.2 times real time (with a burst of 2 seconds); audio beyond that is
dropped and you get one error with agent.invalid_audio; the session goes
on. You may send at most 100 frames per second, text and binary together;
the excess is dropped and you get agent.rate_limited, at most once per
second. Frames of 20 to 40 ms stay well within it. data that is not valid base64 is answered with the same error. Binary
frames are the better choice: no base64, no parsing.
Receiving audio. The agent’s voice arrives in frames of 40 ms each
(frame_ms and frame_bytes in session.ready; the last frame of a reply may
be shorter), in order. With "transport":"binary" in output they are
binary frames between reply.started and reply.audio_done; a binary frame
is always audio. With "transport":"json" the same audio arrives as
audio.chunk messages (reply_id, seq counting from 1, base64 data). After
audio.clear, no frame of that reply follows.
Pacing. The agent’s audio leaves on the service’s own clock: the first
200 ms or so of a reply at once, then a frame every 40 ms at about 1.25 times
real time, and never more than about 1.5 seconds ahead of your playback. Your
playback buffer therefore stays small, and audio.clear takes effect at once.
The events of a reply keep their order with its audio: reply.audio_done and
reply.done come after its last frame, and the next reply’s reply.started
after the previous reply.done. Transcript deltas keep their order with the
audio too, so a reply.transcript.delta can arrive slightly after the audio it
belongs to; do not use it to time playback. Deltas that wait behind audio may
arrive merged into one.
A reply’s audio is checked against its own transcript before it is sent, so the first frame of a reply comes a moment after its first words are ready. While more than about 1.5 seconds of audio still waits to go out, the next reply starts only once it has gone out; nothing is lost.
Formats. pcm16 is headerless 16-bit little-endian mono PCM at 8000, 16000,
24000 or 48000 Hz. mulaw and alaw are G.711 at 8000 Hz, the format of
telephone lines, so call audio can pass straight through without conversion.
Input and output formats are independent: a browser can send 16 kHz PCM and play
24 kHz PCM, a telephone gateway can use µ-law at 8 kHz both ways.
A client that reads too slowly, or a very long reply. If your connection falls
more than about two seconds of audio behind, the excess audio is dropped and you
get a session.warning with agent.audio_dropped and the reply_id; the next
reply is sent normally. If a single reply runs more than about 30 seconds ahead
of your playback, you get the same warning, then audio.clear (only if audio of
that reply already reached you) and reply.done with status interrupted. The
conversation continues from what the listener actually heard.
Echo cancellation is required on your side. The agent listens while it
speaks; if your client plays the reply through a loudspeaker and the microphone
picks it up again, the agent hears itself and interrupts itself. Telephone lines
cancel echo already. In a browser, ask for it:
getUserMedia({ audio: { echoCancellation: true } }). A headset is the safest
setup, and worth recommending to your users wherever they talk for longer than a
moment.
6.3 The conversation
Events from the server
type |
payload | meaning |
|---|---|---|
session.ready |
see 6.1 | the session is set up; billing starts |
user.speech_started |
the caller started speaking | |
user.speech_stopped |
the caller stopped | |
user.transcript.partial |
text |
what the caller has said so far, still changing |
user.transcript.final |
text, language |
the caller’s utterance, final, and the language heard |
reply.started |
reply_id |
the agent starts a reply; audio follows |
audio.chunk |
reply_id, seq, data |
audio of the reply with transport json (base64) |
reply.transcript.delta |
reply_id, text |
the next piece of the reply’s text |
reply.audio_done |
reply_id |
all audio of the reply has been sent |
reply.done |
reply_id, status, transcript |
the reply is over; status is completed, interrupted, cancelled or failed; transcript only with transcripts true |
audio.clear |
reply_id |
the caller interrupted: drop the buffered audio of that reply at once |
tool.call |
call_id, name, arguments, reply_id |
the agent asks your backend to run a tool (6.4) |
transfer.requested |
request_id, target, reason, reply_id |
the agent hands the caller over (6.5) |
session.end_requested |
request_id, reason |
the conversation is ending (6.5) |
session.warning |
code, request_id, seconds_left or reply_id |
the session is about to end, or audio was dropped (6.6) |
session.status |
request_id, state, recognition, connected_s, remaining_s, open_tool_calls |
the state of the session, every 60 s and when recognition changes (6.6) |
error |
code, message, detail?, request_id |
something went wrong (6.6) |
session.complete |
request_id, duration_s, billable_seconds, end_reason?, transcript?, transcript_truncated? |
the session is over (6.7) |
On a sandbox key, every event carries "sandbox": true.
With transcripts true (the default), user.transcript.* and
reply.transcript.delta are sent while the conversation runs, reply.done
carries the reply’s transcript, and session.complete carries the whole
transcript. With false, none of these are sent: reply.done has no
transcript either. user.speech_started and user.speech_stopped come either way.
Turn-taking. In mode: "auto" the agent decides when the caller has
finished and then answers; patience sets how long it waits after a pause
(short, normal, long). Use long for callers who think aloud, short
for quick exchanges. In mode: "manual" the agent answers only when you send
input.commit, for example from push-to-talk; in auto mode input.commit is
refused with agent.invalid_request.
Interruptions. With interruptions: true, a caller who speaks while the
agent is talking interrupts it: two words, a stop word such as “stopp” or
“moment”, or longer speech; a short “mhm” does not. The agent stops, and the
server sends audio.clear for the reply that was playing. Drop everything you
have buffered for that reply_id immediately, otherwise the caller hears the
agent talk on for as long as your buffer is long. The reply ends with
reply.done and status interrupted; its transcript is what the agent had
said. You can also stop the agent yourself with interrupt.
playback.mark: tell the agent what was heard. The server knows how much
audio it has sent, not how much of it your client has played. Audio waits in
your buffer and in the network, so on a slow or jittery connection the caller is
often several sentences behind. When the caller interrupts, the agent has to
know where its sentence was cut: otherwise it believes the caller heard words
they never heard and carries on from the wrong place. Send playback.mark
(reply_id, played_ms) about every 250 ms while a reply plays:
{"type":"playback.mark","reply_id":"rp_…","played_ms":1840}
played_ms is how many milliseconds of that reply your client has played,
counted from the reply’s first audio. A mark counts for its own reply only. It
must not decrease and must not exceed the audio sent for the reply, otherwise
it is answered with an error (agent.invalid_request) and ignored. A mark for
a reply that no longer plays (interrupted, cleared by audio.clear, or long
finished) is accepted and ignored, never an error, so a final mark after
audio.clear is fine. Without marks the server estimates from the clock, which
is accurate only while your playback keeps pace. It is optional, and worth it on
browser and mobile clients.
6.4 Tools
Tools let the agent act on your systems. Define them in session.start (or later
with config.update):
{"tools":[{
"name": "book_appointment",
"description": "Books an appointment for the caller. Call it only after the caller has agreed on a time.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "The caller's name"},
"time": {"type": "string", "description": "Date and time, ISO 8601"}
},
"required": ["name", "time"]
}
}]}
name: 1 to 64 characters of letters, digits,_or-(^[a-zA-Z0-9_-]{1,64}$), unique within the session.end_conversationandtransfer_to_humanare reserved (6.5).description: at most 1,024 characters. Say when to call the tool.parameters: a JSON Schema for an object (typeobject, or left out). Supported keywords:type(object,string,number,integer,boolean,array),properties,required(names of its properties),enum(1 to 100 strings, numbers or booleans),description,items,minimum,maximumandmaxLength; nested at most 6 levels deep. The keywords of everyday strict schemas,additionalProperties,title,default,examples,$schema,minLength,minItemsandmaxItems, are accepted and ignored: they are removed before the schema is used (GET /capabilitieslists them asignored_keywords), so validate your tool’s input yourself. Anything else (oneOf,anyOf,allOf,$ref,pattern,format…) is refused withagent.invalid_request.- At most 32 tools, 16,000 characters for all tools together, and at most 24,000
with the
instructions.
When the agent needs one, it sends tool.call. arguments is always a JSON
object, and reply_id is the reply it belongs to:
{"type":"tool.call","call_id":"tc_…","name":"book_appointment",
"arguments":{"name":"Meier","time":"2026-11-03T10:00"},"reply_id":"rp_…"}
Run the function in your backend and answer with tool.result:
{"type":"tool.result","call_id":"tc_…","output":"{\"booked\":true}"}
- Answer every
call_idonce. Several calls can be open at the same time. outputis a string or any JSON value (sent as JSON, it is converted to text); at most 16,000 characters. The agent then continues and speaks the outcome.- You have 30 seconds. After that the agent tells the caller that it did not
work and the call is closed; a
tool.resultthat arrives late is answered withagent.invalid_request(detail: “call expired”) and ignored. A call is never retried automatically. The same applies to a second result for acall_id, or for one the server never sent. - A call stays valid when the caller interrupts the agent; a call of a reply that was interrupted or cut before it reached you is never sent.
Keep tools fast; session.status shows open_tool_calls.
6.5 Steering the conversation
| message | payload | what it does |
|---|---|---|
input.commit |
manual mode: the user has finished, the agent answers |
|
text.input |
text (at most 4,000 characters) |
a user message as text, as if the caller had said it |
context.add |
text (at most 8,000 characters) |
background information for the agent; no reply is triggered; at most 50 per session |
reply |
instructions? (at most 4,000 characters) |
the agent speaks now, optionally following extra instructions |
say |
text (at most 2,000 characters) |
the agent speaks exactly this text |
interrupt |
stop speaking now | |
playback.mark |
reply_id, played_ms |
how much of a reply your client has played (6.3) |
config.update |
instructions?, tools? (at least one) |
replace the instructions or the tools during the call; at most 10 per session; an empty instructions or tools clears them |
session.end |
end the session; the server answers with session.complete |
Send these after session.ready; before it, the server answers with
agent.invalid_request. context.add is the way to tell the agent what
happened outside the call: “the caller’s account is overdue”, “the order has just
shipped”. reply and say start outbound speech, for example a prompt after a
long silence.
Ending a conversation. With end_conversation true (the default) the
agent can end the call itself, but only on the caller’s own words: a goodbye, an
explicit wish to end, or a clear “no” to the agent’s closing question; otherwise
it asks first. Then you get session.end_requested with a reason (goodbye,
user_request, closing_confirmed or idle), the goodbye is spoken, and the
session closes with session.complete and 1000, at the latest 15 seconds
later. session.complete repeats the reason as end_reason. Set
end_conversation to false if your own system decides when a call ends.
Handing over to a person. Give transfer_targets (name and a short
description of when to use each) and the agent can ask for a handover: you
get transfer.requested with the target name and a short reason, the agent
says a short handover line, and the session goes on. Your telephony makes
the transfer and then ends the session with session.end.
Silence (auto mode). The clock starts with the first exchange and runs only
while nobody speaks, nothing is played and no tool call is open. After
idle_prompt_s the agent asks once, in the caller’s language, whether they are
still there; after idle_end_s it says goodbye and the session ends
(session.end_requested, reason idle).
Several languages. With several languages, recognition listens for all of
them, user.transcript.final carries the language heard, and the agent answers
in the caller’s language, one language per reply. For Swiss German it keeps the
chosen dialect.
6.6 Limits, warnings and errors
| limit | value |
|---|---|
session.start |
within 10 seconds of connecting |
| audio you send | at most 1.2 times real time (burst of 2 s); the excess is dropped |
| frames | at most 100 per second, text and binary together, every frame counted, also one that is too large or cannot be read (the excess is dropped, agent.rate_limited at most once per second); each text frame and each binary frame at most 64 KB |
| control messages | at most 20 per second (audio not counted; a frame that cannot be read counts as one) |
reply, say and text.input together |
at most 30 per minute |
config.update and context.add |
at most 10 and 50 per session |
tool.result |
at most 16,000 characters, within 30 s of the tool.call |
| tools | at most 32; 16,000 characters together; description 1,024; schema 6 levels deep |
instructions |
at most 16,000 characters; with the tools at most 24,000 |
greeting, text.input, context.add, reply instructions, say |
at most 1,000, 4,000, 8,000, 4,000 and 2,000 characters |
| silence on the socket | no audio and no message for 120 s ends the session |
| session length | max_duration_s, 60 to 1800 s, default 900 s |
| simultaneous sessions | your plan’s ceiling for simultaneous requests (your_plan in GET /capabilities); a sandbox key has 5 of its own (Limits) |
| session opens | 600 per minute per account |
A frame over the size limit gets agent.invalid_request (text) or
agent.invalid_audio (binary) up to 65 KB and is not read; above that the
socket closes with 1009, the standard WebSocket “message too big”.
Messages that break a limit are answered with an error and dropped; the
session goes on. Too many frames, too many control messages, or too many reply, say and
text.input, give agent.rate_limited; the rest give agent.invalid_request.
Errors that do not end the session come at most once per code per second;
further ones of the same code in that second are dropped silently. More than 50
refused frames within 10 seconds (over the frame rate, too large or unreadable)
end the session with agent.rate_limited and the close code 1008. A refused
session.start also ends the connection.
Warnings. A session.warning carries a code, and the session goes on:
code |
when |
|---|---|
agent.duration_ending |
60 seconds before the end of the session (seconds_left) |
agent.entitlement_exhausted |
the credit is running out: the session ends 30 seconds later (seconds_left), or at the length the credit set |
agent.usage_limit |
the session’s usage protection applies: it ends 30 seconds later (seconds_left) |
agent.audio_dropped |
your client reads audio too slowly (the excess is dropped), or one reply runs more than about 30 s ahead of playback: audio.clear (if audio of it reached you) and reply.done with status interrupted follow (reply_id, 6.2) |
Wrap the conversation up when you get the first three, or open a new session afterwards.
session.status comes every 60 seconds and when recognition changes:
state (listening, thinking or speaking), recognition (ok or
degraded: the agent then answers from the audio alone, and
user.transcript.* stops), connected_s, remaining_s and open_tool_calls.
Errors arrive as an error message:
{"type":"error","code":"agent.invalid_request","message":"…","detail":"…","request_id":"req_…"}
| code | meaning | retry? |
|---|---|---|
agent.invalid_request |
a message or field is not valid, including an unknown field, an unknown call_id, an expired tool call |
no: fix the message |
agent.invalid_audio |
the audio is not valid base64, or arrives faster than 1.2 times real time | no |
agent.unauthenticated |
the key or token is missing or not valid (HTTP 401 on the handshake and on /agent/tokens) |
no |
agent.forbidden |
a locked token’s field was changed, or config.update / reply instructions with a locked token |
no |
agent.not_enabled |
voice agents are not enabled for this account | no: contact us |
agent.rate_limited |
too many requests, control messages or spoken replies in the window | yes, after Retry-After on HTTP; on the socket, slow down |
agent.capacity |
the service is momentarily full | yes, after Retry-After |
agent.plan_limit_reached |
too many simultaneous sessions on this key | not by itself: reduce parallelism |
agent.entitlement_exhausted |
the free minutes or the credit are used up (close 4003) |
no: top up |
agent.duration_limit |
the session reached its maximum length (close 4000) |
open a new session |
agent.usage_limit |
the session reached its usage limit (close 4002) |
no |
agent.unavailable |
the service is not reachable, the session could not be set up (for example the credit could not be read or held), or a running session cannot continue (close 1011) |
yes, with backoff |
agent.timeout |
no audio and no message for 120 seconds (close 4001) |
yes, with a new session |
agent.internal_error |
a fault on our side | once, then contact us |
Close codes. An error that ends the session comes first, then:
| code | meaning |
|---|---|
1000 |
normal closure: session.end, the end of a conversation |
1001 |
the service is restarting: the session is ended properly first (session.complete with end_reason restart, its seconds billed); open a new session |
1008 |
policy: voice agents not enabled, a refused session.start, no session.start within 10 s, a locked token’s field changed, or more than 50 refused frames within 10 s |
1009 |
a frame larger than 65 KB: the frame is not read |
1011 |
error: agent.unavailable, the session could not be set up (nothing is billed) or cannot continue (session.complete with end_reason unavailable first, billed up to then) |
4000 |
agent.duration_limit: the session reached max_duration_s |
4001 |
agent.timeout: no audio and no message for 120 seconds |
4002 |
agent.usage_limit |
4003 |
agent.entitlement_exhausted: no credit to start, or the credit ran out |
Log the request_id of every error and every session.complete: it is what to
quote in a support request (Errors).
6.7 Billing and sandbox
A session is billed per second of connected time, from session.ready until
the session ends, with no minimum, at CHF 0.33 per minute (CHF 19.80 per
hour), excluding VAT. A start that never reaches session.ready costs nothing.
session.complete reports duration_s (connected seconds) and
billable_seconds, and, with transcripts on, the whole conversation:
{"type":"session.complete","request_id":"req_…","duration_s":184.2,"billable_seconds":184.2,
"end_reason":"goodbye",
"transcript":[{"role":"agent","text":"Grüezi, Garage Muster …","at_ms":420},
{"role":"user","text":"Ich möchte einen Termin.","at_ms":3100}]}
transcript holds every user turn and what the agent actually played (an
interrupted reply up to where it was heard), with at_ms since session.ready;
at most 1,000 entries and 200,000 characters, then transcript_truncated is
true. end_reason is present after session.end_requested, is restart
when the service restarts (the session is ended properly, close 1001), and
unavailable when the service cannot continue a running session (after
agent.unavailable, close 1011; billed up to that moment). If your
connection drops, the session ends and is billed up to that moment, but no
session.complete can reach you.
Voice agents draw on the same prepaid credit as the other services
(Limits and metering). Each session holds the credit for its length from the start, so sessions
that run at the same time share the balance and can never overdraw it. A session
needs at least one minute of credit left to start; with less, it is refused with
agent.entitlement_exhausted (close 4003). If the remaining credit is shorter
than your max_duration_s, the session is shortened to what the credit covers
(limited_by: "credit" in session.ready). If the balance of a prepaid account
cannot be read or held at the start, the session is refused with
agent.unavailable (close 1011); retry shortly. A session is billed at the price in force when it
started.
Connected time is billed whether or not anyone is speaking, so close the socket
(session.end) when the call is over.
Sandbox. A key that starts with sv_test_ runs a synthetic session: the
same events in the same order, with a synthetic transcript and a short test tone
as the answer, and "sandbox": true in every event. It is never billed. Build
and test your client with it; judge the voice and the dialect with a live key.
6.8 Example: browser
A complete minimal page: it asks your server for a token (6.1), captures the
microphone at 16 kHz (pcm16), plays the agent’s 24 kHz pcm16 binary frames,
drops audio on audio.clear and answers a tool call. The page never holds an
API key; the server route /agent-token is the one shown in 6.1, whose locked
preset holds the agent’s language, voice, instructions, greeting and the
get_time tool.
<button id="start">Start</button> <button id="stop">Stop</button>
<pre id="log"></pre>
<script type="module">
const ENDPOINT = 'wss://api.suisse-speech.ch/v1/agent/stream';
const log = (who, text) => { document.querySelector('#log').textContent += `${who}: ${text}\n`; };
let ws, mic, micCtx, out, playAt = 0;
const playing = new Set();
// Playback: binary frames are raw 24 kHz pcm16, queued back to back.
function play(buffer) {
const pcm = new Int16Array(buffer);
const audio = out.createBuffer(1, pcm.length, 24000);
const ch = audio.getChannelData(0);
for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
const src = out.createBufferSource();
src.buffer = audio;
src.connect(out.destination);
playAt = Math.max(playAt, out.currentTime + 0.05);
src.start(playAt);
playAt += audio.duration;
playing.add(src);
src.onended = () => playing.delete(src);
}
// audio.clear: the caller interrupted, drop everything not yet played.
function clearPlayback() {
for (const s of playing) { try { s.stop(); } catch {} }
playing.clear();
playAt = 0;
}
// Capture: 16 kHz mono, 40 ms (640 samples) per binary frame.
const WORKLET = `
class Capture extends AudioWorkletProcessor {
constructor() { super(); this.buf = new Int16Array(640); this.n = 0; }
process(inputs) {
const ch = inputs[0][0];
if (!ch) return true;
for (let i = 0; i < ch.length; i++) {
this.buf[this.n++] = Math.max(-1, Math.min(1, ch[i])) * 32767;
if (this.n === 640) { this.port.postMessage(this.buf.slice().buffer); this.n = 0; }
}
return true;
}
}
registerProcessor('capture', Capture);`;
async function startCapture(send) {
micCtx = new AudioContext({ sampleRate: 16000 });
const source = micCtx.createMediaStreamSource(mic);
const mute = micCtx.createGain(); // keeps the graph running, plays nothing
mute.gain.value = 0;
mute.connect(micCtx.destination);
if (micCtx.audioWorklet) {
const url = URL.createObjectURL(new Blob([WORKLET], { type: 'text/javascript' }));
await micCtx.audioWorklet.addModule(url);
const node = new AudioWorkletNode(micCtx, 'capture');
node.port.onmessage = (e) => send(e.data);
source.connect(node).connect(mute);
} else { // fallback for old browsers
const node = micCtx.createScriptProcessor(1024, 1, 1);
node.onaudioprocess = (e) => {
const f = e.inputBuffer.getChannelData(0);
const pcm = new Int16Array(f.length);
for (let i = 0; i < f.length; i++) pcm[i] = Math.max(-1, Math.min(1, f[i])) * 32767;
send(pcm.buffer);
};
source.connect(node).connect(mute);
}
}
async function start() {
out = new AudioContext({ sampleRate: 24000 });
// Ask for the microphone first, with echo cancellation, so the agent never hears itself.
mic = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
});
// A token from your server (6.1): valid for 60 s and one session, never the API key.
const { token } = await (await fetch('/agent-token', { method: 'POST' })).json();
ws = new WebSocket(ENDPOINT, ['suisse.v1', 'key.' + token]);
ws.binaryType = 'arraybuffer';
// Language, voice, instructions, greeting and the get_time tool come from the token's
// locked preset (6.1); the page sends only the audio formats.
ws.onopen = () => ws.send(JSON.stringify({
type: 'session.start',
input: { encoding: 'pcm16', sample_rate_hz: 16000 },
output: { encoding: 'pcm16', sample_rate_hz: 24000, transport: 'binary' },
}));
ws.onmessage = async (e) => {
if (e.data instanceof ArrayBuffer) return play(e.data); // the agent's voice
const m = JSON.parse(e.data);
switch (m.type) {
case 'session.ready':
await startCapture((buf) => ws.readyState === WebSocket.OPEN && ws.send(buf));
break;
case 'audio.clear': clearPlayback(); break;
case 'user.transcript.final': log('You', m.text); break;
case 'reply.done': log('Agent', m.transcript); break;
case 'tool.call': {
const result = { time: new Date().toLocaleTimeString('de-CH') };
ws.send(JSON.stringify({ type: 'tool.result', call_id: m.call_id, output: JSON.stringify(result) }));
break;
}
case 'error': log('Error', `${m.code} ${m.message}`); break;
case 'session.complete': log('Done', `${m.billable_seconds} s billed`); break;
}
};
ws.onclose = stop;
}
function stop() {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'session.end' }));
mic?.getTracks().forEach((t) => t.stop());
micCtx?.close(); out?.close();
mic = micCtx = out = undefined;
}
document.querySelector('#start').onclick = start;
document.querySelector('#stop').onclick = stop;
</script>
6.9 Example: Node.js and a telephone line
A telephone bot that passes G.711 µ-law at 8 kHz straight through. call
stands for your telephony connection (a media gateway or a SIP stack): it
delivers the caller’s audio and plays audio back to the caller.
import WebSocket from 'ws';
const ws = new WebSocket('wss://api.suisse-speech.ch/v1/agent/stream', {
headers: { 'X-API-Key': process.env.SUISSE_SPEECH_KEY },
});
let ready = false;
ws.on('open', () => ws.send(JSON.stringify({
type: 'session.start',
language: 'de-CH', dialect: 'bern', voice: 'anna',
instructions: 'Du bist die Terminvergabe der Praxis Muster. ' +
'Sprich kurz und freundlich, frage nach dem Namen und biete freie Termine an.',
greeting: 'Grüezi, Praxis Muster. Wie kann ich helfen?',
vocabulary: ['Muster', 'Dr. Brunner'],
input: { encoding: 'mulaw', sample_rate_hz: 8000 },
output: { encoding: 'mulaw', sample_rate_hz: 8000, transport: 'binary' },
turn_taking: { mode: 'auto', interruptions: true, patience: 'normal' },
max_duration_s: 600,
tools: [{
name: 'free_slots',
description: 'Lists the free appointment slots of the next days.',
parameters: { type: 'object', properties: {} },
}, {
name: 'book_appointment',
description: 'Books an appointment. Call it only after the caller has agreed on a time.',
parameters: {
type: 'object',
properties: { name: { type: 'string' }, time: { type: 'string', description: 'ISO 8601' } },
required: ['name', 'time'],
},
}],
})));
// The caller's audio: Buffers of µ-law, in real time.
call.on('audio', (frame) => { if (ready && ws.readyState === WebSocket.OPEN) ws.send(frame); });
call.on('hangup', () => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: 'session.end' })));
const tools = {
free_slots: async () => calendar.freeSlots(),
book_appointment: async ({ name, time }) => calendar.book(name, time),
};
ws.on('message', async (data, isBinary) => {
if (isBinary) return call.play(data); // the agent's voice, 40 ms per frame
const m = JSON.parse(data.toString());
switch (m.type) {
case 'session.ready': ready = true; break;
case 'audio.clear': call.clearPlayback(); break; // the caller interrupted
case 'tool.call': {
let output;
try { output = JSON.stringify(await tools[m.name](m.arguments)); }
catch (err) { output = JSON.stringify({ error: 'not available' }); }
ws.send(JSON.stringify({ type: 'tool.result', call_id: m.call_id, output }));
break;
}
case 'session.warning': console.warn(`ends in ${m.seconds_left} s`); break;
case 'error': console.error(m.code, m.message, m.request_id); break;
case 'session.complete': console.log(`billed ${m.billable_seconds} s (${m.request_id})`); break;
}
});
ws.on('close', (code) => { ready = false; call.hangup?.(); });