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, Record, Redirect, Reject, or Hangup), 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 |
| 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 |
| 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"
}
}{
"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) |
<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 a conference room with startOnEnter: false rather than a dedicated queue verb.
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.