Voice Actions DSL
The Sautikit VoiceAction DSL: a JSON verb set for controlling call flow returned from your webhook handler.
Voice Actions are the JSON DSL Sautikit uses to control call flow. When a call arrives or a step completes, Sautikit POSTs the call state to your voice_callback_url. Your server returns a JSON object with an actions array of verbs (Say, Play, GetDigits, Dial, Conference, Queue, Record, Redirect, Reject, Hangup, Stream, or aiAgent), which the platform executes in order.
Voice actions can be expressed in two forms:
Both forms are shown as tabs on each verb's reference page, with JSON as the default tab.
Runtime note: JSON is the format Sautikit parses and validates. Raw XML you return from your voice URL is forwarded to the PBX unchanged; validation, if any, happens at the PBX, not at Sautikit. Return JSON in your webhook responses unless you have a specific reason to hand the PBX raw XML.
Sautikit accepts two response formats from your voice callback:
The JSON DSL exists so your server can construct call flows with standard JSON libraries, receive type-checked errors from the Sautikit validator, and stay decoupled from the underlying PBX XML dialect. If you are moving from an XML-based voice API, the verb names map closely. The main difference is that Sautikit uses getDigits for DTMF collection (see the verb table below).
{
"actions": [
{ "say": { "text": "Habari, karibu Sautikit." } },
{ "getDigits": { "timeout": 5, "numDigits": 1,
"nested": [{ "say": { "text": "Bonyeza 1 kwa Kiswahili, 2 kwa English." } }]
}},
{ "hangup": {} }
]
}actions is ordered: verbs execute top to bottom.MaxActionSteps).| Verb | Key | Purpose |
|---|---|---|
| Say | say | Synthesise text to the caller via TTS |
| Play | play | Stream an audio file URL to the caller |
| GetDigits | getDigits | Collect DTMF keypad input with an optional prompt |
| Dial | dial | Connect the caller to a phone number or SIP URI |
| Conference | conference | Place the caller in a named conference room |
| Queue | queue | Park the caller in a call-centre queue until an agent answers |
| Record | record | Record caller audio and POST the file URL to your action endpoint |
| Redirect | redirect | Transfer call flow to another URL |
| Reject | reject | Reject an inbound call with "rejected" or "busy" signal |
| Hangup | hangup | End the call immediately |
| AIAgent | aiAgent | Hand the answered call to one of your AI agents (JSON only) |
| Stream | stream | Fork live call audio to a WebSocket for real-time AI (transcription, LLM voice agents such as Google Gemini) |
{
"say": {
"text": "Your OTP is 4 8 2 1.",
"voice": "alice",
"language": "en-US",
"loop": 1
}
}voice and language default to PBX defaults when omitted. loop: 0 means play once.
{
"play": {
"url": "https://cdn.example.com/hold-music.mp3",
"loop": 0
}
}The URL must resolve to a host on your workspace's CDN allow-list.
{
"getDigits": {
"timeout": 5,
"numDigits": 1,
"finishOnKey": "#",
"nested": [
{ "say": { "text": "Press 1 for sales, 2 for support." } }
]
}
}nested verbs (Say or Play) play while waiting for input. finishOnKey defaults to #. When digits are collected the platform POSTs back to your voice_callback_url with the Digits field populated.
{
"dial": {
"number": "+254722000001",
"callerId": "+254700000001",
"timeout": 30,
"record": "record-from-answer"
}
}Use sip instead of number to dial a SIP URI ("sip": "sip:alice@pbx.example.com"). number and sip are mutually exclusive.
{
"conference": {
"name": "weekly-team-call",
"maxParticipants": 10,
"record": true,
"beep": true,
"waitUrl": "https://cdn.example.com/hold.mp3",
"statusEventsCallbackUrl": "https://ivr.example.com/conference-events",
"statusEvents": "start end join leave"
}
}{
"queue": {
"name": "support",
"waitUrl": "https://cdn.example.com/hold.mp3",
"callerId": "+254712345678",
"announceVoice": "en-GB-Standard-F"
}
}queue parks the caller in the phone switch's own call-centre queue. The switch owns the waiting and the matching, so:
maxWaitTime if one is configured, or until they hang up.name is bare — no workspace prefix. There is deliberately no tenant attribute on this verb: the switch reads the workspace off the authenticated channel, so a callback cannot park its caller in someone else's queue.
Queue is not a terminal verb. Actions placed after it run when the caller leaves the queue, however that happened — which is how an unanswered caller reaches voicemail, and how a full queue (maxDepth reached) overflows, since the switch skips the park and simply continues:
{
"actions": [
{ "queue": { "name": "support", "waitUrl": "https://cdn.example.com/hold.mp3" } },
{ "redirect": { "url": "https://ivr.example.com/voice/queue-timed-out" } }
]
}Hold audio. Prefer a stable waitUrl served with real cache headers, or configure the hold audio on the queue itself and omit waitUrl entirely. The switch caches hold audio by URL, so a freshly-signed URL per call is a new cache key on every join and the file is downloaded before the caller is parked — about 0.38s, against a park-to-ring that is otherwise about 170ms. When audio cannot be played the switch falls back in order: waitUrl, then the queue's own hold audio, then a tone. A waiting caller never hears silence.
Announcement voice. announceVoice names the Google Cloud TTS voice the switch speaks position announcements in; omit it and the switch uses en-GB-Standard-F. Letters, digits and hyphens only — the value reaches a command line on the switch, so anything else is rejected rather than cleaned up. Google publishes no en-KE, en-ZA or en-NG voice, so en-GB-Standard-F is the closest neutral default for East and West African deployments alike. The Swahili sw-KE Chirp3-HD voices are available and sound good, but position announcements in Swahili would need Swahili numerals, which the switch does not yet speak.
To point a claimed number straight at a queue without writing a callback at all, set the queue block on PUT /v1/numbers/{id}/routing, which also documents the sharing_mode ring orders and the 202 provisioning response.
{
"record": {
"action": "https://ivr.example.com/recording-done",
"method": "POST",
"timeout": 5,
"maxLength": 120,
"finishOnKey": "#",
"transcribe": false
}
}{
"redirect": {
"url": "https://ivr.example.com/after-hours",
"method": "POST"
}
}Transfers call flow to a new URL that returns its own VoiceAction response. Use this to implement menus without a single monolithic handler.
{
"reject": { "reason": "busy" }
}reason is "rejected" (default) or "busy". Use busy to simulate a busy signal rather than a hard rejection.
{ "hangup": {} }Ends the call. No parameters required.
Stream forks the live call's audio to a WebSocket endpoint as raw binary PCM frames, so an external service (a transcriber, or an LLM voice agent such as Google Gemini Live) can process the audio in real time. In bidirectional mode, your WebSocket server plays audio back into the call by sending binary PCM frames on the same socket, which makes full-duplex AI voice agents possible.
XML form (forward this via the raw-XML response today; see the note below). This example bridges to a Gemini Live agent and passes tenant/auth context through the metadata attributes:
<Response>
<Stream
name="gemini-agent"
url="wss://your-app.example.com/audio"
track="both_tracks"
connect="true"
outputSamplingRate="16000"
bidirectionalSamplingRate="16000"
headerMetadata='{"X-Tenant":"hdty","X-Auth":"abc123"}'
openMetadata='{"correlationId":"req-42"}'
statusCallback="https://your-app.example.com/stream-status"
statusEvents="stream-started stream-stopped stream-error" />
</Response>JSON form — build it with the stream() helper in @sautikit/node (the SDK emits this shape ahead of native runtime support; see the note):
{
"stream": {
"name": "gemini-agent",
"url": "wss://your-app.example.com/audio",
"track": "both_tracks",
"connect": true,
"outputSamplingRate": 16000,
"bidirectionalSamplingRate": 16000,
"headerMetadata": { "X-Tenant": "hdty", "X-Auth": "abc123" },
"openMetadata": "{\"correlationId\":\"req-42\"}",
"statusCallback": "https://your-app.example.com/stream-status",
"statusEvents": "stream-started stream-stopped stream-error"
}
}| Attribute | Required | Default | Purpose |
|---|---|---|---|
url | yes | — | ws:// or wss:// endpoint that receives the audio. Your server must accept the audio.drachtio.org WebSocket subprotocol (override via the MOD_AUDIO_FORK_SUBPROTOCOL_NAME env var). |
name | no | generated streamSid | Friendly identifier echoed back in callbacks/events as streamName. |
track | no | both_tracks | inbound_track (caller only → mono), outbound_track (stereo — the WS picks channel 2), or both_tracks (mono mix of both legs). There is no write-only mode, so outbound_track falls through to stereo. |
connect | yes | — | connect="true" tells the platform to answer and hold the call leg for the fork's lifetime. Without it the fork is fire-and-forget on a document that ends immediately — the call hangs up within about a second. Always set it when Stream drives the call. |
headerMetadata | no | — | Flat JSON object of string key/value pairs (auth tokens, tenant/correlation IDs). Folded into the first WS text frame alongside openMetadata as {"metadata":<openMetadata>,"headers":<headerMetadata>} — not HTTP handshake headers. Validated for a flat shape, RFC 7230 header names, and no CR/LF in values; malformed input raises stream-error before the fork starts. |
openMetadata | no | small default JSON object | Opaque UTF-8 string sent in the first WS text frame. Pre-serialize JSON yourself if the server expects structured data. When both this and headerMetadata are omitted, a default object with streamSid, streamName, sessionId, callerNumber, destinationNumber is sent. |
inputSamplingRate | no | channel read_rate | Informational hint of the source rate in Hz (8000 PCMU, 16000 G.722, 48000 Opus). mod_audio_fork auto-resamples from the live rate, so this does not change codec negotiation; a mismatch logs a NOTICE so silent resampling is visible. |
outputSamplingRate | no | 8000 | Rate sent over the WebSocket in Hz: 8000 or 16000. The WS server always receives audio at exactly this rate. The rate you send audio back at is declared by bidirectionalSamplingRate, not this attribute. samplingRate is a deprecated alias. |
bidirectionalSamplingRate | no | — | Sample rate in Hz your WebSocket server returns audio at (raw S16LE binary PCM frames); the platform resamples it to the channel codec. Set it whenever you play audio back into the call — without it, bidirectional playback behavior is undefined. |
statusCallback | no | active callback URL | URL for status callbacks. Response bodies are not parsed for actions — Stream is fire-and-forget. |
statusEvents (alias statusCallbackEvent) | no | stream-started stream-stopped | Space-separated subset of stream-started, stream-stopped, stream-error. |
Your WebSocket server must accept the audio.drachtio.org subprotocol. For each subscribed event, Sautikit POSTs this payload to your statusCallback:
{
"callSessionState": "StreamStarted | StreamStopped | StreamError",
"streamSid": "MZ<epoch><rand>",
"streamName": "<name or sid>",
"streamUrl": "<wss-url>",
"streamTrack": "inbound_track | outbound_track | both_tracks",
"streamInputSamplingRate": "<hz>",
"streamOutputSamplingRate": "<hz>",
"streamEvent": "stream-started | stream-stopped | stream-error",
"streamStatus": "active | stopped | failed",
"errorMessage": "<reason>"
}Stream forks media and returns immediately — the fork itself is fire-and-forget and does not hold the call. A document containing only Stream without connect="true" ends as soon as it is executed, and the platform hangs up the leg within about a second. connect="true" answers and holds the leg for the fork's lifetime.
Your voice callback is also re-invoked on lifecycle edges — getDigits completion, StreamStopped, StreamError, and Completed — not just the initial answer. A stateless callback that returns the same Stream document on every POST re-issues the fork each time, which shows up as repeated streamSid values and StreamError callbacks with errorMessage -ERR. Branch on callSessionState: return the Stream document only on the initial answered edge, and an empty <Response/> on every other edge.
If you are moving from a PBX XML dialect, the following table shows the equivalent JSON key for each common XML verb:
| XML verb | Sautikit VoiceAction | Notes |
|---|---|---|
<Say> | say | Identical semantics |
<Play> | play | Identical semantics |
<Gather> / <GetDigits> | getDigits | Collects DTMF; uses numDigits and finishOnKey |
<Dial> | dial | Supports number or sip key |
<Conference> | conference | Top-level verb in Sautikit (not nested inside dial) |
<Queue> | queue | Call-centre queue; verbs after it run when the caller leaves |
<Record> | record | Identical semantics |
<Redirect> | redirect | Identical semantics |
<Reject> | reject | Identical semantics |
<Hangup> | hangup | Identical semantics |
<Stream> | stream | Real-time media fork to a WebSocket; available via the XML form today |
For hold queues, use the queue verb. A conference room with startOnEnter: false is still the right tool for a bridge you assemble yourself — a supervisor joining a live call, or a scheduled multi-party room — but it is no longer the way to make callers wait for an agent.
If your voice_callback_url returns a non-2xx status or does not respond within 10 seconds, the platform hangs up the call. Always respond quickly; do any heavy work asynchronously after returning the initial action set.
If the platform cannot parse your JSON (unknown verb, too many actions, malformed body), it logs the error and hangs up.