# 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 ```bash ELEVENLABS_AGENT_ID=your-agent-id ELEVENLABS_API_KEY=your-api-key # for outbound calls and post-call webhooks ``` ## Inbound call — connect to agent Node.js ```typescript 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; ``` C# ```csharp using Microsoft.AspNetCore.Mvc; using SinchFunctions.AI; using SinchFunctions.Models; using SinchFunctions.Utils; namespace SinchFunctions { public class FunctionController : SinchVoiceController { public FunctionController( FunctionContext context, IConfiguration configuration, ILogger logger) : base(context, configuration, logger) { } public override async Task Ice([FromBody] IceCallbackModel callbackData) { var agentId = Configuration["ELEVENLABS_AGENT_ID"] ?? throw new InvalidOperationException("ELEVENLABS_AGENT_ID is required"); return Ok(new IceSvamletBuilder() .Instructions.Say("Connecting you to our AI assistant. Please hold.") .Action.ConnectAgent(AgentProvider.ElevenLabs, agentId) .Build()); } public override async Task Ace([FromBody] AceCallbackModel callbackData) => Ok(new AceSvamletBuilder().Action.Continue().Build()); public override async Task Dice([FromBody] DiceCallbackModel callbackData) { Logger.LogInformation("Call ended: {Reason}, {Duration}s", callbackData.Reason, callbackData.Duration); return Ok(); } } } ``` ## ConnectAgent options Node.js ```typescript 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(); ``` C# ```csharp return Ok(new IceSvamletBuilder() .Action.ConnectAgent(AgentProvider.ElevenLabs, agentId, new ConnectAgentOptions { Cli = callbackData.Cli, MaxDuration = 1800, SuppressCallbacks = false, }) .Build()); ``` ## IVR gating before agent connection Show a menu first and connect to the agent only if the caller opts in: Node.js ```typescript 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; ``` C# ```csharp public override async Task Ice([FromBody] IceCallbackModel callbackData) { var menu = MenuFactory.CreateMenu() .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 Ok(new IceSvamletBuilder().Action.RunMenu(menu).Build()); } public override async Task Pie([FromBody] PieCallbackModel callbackData) { var agentId = Configuration["ELEVENLABS_AGENT_ID"]!; var humanNumber = Configuration["HUMAN_AGENT_NUMBER"]!; return callbackData.MenuResult?.Value switch { "ai_agent" => Ok(new PieSvamletBuilder() .Instructions.Say("Connecting you to our AI assistant.") .Action.ConnectAgent(AgentProvider.ElevenLabs, agentId).Build()), "human_agent" => Ok(new PieSvamletBuilder() .Instructions.Say("Connecting you to a human agent.") .Action.ConnectPstn(humanNumber).Build()), _ => Ok(new PieSvamletBuilder() .Instructions.Say("Invalid selection. Goodbye.") .Action.Hangup().Build()), }; } ``` ## 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` Node.js ```typescript import { ElevenLabsController, type ConversationInitRequest, type ConversationInitResponse, } from '@sinch/functions-runtime'; class MyElevenLabsHandler extends ElevenLabsController { async handleConversationInit( request: ConversationInitRequest ): Promise { const callerId = request.caller_id ?? 'unknown'; return { dynamic_variables: { caller_phone: callerId, customer_name: 'valued customer', account_tier: 'standard', }, }; } async handlePostCall(webhook: ElevenLabsPostCallWebhook): Promise { const { conversation_id, status, metadata, analysis } = webhook.data; console.log('Call completed', { conversationId: conversation_id, duration: metadata.call_duration_secs, summary: analysis?.transcript_summary, }); } } ``` C# ```csharp using Microsoft.AspNetCore.Mvc; using SinchFunctions.AI.ElevenLabs; using SinchFunctions.Controllers; namespace SinchFunctions { public class MyElevenLabsWebhooksController : ElevenLabsController { private readonly ILogger _logger; public MyElevenLabsWebhooksController(ILogger logger) { _logger = logger; } public override async Task HandleConversationInitWebhook( [FromBody] ConversationInitRequest request) { return Ok(new ConversationInitResponse { Type = "conversation_initiation_client_data", DynamicVariables = new Dictionary { ["caller_phone"] = request.CallerId ?? "unknown", ["customer_name"] = "valued customer", ["account_tier"] = "standard", } }); } public override async Task HandlePostCallWebhook( [FromBody] ElevenLabsPostCallWebhook webhook) { _logger.LogInformation("Call completed: Id={Id}, Duration={Duration}s", webhook.Data?.ConversationId, webhook.Data?.Metadata?.CallDurationSecs); return Ok(); } } } ``` ## ConversationInitResponse fields | Field | Type | Description | | --- | --- | --- | | `dynamic_variables` | `Record` | Key-value pairs injected into agent prompt and first message | | `conversation_config_override.agent.first_message` | `string` | Override opening line | | `conversation_config_override.agent.prompt` | `string` | Override system prompt | | `conversation_config_override.agent.language` | `string` | Override language (e.g. `"es"`) | ## Outbound calls via ElevenLabs ElevenLabs initiates the outbound call. Your function bridges the SIP leg to PSTN: Node.js ```typescript 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; ``` C# ```csharp public override async Task Ice([FromBody] IceCallbackModel callbackData) { if (callbackData.OriginationType == "SIP") { var destination = callbackData.To?.Endpoint ?? ""; return Ok(new IceSvamletBuilder() .Action.ConnectPstn(destination, cli: callbackData.Cli).Build()); } return Ok(new IceSvamletBuilder().Action.Hangup().Build()); } public override async Task Ace([FromBody] AceCallbackModel callbackData) => Ok(new AceSvamletBuilder().Action.Continue().Build()); ``` For sinch.json configuration and environment variables, see [Templates](#) and [Configuration & Secrets](#).