Voice agents

The OpenAI Realtime protocol, a phone IVR over SIP, a Voiceflow voice agent through its SIP connection, and Vapi, Deepgram and Retell agents on the generic targets.

Each recipe is a whole project you can copy, change the URLs and run; see Recipes for the other pages.

A voice agent on the OpenAI Realtime protocol

# tamesu.yaml
version: 1
suites: ["realtime.yaml"]
genkit:
  plugins: [googlegenai]
models: { default: googleai/gemini-2.5-flash }
speech:
  tts: { model: googleai/gemini-2.5-flash-preview-tts, voice: en-US-female }
  stt: { model: googleai/gemini-2.5-flash, language: en }
secrets:
  GEMINI_API_KEY: env:GEMINI_API_KEY
  OPENAI_API_KEY: env:OPENAI_API_KEY
suite: realtime
targets:
  rt:
    type: realtime
    model: gpt-realtime
    headers: { Authorization: "Bearer {{secrets.OPENAI_API_KEY}}" }
    session_update:
      instructions: "You are the support agent of Mochi Shoes. Call lookup_policy before answering a policy question; call end_session when the customer is done."
      tools:
        - { type: function, name: lookup_policy, description: "Look up a store policy", parameters: { type: object, properties: { topic: { type: string } }, required: [topic] } }
        - { type: function, name: end_session, description: "The customer is done.", parameters: { type: object, properties: {} } }
    tool_results:
      lookup_policy: { topic: "{{args.topic}}", window_days: 30, exceptions: none }
    mode: audio
    audio_format: g711_ulaw
tests:
  - name: interrupted refund answer
    target: rt
    persona: { name: Marta, style: impatient, language: en, voice: en-US-female, behaviors: { interrupt_probability: 0.5 } }
    scenario: { goal: "Ask about returning shoes bought 40 days ago, then say you are done", max_turns: 4 }
    assert:
      - judge: "The agent states the 30-day return policy"
      - tool_called: lookup_policy
      - metric: { name: time_to_first_audio_ms_p95, lt: 2500 }

A phone IVR through the SIP target

# tamesu.yaml
version: 1
suites: ["ivr.yaml"]
genkit:
  plugins: [googlegenai]
models: { default: googleai/gemini-2.5-flash }
speech:
  tts: { model: googleai/gemini-2.5-flash-preview-tts }
  stt: { model: googleai/gemini-2.5-flash, language: en }
  vad: { provider: silero }
sip:
  livekit_url: "${LIVEKIT_URL:-ws://localhost:7880}"
  outbound_trunk_id: "${TAMESU_SIP_TRUNK:-ST_xxxx}"
  allowed_numbers: ["+1555000*"]
  max_concurrent_calls: 1
secrets:
  GEMINI_API_KEY: env:GEMINI_API_KEY
  LIVEKIT_API_KEY: env:LIVEKIT_API_KEY
  LIVEKIT_API_SECRET: env:LIVEKIT_API_SECRET
suite: ivr
targets:
  ivr: { type: sip, direction: outbound, to: "+15550001", from: "+15550002", ring_timeout: 20s, max_call_duration: 2m }
tests:
  - name: the IVR routes 2 to billing
    target: ivr
    seed: 1
    persona: { name: Marta, style: calm, language: en, voice: en-US-female, behaviors: { background_noise: street, line_quality: pstn } }
    scenario:
      goal: "Reach the billing department and ask about a double charge"
      max_turns: 3
      script:
        - wait_for: "press 2 for billing"
        - dtmf: "2"
        - wait_for: "billing department"
    assert:
      - dtmf_received: { equals: "9" }
      - call_ended_by: [agent, caller]
      - metric: { name: call_setup_ms, lt: 15000 }

A Vapi assistant over its websocket transport

Vapi exposes an assistant on a websocket when the call is created with the vapi.websocket transport: POST /call answers a websocketCallUrl, the socket carries raw PCM both ways, and Vapi’s control messages (transcripts, speech updates, tool calls, the end of the call) travel as JSON on the same socket. The voice target does all of it from YAML: session.open creates the call and captures the URL, and messages names the control messages.

suite: vapi-assistant
tags: [voice, vapi]
vars: { assistant_id: "${VAPI_ASSISTANT_ID:-asst_000}" }
secrets: { VAPI_API_KEY: env:VAPI_API_KEY }
targets:
  assistant:
    type: voice
    session:
      open:
        url: https://api.vapi.ai/call
        headers: { Authorization: "Bearer {{secrets.VAPI_API_KEY}}" }
        body:
          assistantId: "{{vars.assistant_id}}"
          assistantOverrides: { variableValues: { customer_name: Marta } }
          transport: { provider: vapi.websocket, audioFormat: { format: pcm_s16le, container: raw, sampleRate: 16000 } }
        capture: { ws_url: $.transport.websocketCallUrl, call_id: $.id }
    url: "{{session.ws_url}}"
    audio: { encoding: pcm16, sample_rate: 16000, frames: binary }
    messages:
      transcript: "$[?(@.type=='transcript' && @.role=='assistant' && @.transcriptType=='final')].transcript"
      done: "$[?(@.type=='status-update' && @.status=='ended')].status"
      interrupted: "$[?(@.type=='user-interrupted')].type"
      tool_calls: "$[?(@.type=='tool-calls')].toolCallList[*]"
      close: { type: hangup }
    idle: 2s
