Zum Hauptinhalt springen

Voice Agents JS Example (Grok Basic Call Transfer)

Voice Agents JS Example - Grok Basic Call Transfer​

This example demonstrates a basic Voice Agents script that uses xAI's Grok Speech to Speech API through Vodia's JavaScript Voice Agents capabilities.

As with the OpenAI example, the audio (RTP) is established through a SIP call to xAI, while call control and function calls are handled over a WebSocket bound to the same call by the unique call_id that xAI delivers in its webhook.

Grok Setup:​

Please refer to the documentation here for Grok setup, including registering your phone number with xAI, configuring the webhook and SIP authentication, and then setting up the generic SIP trunk and dialplan on Vodia.

Scenario:​

  • Send the SIP call to xAI's SIP interface using Vodia's trunk and dialplan. The dialplan rewrites grok into the E.164 number registered with xAI.
  • xAI sends a webhook back with a unique call_id.
  • Open a WebSocket to that call using the same call_id. There is no accept step — unlike OpenAI, joining the WebSocket is what activates the session.
  • Send session.update to configure the voice, instructions, turn detection and tools.
  • Send response.create to trigger the greeting.
  • Parse Grok's function call to determine the appropriate call routing.
note

The model alias used is grok-voice-latest, which currently points to grok-voice-think-fast-2.0. Verify its availability under your xAI plan and pin a versioned model name in production. Check rate limit and cost impacts before deploying at scale.

//
// Grok (xAI) integration
//
// (C) Vodia Networks 2026
//
// This file is property of Vodia Networks Inc. All rights reserved.
// For more information mail Vodia Networks Inc., info@vodia.com.
//
'use strict'

var secret = "xai-YOUR_API_KEY"

// Delay between the transfer decision and the actual transfer, so Grok's
// spoken confirmation finishes playing out over RTP first.
var transferDelay = 2000

var texts = {
initial: {
en: "Hi, I am your Vodia AI assistant. How may I help you today?"
}
}

function text(name) {
var prompt = texts[name]
if (call.lang in prompt) return prompt[call.lang]
return prompt["en"]
}

var transferInitiated = false

var timer = setTimeout(function() {
console.log('TIMEOUT: no transfer in 30 seconds, falling back to 700')
call.transfer('700')
}, 30000)

call.http(onhttp)

function onhttp(args) {
console.log('Grok ringing ... ')
console.log(JSON.stringify(args))
const body = JSON.parse(args.body)
if (body.type == 'realtime.call.incoming') {
// No accept call is required for xAI. Joining the WebSocket on this
// call_id is what brings the session up.
connected(body.data.call_id)
}
}

