# 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<FunctionController> logger) : base(context, configuration, logger) { }

        public override async Task<IActionResult> 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<IActionResult> Ace([FromBody] AceCallbackModel callbackData)
            => Ok(new AceSvamletBuilder().Action.Continue().Build());

        public override async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<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,
    });
  }
}
```

C#

```csharp
using Microsoft.AspNetCore.Mvc;
using SinchFunctions.AI.ElevenLabs;
using SinchFunctions.Controllers;

namespace SinchFunctions
{
    public class MyElevenLabsWebhooksController : ElevenLabsController
    {
        private readonly ILogger<MyElevenLabsWebhooksController> _logger;

        public MyElevenLabsWebhooksController(ILogger<MyElevenLabsWebhooksController> logger)
        { _logger = logger; }

        public override async Task<IActionResult> HandleConversationInitWebhook(
            [FromBody] ConversationInitRequest request)
        {
            return Ok(new ConversationInitResponse
            {
                Type = "conversation_initiation_client_data",
                DynamicVariables = new Dictionary<string, object>
                {
                    ["caller_phone"]   = request.CallerId ?? "unknown",
                    ["customer_name"]  = "valued customer",
                    ["account_tier"]   = "standard",
                }
            });
        }

        public override async Task<IActionResult> 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<string, string>` | 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<IActionResult> 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<IActionResult> Ace([FromBody] AceCallbackModel callbackData)
    => Ok(new AceSvamletBuilder().Action.Continue().Build());
```

For sinch.json configuration and environment variables, see [Templates](/docs/functions/functions/templates) and [Configuration & Secrets](/docs/functions/functions/concepts/configuration-secrets).