tests:
  - name: return after 40 days
    target: assistant
    persona: { name: Marta, style: "polite, slightly confused", language: en, voice: en-US-female }
    scenario: { goal: "Find out if the shoes you bought 40 days ago can still be returned", max_turns: 6 }
    assert:
      - judge: "The assistant states the 30-day window and does not promise an exception"
      - tool_called: lookup_policy
      - metric: { name: time_to_first_audio_ms_p95, lt: 2500 }
  - name: it stops when interrupted
    target: assistant
    persona: { name: Ken, style: "impatient", language: en, behaviors: { interrupt_probability: 1 } }
    scenario: { goal: "Ask about shipping times and cut the assistant off", max_turns: 3 }
    assert:
      - metric: { name: interruption_stop_ms_p95, lt: 800 }

A mulaw 8 kHz call is the same suite with audioFormat: { format: mulaw, container: raw, sampleRate: 8000 } and audio: { encoding: mulaw }. The same Vapi assistant on a phone number is dialled with the SIP target.

A Deepgram Voice Agent

Deepgram’s agent speaks binary audio both ways on one websocket, configured by a first Settings message, and reports what it heard and said as ConversationText messages. The voice target sends the settings as messages.open and reads the agent’s lines from the transcript messages.

suite: deepgram-agent
tags: [voice, deepgram]
secrets: { DEEPGRAM_API_KEY: env:DEEPGRAM_API_KEY }
targets:
  agent:
    type: voice
    url: wss://agent.deepgram.com/v1/agent/converse
    headers: { Authorization: "Token {{secrets.DEEPGRAM_API_KEY}}" }
    audio: { encoding: pcm16, sample_rate: 16000, frames: binary }
    messages:
      open:
        type: Settings
        audio:
          input: { encoding: linear16, sample_rate: 16000 }
          output: { encoding: linear16, sample_rate: 16000, container: none }
        agent:
          listen: { provider: { type: deepgram, model: nova-3 } }
          think: { provider: { type: open_ai, model: gpt-4o-mini }, prompt: "You are the Mochi Shoes support agent. Returns are accepted within 30 days of delivery, no exceptions." }
          speak: { provider: { type: deepgram, model: aura-2-thalia-en } }
          greeting: "Hi, this is Mochi Shoes. How can I help?"
      transcript: "$[?(@.type=='ConversationText' && @.role=='assistant')].content"
      interrupted: "$[?(@.type=='UserStartedSpeaking')].type"
      tool_calls: "$[?(@.type=='FunctionCallRequest')].functions[*]"
    idle: 2s
tests:
  - name: return after 40 days
    target: agent
    persona: { name: Marta, style: "polite", language: en, voice: en-US-female }
    scenario: { goal: "Find out if the shoes you bought 40 days ago can still be returned", max_turns: 6 }
    assert:
      - judge: "The agent states the 30-day window and does not promise an exception"
      - metric: { name: greeting_ms, lt: 4000 }

The prompt in Settings is the agent under test; keep it in a file and {{vars}} it in when the suite grows. A protocol that wraps audio in JSON instead of binary frames sets audio: { frames: json, send: { ... "{{audio}}" }, receive: "$[*].path.to.base64" } and is otherwise the same.

A Voiceflow voice agent, over SIP

Voiceflow’s runtime takes text over its WebSocket and Conversations API; its voice agents hear through telephony only: a Twilio, Vonage, Telnyx or Voiceflow number, or a Voiceflow SIP connection, an address of the form <uuid>.sip.voiceflow.com that answers sip:<number>@<address> on UDP or TCP 5060 (TLS on 5061), without REGISTER, authenticated by your IP, by credentials or both. So the voice agent is tested with the SIP target: the trunk points at the Voiceflow SIP address, the caller ID goes in From as Voiceflow requires, and tamesu dials. Any number works as to: the connection is bound to one agent. Call forwarding in the agent is a SIP REFER, which transferred_to asserts on; a Capture step collecting digits is answered with dtmf script steps.

# tamesu.yaml
version: 1
suites: ["voiceflow-voice.yaml"]
genkit: { plugins: [googlegenai] }
models: { default: googleai/gemini-2.5-flash }
speech:
  tts: { model: googleai/gemini-2.5-flash-preview-tts }
  stt: { model: googleai/gemini-2.5-flash, language: en }
  vad: { provider: silero }