function connected(callid) {
var ws = new Websocket("wss://api.x.ai/v1/realtime?call_id=" + callid)

ws.header([
{ name: "Authorization", value: "Bearer " + secret, secret: true },
{ name: "User-Agent", value: "Vodia-PBX/70" }
])

ws.on('open', function() {
console.log("Websocket opened")

const instructions = [
"## Role & Persona",
"You are a concise, professional receptionist for Vodia Networks.",
"",
"## Objective",
"Identify who the caller wants to reach and transfer them, or route them to the operator when you cannot.",
"",
"## Conversation Flow",
"Ask who the caller would like to speak to. Resolve the answer against this map:",
"Sales 501, Marketing 504, Support 500.",
"If the caller gives a number directly, use it as-is.",
"If the destination is not in the map, use 700.",
"Whenever the caller asks to be transferred, connected, or to speak to someone,",
"you MUST call the function 'transfer_call' with the resolved number as the",
"'destination' argument. Say one short confirmation line first, then call the tool.",
"NEVER answer these intents with plain text instead of the tool call.",
"",
"## Guardrails & Escalation",
"Stay strictly within call routing for Vodia Networks. Do not give technical,",
"billing, legal or medical advice. If the caller is unhappy, asks for a human,",
"or you fail twice to understand them, transfer to 700.",
"",
"## Voice & Communication Style",
"Spoken word only: no markdown, no lists, no emojis.",
"One or two short sentences per turn. Respond only in English.",
"When reading back a number, speak each digit separately with hyphens and confirm.",
"If the input is unclear or incomplete, ask a short clarifying question rather than guessing."
].join("\n")

const update = {
"type": "session.update",
"session": {
"voice": "eve",
"instructions": instructions,
"turn_detection": {
"type": "server_vad",
"silence_duration_ms": 600
},
"reasoning": { "effort": "none" },
"tools": [
{
"type": "function",
"name": "transfer_call",
"description": "Transfers the active SIP call to a destination number",
"parameters": {
"type": "object",
"properties": {
"destination": {
"type": "string",
"description": "Phone number or extension to transfer to"
}
},
"required": ["destination"]
}
}
]
}
}
ws.send(JSON.stringify(update))

const greeting = {
"type": "response.create",
"response": {
"instructions": "Greet with: " + text('initial')
}
}
ws.send(JSON.stringify(greeting))
})

ws.on('close', function() { console.log("Websocket closed") })
ws.on('error', function(e) { console.log('WS ERROR: ' + e) })

ws.on('message', function(message) {
const evt = JSON.parse(message)

if (evt.type === "error") {
console.log('Grok error: ' + JSON.stringify(evt))
return
}

if (evt.type === "input_audio_buffer.dtmf_event_received") {
console.log('DTMF digit from caller: ' + evt.event)
return
}

if (
evt.type === "response.function_call_arguments.done" &&
evt.name === "transfer_call"
) {
const args = JSON.parse(evt.arguments)
handleTransfer(args.destination)
}
})

function handleTransfer(destination) {
if (transferInitiated) {
console.log('Transfer already initiated, ignoring duplicate')
return
}
if (!destination || destination === "") {
console.log('Empty destination, using 700')
destination = "700"
}

transferInitiated = true
if (timer) {
clearTimeout(timer)
timer = null
}

console.log('Transfer to destination: ' + destination)
setTimeout(function() {
call.transfer(destination)
}, transferDelay)
}

ws.connect()
}

call.dial('grok')

Notes on adapting other OpenAI scripts to Grok​

The three changes above cover any OpenAI script in this documentation — call screening, attended transfer, hotel reception:

  1. Drop the accept step. Remove the system.http() POST to /accept. Go straight from onhttp into opening the WebSocket.
  2. Function-call event shape differs. OpenAI emits response.output_item.done with evt.item.type === "function_call" and evt.item.name / evt.item.arguments. Grok emits response.function_call_arguments.done with evt.name / evt.arguments at the top level.
  3. Session body differs slightly. Grok's session.update does not use "type": "realtime" or "tool_choice" — omit both.

Everything that is Vodia call control — call.transfer(), call.dial(), call.ivraction, call.orig_from, hold/accept/reject actions — is unaffected by the provider change, since these are PBX-side APIs rather than part of either provider's protocol.

DTMF​

On SIP sessions, Grok buffers caller keypresses and flushes them to the model as text input automatically. The buffer is submitted when the caller presses #, after 2.5 seconds of idle time, or when the caller starts speaking.

The script receives each keypress as an input_audio_buffer.dtmf_event_received event — an audit trail rather than something requiring action. The example logs it.

note

DTMF events are emitted only on SIP sessions, not on direct WebSocket connections.

Useful session options​

OptionPurpose
force_messageA conversation.item.create item type that speaks a verbatim TTS line without involving the model — use for scripted greetings and compliance disclosures. Do not follow it with response.create; the force message is the turn
replaceMaps phrases to spoken substitutions before TTS, so brand names and technical terms are pronounced correctly without changing the transcript
audio.input.transcription.keytermsBiases transcription toward product names and proper nouns. Up to 100 terms
audio.input.transcription.language_hintBCP-47 code biasing ASR toward a language. Spanish and Portuguese require a regional variant
turn_detection.idle_timeout_msRe-engages a silent caller after the assistant finishes speaking, re-arming after every response
resumption.enabledCaches turns against a conversation_id so a later call can continue the same conversation. Both sessions must opt in; history expires after 30 minutes of inactivity

For more information on Vodia's JavaScript capabilities, refer to: Vodia Backend JavaScript Documentation