Skip to content

Build an AI Voice Agent

Connect inbound calls to an AI voice agent powered by ElevenLabs Conversational AI. The runtime handles SIP bridging; you write the webhook logic that personalizes each conversation.

How it works

  1. Caller dials your Sinch number.
  2. Sinch sends an ICE callback to your function.
  3. Your function responds with a connectAgent action targeting ElevenLabs via SIP.
  4. Sinch bridges the call to ElevenLabs.
  5. (Optional) ElevenLabs sends a conversation-init webhook to personalize the greeting.
  6. After the call ends, ElevenLabs sends a post-call webhook with transcript and analysis.

Required environment variables

ELEVENLABS_AGENT_ID=your-agent-id
ELEVENLABS_API_KEY=your-api-key    # for outbound calls and post-call webhooks

Inbound call — connect to agent

import type { VoiceFunction, FunctionContext } from '@sinch/functions-runtime';
import type { Voice } from '@sinch/voice';
import { createIceBuilder, AgentProvider, createUniversalConfig } from '@sinch/functions-runtime';

export default {
  async ice(context: FunctionContext, event: Voice.IceRequest) {
    const config = createUniversalConfig(context);
    const agentId = config.requireSecret('ELEVENLABS_AGENT_ID');

    return createIceBuilder()
      .say('Connecting you to our AI assistant. Please hold.')
      .connectAgent(AgentProvider.ElevenLabs, agentId)
      .build();
  },
} satisfies VoiceFunction;

ConnectAgent options

return createIceBuilder()
  .connectAgent(AgentProvider.ElevenLabs, agentId, {
    cli: event.cli, // Pass caller ID to agent SIP trunk
    maxDuration: 1800, // 30-minute max
    suppressCallbacks: false, // Still receive ACE/DICE callbacks
  })
  .build();

IVR gating before agent connection

Show a menu first and connect to the agent only if the caller opts in:

import {
  createIceBuilder,
  createPieBuilder,
  MenuBuilder,
  AgentProvider,
  createUniversalConfig,
} from '@sinch/functions-runtime';

export default {
  async ice(context: FunctionContext, event: Voice.IceRequest) {
    const menu = new MenuBuilder()
      .prompt('Press 1 to speak with our AI assistant, or press 0 to reach a human agent.')
      .option('1', 'return(ai_agent)')
      .option('0', 'return(human_agent)')
      .timeout(5000)
      .repeats(2)
      .build();

    return createIceBuilder().runMenu(menu).build();
  },

  async pie(context: FunctionContext, event: Voice.PieRequest) {
    const config = createUniversalConfig(context);
    const agentId = config.requireSecret('ELEVENLABS_AGENT_ID');
    const humanNumber = config.requireVariable('HUMAN_AGENT_NUMBER');

    if (event.menuResult?.value === 'ai_agent') {
      return createPieBuilder()
        .say('Connecting you to our AI assistant.')
        .connectAgent(AgentProvider.ElevenLabs, agentId)
        .build();
    }

    if (event.menuResult?.value === 'human_agent') {
      return createPieBuilder()
        .say('Connecting you to a human agent.')
        .connectPstn(humanNumber)
        .build();
    }

    return createPieBuilder().say('Invalid selection. Goodbye.').hangup().build();
  },
} satisfies VoiceFunction;

Conversation-init webhook

ElevenLabs calls this webhook before the agent's first word. Return dynamic_variables to personalize the session.

Configure the URL in your ElevenLabs agent settings: https://func-{project}-{name}.functions.sinch.com/webhook/elevenlabs/conversation-init

import {
  ElevenLabsController,
  type ConversationInitRequest,
  type ConversationInitResponse,
} from '@sinch/functions-runtime';

class MyElevenLabsHandler extends ElevenLabsController {
  async handleConversationInit(
    request: ConversationInitRequest
  ): Promise<ConversationInitResponse> {
    const callerId = request.caller_id ?? 'unknown';
    return {
      dynamic_variables: {
        caller_phone: callerId,
        customer_name: 'valued customer',
        account_tier: 'standard',
      },
    };
  }

  async handlePostCall(webhook: ElevenLabsPostCallWebhook): Promise<void> {
    const { conversation_id, status, metadata, analysis } = webhook.data;
    console.log('Call completed', {
      conversationId: conversation_id,
      duration: metadata.call_duration_secs,
      summary: analysis?.transcript_summary,
    });
  }
}

ConversationInitResponse fields

FieldTypeDescription
dynamic_variablesRecord<string, string>Key-value pairs injected into agent prompt and first message
conversation_config_override.agent.first_messagestringOverride opening line
conversation_config_override.agent.promptstringOverride system prompt
conversation_config_override.agent.languagestringOverride language (e.g. "es")

Outbound calls via ElevenLabs

ElevenLabs initiates the outbound call. Your function bridges the SIP leg to PSTN:

export default {
  async ice(context: FunctionContext, event: Voice.IceRequest) {
    if (event.originationType === 'SIP') {
      const destination = event.to?.endpoint ?? '';
      return createIceBuilder().connectPstn(destination, { cli: event.cli }).build();
    }
    return createIceBuilder().hangup().build();
  },

  async ace(context: FunctionContext, event: Voice.AceRequest) {
    return new AceSvamlBuilder().continue().build();
  },
} satisfies VoiceFunction;

For sinch.json configuration and environment variables, see Templates and Configuration & Secrets.