sip:
  livekit_url: "${LIVEKIT_URL:-ws://localhost:7880}"
  outbound_trunk_id: "${TAMESU_SIP_TRUNK:-ST_xxxx}"      # created by tamesu sip setup from the trunk below
  allowed_numbers: ["+1555*"]
  max_concurrent_calls: 2                                 # SIP calls count against the agent's phone concurrency
  trunk:
    address: 3f2c9a1e-5b7d-4e8a-9c61-2d4b8f0e7a13.sip.voiceflow.com   # the connection's SIP address, never an IP
    numbers: ["+15550100"]                                # the caller ID Voiceflow reads from From
    auth_username: "{{secrets.VF_SIP_USER}}"              # the connection's Credentials; omit with IP authentication
    auth_password: "{{secrets.VF_SIP_PASSWORD}}"
    transport: tcp
secrets:
  GEMINI_API_KEY: env:GEMINI_API_KEY
  LIVEKIT_API_KEY: env:LIVEKIT_API_KEY
  LIVEKIT_API_SECRET: env:LIVEKIT_API_SECRET
  VF_SIP_USER: env:VF_SIP_USER
  VF_SIP_PASSWORD: env:VF_SIP_PASSWORD
suite: voiceflow-voice
tags: [voice, voiceflow]
targets:
  agent:
    type: sip
    direction: outbound
    to: "+15550199"                             # any E.164 number: the SIP connection is assigned to one agent
    from: "+15550100"
    ring_timeout: 20s
    max_call_duration: 3m
tests:
  - name: returns policy, spoken
    target: agent
    persona: { name: Marta, style: "polite, a little hard of hearing", language: en, voice: en-US-female }
    scenario: { goal: "Ask whether shoes bought 40 days ago can still be returned", max_turns: 5 }
    assert:
      - judge: "The agent states the 30-day return window and offers the voucher instead of an exception"
      - call_ended_by: [agent, caller]
      - metric: { name: call_setup_ms, lt: 15000 }
      - metric: { name: time_to_first_audio_ms_p95, lt: 3000 }
  - name: the order number is collected by keypad
    target: agent
    persona: { name: Marta, style: direct, language: en }
    scenario:
      goal: "Find out where order 1001 is"
      max_turns: 4
      script:
        - wait_for: "enter your order number"
        - dtmf: "1001#"
    assert:
      - contains: "1001"
      - judge: "The agent gives a delivery status for the order"
  - name: a billing question is forwarded
    target: agent
    persona: { name: Marta, style: insistent, language: en }
    scenario: { goal: "Dispute a double charge and insist on speaking to billing", max_turns: 4 }
    assert:
      - transferred_to: { matches: "15550155" }       # the billing line the agent forwards to
      - pack: discloses_ai

The same suite tests the agent behind a Twilio, Vonage or Telnyx number instead: keep your own trunk in sip.trunk and put the agent’s phone number in to. The runtime’s audioEvents setting makes the WebSocket return the agent’s TTS as audio traces, which is useful for listening to a reply but is not a way to speak to the agent; the text recipe is under Messaging channels.

A Retell agent through a web call

Retell’s web calls run in a LiveKit room: create-web-call answers an access token for it. session.open creates the call and the LiveKit target joins Retell’s cloud with that token, as the browser SDK does; Retell’s server participant is the agent.

suite: retell-agent
tags: [voice, retell]
vars: { agent_id: "${RETELL_AGENT_ID:-agent_000}" }
secrets: { RETELL_API_KEY: env:RETELL_API_KEY }
targets:
  agent:
    type: livekit
    url: wss://retell-ai-4ihahnq7.livekit.cloud          # the LiveKit cloud Retell's web calls run on
    session:
      open:
        url: https://api.retellai.com/v2/create-web-call
        headers: { Authorization: "Bearer {{secrets.RETELL_API_KEY}}" }
        body: { agent_id: "{{vars.agent_id}}", retell_llm_dynamic_variables: { customer_name: Marta } }
        capture: { token: $.access_token, call_id: $.call_id }
    token: "{{session.token}}"
    room: "{{session.call_id}}"
    agent_identity_prefix: server
    mode: audio
    join_timeout: 20s
tests:
  - name: order status
    target: agent
    persona: { name: Marta, style: direct, language: en, voice: en-US-female }
    scenario: { goal: "Ask where order ORD-1001 is", max_turns: 4 }
    assert:
      - judge: "The agent gives a delivery estimate or says how to get one"
      - metric: { name: time_to_first_audio_ms_p95, lt: 3000 }

Retell’s newer v3 endpoint issues tokens for its own WebRTC gateway rather than LiveKit; the v2 endpoint above is the one that works with the LiveKit target. The same agent on a phone number is dialled with the SIP target.