Sinch.Functions.Runtime 0.3.22

dotnet add package Sinch.Functions.Runtime --version 0.3.22
                    
NuGet\Install-Package Sinch.Functions.Runtime -Version 0.3.22
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sinch.Functions.Runtime" Version="0.3.22" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sinch.Functions.Runtime" Version="0.3.22" />
                    
Directory.Packages.props
<PackageReference Include="Sinch.Functions.Runtime" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sinch.Functions.Runtime --version 0.3.22
                    
#r "nuget: Sinch.Functions.Runtime, 0.3.22"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sinch.Functions.Runtime@0.3.22
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sinch.Functions.Runtime&version=0.3.22
                    
Install as a Cake Addin
#tool nuget:?package=Sinch.Functions.Runtime&version=0.3.22
                    
Install as a Cake Tool

Sinch.Functions.Runtime

Development runtime for Sinch Functions - build serverless communication workflows with ease.

Getting Started

The easiest way to get started is using the Sinch CLI:

# Install the CLI
npm install -g @sinch/cli

# Create a new function (interactive)
sinch functions init

The interactive prompt will guide you through:

  1. Selecting a runtime (Node.js, C#, etc.)
  2. Choosing a template (simple-voice-ivr recommended for first project)
  3. Setting up your project

Then start the local development server:

sinch functions dev

Your function is now running locally with hot reload!

Deploy to Production

When you're ready to deploy:

sinch functions deploy

Your function will be built and deployed to the Sinch Functions platform.

Features

Voice API

  • SinchVoiceController - Base controller for voice callbacks (ICE, PIE, ACE, DICE)
  • SVAML Builders - Fluent API for building voice responses (IceSvamletBuilder, PieSvamletBuilder)
  • Menu Utilities - IVR menu builders (MenuFactory.CreateMenu())
  • Voice Helpers - TTS, audio playback, and call control utilities
  • Webhook Validation - HMAC signature validation for Voice API callbacks
  • Realtime Voice Agent - RealtimeAgent/AddRealtimeAgent stays on the line for the whole call, relaying audio to a vendor's realtime voice API and dispatching its tool calls in-process (Deepgram, OpenAI GPT-Live, xAI Grok, Google Gemini)

Conversation API

  • SinchConversationController - Base controller for omnichannel messaging (SMS, WhatsApp, Messenger, Viber)
  • Message Builders - Static helpers and fluent builder for messages
  • Extension Methods - Easy access to inbound message properties
  • Multi-Channel Support - Automatic SMS_SENDER handling, channel-specific configurations
  • Webhook Routing - Automatic routing to typed handlers

Infrastructure

  • FunctionContext - Pre-configured SDK clients, caching, logging
  • Local Development - In-memory caching, tunnel support for local debugging
  • SDK Integration - Pre-configured Sinch SDK clients for Voice and Conversation APIs

Voice IVR Example

using SinchFunctions.Utils;

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)
    {
        // Create a voice menu
        var menu = MenuFactory.CreateMenu()
            .Prompt("Welcome to Sinch! Press 1 for sales, 2 for support.")
            .Option("1", "return(sales)")
            .Option("2", "return(support)")
            .Build();

        // Return SVAML response
        return Ok(new IceSvamletBuilder()
            .Action.RunMenu(menu)
            .Build());
    }

    public override async Task<IActionResult> Pie([FromBody] PieCallbackModel callbackData)
    {
        var selection = callbackData.MenuResult?.Value;

        return selection switch
        {
            "sales" => Ok(new PieSvamletBuilder().Action.ConnectPstn("+15551234567").Build()),
            "support" => Ok(new PieSvamletBuilder().Action.ConnectPstn("+15559876543").Build()),
            _ => Ok(new PieSvamletBuilder().Action.Hangup().Build())
        };
    }
}

Realtime Voice Agent Example

ConnectAgent hands the call away — the vendor's own agent runtime takes it from there. RealtimeAgent is the opposite: this function stays on the line for the whole call, relaying audio to a vendor's realtime voice API and dispatching its tool calls in-process. Every adapter is System.Net.WebSockets.ClientWebSocket — no vendor SDK.

Use*() Vendor Notes
UseDeepgram(configure) Deepgram Voice Agent API No resampling — Deepgram accepts the platform's own negotiated rate. Every declared tool defaults to defer_until_eot: true. Barge-in is gated on an estimated playout window rather than raised on every UserStartedSpeaking, which Deepgram's own docs say fires for every utterance, not only a genuine interruption. Listen defaults to flux-general-en (Flux, on Deepgram's /v2/listen); a nova-* model omits the version field, a flux* one sends "version": "v2" alongside it — a Flux model without it falls back to the Nova endpoint, where it's invalid. ThinkProvider (default open_ai) picks the LLM vendor behind Think: open_ai, anthropic, google, groq, or aws_bedrock.
UseOpenAi() OpenAI GPT-Live (gpt-live-1) — not the Realtime API Responses delegation, 8kHz audio/pcmu both ways. Sends audio continuously — a caller leg that goes quiet still needs frames flowing, since GPT-Live paces entirely off inbound audio and a gap in it reads as the leg having stalled, not as silence. No barge-in signal exists; the agent-turn boundary is a silence gap on output audio (GPT-Live streams silence padding continuously between utterances, so only audio whose decoded RMS clears roughly -50 dBFS counts as speech and resets the gap timer — otherwise the gap would never elapse), never on caller speech, with a 30s hard-cap flush and a short fallback window for a transcript delta that arrives just after the audio-side flush already ran.
UseGrok() xAI Grok Realtime-API-shaped (flat session.update), 8kHz audio/pcmu both ways. greeting is paraphrased, not verbatim — sent as response.create instructions, not a dedicated field.
UseGemini() Google Gemini Live Fixed 16kHz in / 24kHz out, resampled through a stateful anti-aliasing resampler. Reconnects transparently (capped retries) on a session-window close, using Gemini's own resumption handle. The caller audio stream must stay continuous — SendAudio forwards every frame the Sinch leg sends unconditionally, including silence, since a gap in it is what earlier looked like a vendor-side silence bug and turned out to be a probe pausing caller audio, not anything Gemini itself did.
UseVoiceRelay(configure) Any OpenAI Responses-compatible endpoint No audio at all — the agent's leg is a relay leg (platform STT/TTS), not a stream leg, and the LLM loop runs in text against POST {BaseUrl}/responses (stream: true), never Chat Completions. VoiceRelayAgentOptions.Model is required and passed through verbatim, no provider prefix; BaseUrl defaults to https://api.openai.com/v1, overridable by the OPENAI_BASE_URL configuration value or the option itself. Works with any model behind an OpenAI Responses-compatible endpoint — point BaseUrl/OPENAI_BASE_URL elsewhere and use whatever model name that server supports. Needs OPENAI_API_KEY as a Bearer token to whichever server. Conversation state is previous_response_id, not the full input list. A caller barge-in (relay interrupt) cancels whatever response is in flight. The same RealtimeAgent subclass works unchanged: Prompt, Greeting, [AgentTool]s, EscalationNumber, the built-in hangup_call/transfer_to_human, Call.Transcript/OnTranscript, OnEndAsync, MaxCallDuration — only Voice/ResolveVoiceAsync don't apply (the relay leg's own TTS voice is VoiceRelayAgentOptions.Voice).

/stream (/relay for UseVoiceRelay) requires a one-time token Connect() mints per call — nothing an author does, but worth knowing when reading logs: a connection that never answers because a token was unknown, expired, or already consumed reached /stream without going through this agent's own call.incoming plan. The platform is also told answer only once the vendor session actually acks ready (Deepgram SettingsApplied, GPT-Live session.started, Grok session.updated, Gemini setupComplete) — a vendor outage or a bad API key rejects the call outright instead of answering into silence.

using SinchFunctions.AI.Realtime;

public class ReservationsAgent : RealtimeAgent
{
    protected override string Prompt =>
        "You are a reservations agent for Acme Cafe. Be brief and friendly.";

    protected override string? Greeting => "Hello! Thanks for calling Acme Cafe.";

    // protected override string? Voice => "aura-2-thalia-en"; // vendor specific, omit for the vendor default

    // A CancellationToken parameter is injected, not read from the vendor's JSON arguments -- it's
    // cancelled if the vendor tells this call it no longer wants the result (e.g. Deepgram's
    // FunctionCallCancelled, Gemini's toolCallCancellation) or the call itself ends.
    [AgentTool("Check table availability for a given date and party size.")]
    public async Task<object> CheckAvailability(string date, int partySize, CancellationToken cancellationToken)
    {
        var slots = await LookUpAvailabilityAsync(date, partySize, cancellationToken); // ... your own lookup ...
        return new { available = slots.Count > 0, times = slots };
    }

    [AgentTool("Book a table once the caller has confirmed a date, time and party size.")]
    public async Task<object> BookTable(string date, string time, int partySize, string name)
    {
        var confirmationId = $"BK-{DateTimeOffset.UtcNow.Ticks:x}";
        await Call.Context.Storage.WriteAsync($"bookings/{confirmationId}.json", /* ... */ "{}");
        return new { confirmationId, message = $"Booked for {name}, {partySize} guests, {date} {time}." };
    }

    [AgentTool("Transfer the call to the host stand for anything this agent cannot resolve.")]
    public async Task<object> TransferToHost(string reason)
    {
        await Call.TransferToPhoneAsync("+15550001234");
        return new { transferred = true, reason };
    }

    // Offers the built-in transfer_to_human tool too, targeting this number; leave unset (the
    // default) and it is not offered at all.
    protected override string? EscalationNumber => "+15550001234";

    protected override void OnTranscript(RealtimeTranscriptEntry entry) =>
        Console.WriteLine($"[{Call.Id}] {entry.Role}: {entry.Text}");

    protected override Task OnEndAsync(RealtimeCallSummary summary) => Task.CompletedTask;
}
// Program.cs / ConfigureServices
services.AddRealtimeAgent<ReservationsAgent>(agent => agent.UseDeepgram());
public class FunctionController : SinchVoiceController
{
    private readonly IRealtimeAgent<ReservationsAgent> _agent;

    public FunctionController(
        FunctionContext context, IConfiguration configuration, ILogger<FunctionController> logger,
        IRealtimeAgent<ReservationsAgent> agent)
        : base(context, configuration, logger) => _agent = agent;

    protected override CallFlow OnIncoming(WebhookRequest request) => _agent.Connect(request);
}

Per-call overrides. Prompt and Greeting are plain overridable properties, evaluated fresh for each call since a RealtimeAgent instance is scoped per call. Voice is a plain nullable string for a fixed choice; override ResolveVoiceAsync() instead (or its cancellation-aware overload ResolveVoiceAsync(CancellationToken)) when the choice depends on the call, e.g. picked from Call.Id or a stored preference. The default ResolveVoiceAsync() just forwards to Voice, resolved once, right before the vendor session opens.

Voice. Unset means no voice is sent to the vendor at all — every adapter omits the field and lets the vendor apply its own default, rather than this package picking one.

Tool cancellation. A tool handler that declares a CancellationToken parameter (see CheckAvailability above) gets one that's actually cancelled when the vendor says it no longer wants this call's result (Deepgram's FunctionCallCancelled, Gemini's toolCallCancellation) or the call itself ends -- not just a bookkeeping entry cleared while the handler keeps running to completion regardless. Grok and GPT-Live don't have a vendor-initiated cancellation frame of their own, so the token there only ever fires when the call ends. The parameter is optional and, if present, is never bound from the vendor's JSON arguments.

What's normalised: the tool contract ([AgentTool]), the lifecycle hooks (OnCallStartingAsync/OnTranscript/OnBargeIn/OnError/OnEndAsync), call control from a tool handler (Call.Hangup(), Call.TransferToPhoneAsync(number) and its TransferToSip/ TransferToStream/TransferToRelay/TransferToAgent siblings — see TransferToHost above), audio format, and prompt/greeting delivery. What isn't: voice ids, a vendor's own split STT/LLM/TTS model choices, and turn-detection/latency feel — those are vendor-specific configuration on each Use*() call (e.g. UseDeepgram(o => o.Think = "gpt-5.4-nano")). Audio defaults to 8kHz telephony, matching the Voice v2 platform's own default for calls that traverse the PSTN; set agent.Audio = RealtimeAudioRate.Wideband for a media path that never does. The stream leg is capped at the MAX_CALL_DURATION configuration value (seconds), falling back to 1800.

Built-in tools. Every agent is offered hangup_call (always) and transfer_to_human (only when EscalationNumber is set). An author's own [AgentTool] method of the same name replaces the built-in rather than running alongside it.

The transcript. Call.Transcript and RealtimeCallSummary.Transcript carry every caller turn, agent turn and completed tool call, in order, as an IReadOnlyList<RealtimeTranscriptEntry>. OnTranscript(RealtimeTranscriptEntry entry) fires once per entry as it's added.

Secrets (DEEPGRAM_API_KEY, OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY) are read from IConfiguration, the same way every other function reads a secret.

Conversation API Chatbot Example

using Sinch.Conversation.Hooks;
using SinchFunctions.Utils;

public class FunctionController : SinchConversationController
{
    public FunctionController(FunctionContext context, IConfiguration config, ILogger<FunctionController> logger)
        : base(context, config, logger) { }

    public override async Task<IActionResult> MessageInbound(MessageInboundEvent inbound)
    {
        // Use extension methods for easy property access
        var text = inbound.GetText();
        var channel = inbound.GetChannel();
        var contactId = inbound.GetContactId();

        if (string.IsNullOrEmpty(text))
        {
            Logger.LogDebug("Ignoring non-text message from {ContactId}", contactId);
            return Ok();
        }

        Logger.LogInformation("Message from {ContactId} via {Channel}: {Text}", contactId, channel, text);

        // Reply using helper method (auto-sets SMS_SENDER from config for SMS)
        await Context.Conversation!.Messages.Send(Reply(inbound, "Thanks for your message!"));

        return Ok(new { status = "ok" });
    }

    public override Task<IActionResult> MessageDelivery(MessageDeliveryReceiptEvent callback)
    {
        var status = callback.MessageDeliveryReport?.Status?.Value;
        Logger.LogInformation("Delivery status: {Status}", status);
        return Task.FromResult<IActionResult>(Ok());
    }
}

Conversation API Extension Methods

// Easy access to inbound message properties
var text = inbound.GetText();           // Message text content
var channel = inbound.GetChannel();     // SMS, WHATSAPP, MESSENGER, etc.
var identity = inbound.GetIdentity();   // Sender's channel identity (phone/PSID)
var contactId = inbound.GetContactId(); // Sinch contact ID
var to = inbound.GetTo();               // Number the message was sent TO

// Check message types
if (inbound.IsTextMessage()) { /* handle text */ }
if (inbound.IsMediaMessage()) { /* handle media */ }
if (inbound.IsPostback()) { /* handle button click */ }

Conversation API Message Builders

// Simple reply - uses channel from inbound message
await Context.Conversation!.Messages.Send(Reply(inbound, "Hello!"));

// Static helper for SMS
var msg = ConversationMessage.CreateSms(appId, "+15551234567", "Hello!", smsSender);
await Context.Conversation!.Messages.Send(msg);

// Static helper for WhatsApp
var msg = ConversationMessage.CreateWhatsApp(appId, "+15551234567", "Hello!");
await Context.Conversation!.Messages.Send(msg);

// Fluent builder for complex cases
var msg = CreateMessage(inbound)
    .Text("Custom reply")
    .Build();
await Context.Conversation!.Messages.Send(msg);

Conversation API Webhook Events

Trigger Method Description
MESSAGE_INBOUND MessageInbound() User sends a message
MESSAGE_DELIVERY MessageDelivery() Delivery status update
EVENT_INBOUND EventInbound() Typing indicators, read receipts
CONVERSATION_START ConversationStart() Conversation begins
CONVERSATION_STOP ConversationStop() Conversation ends

Conversation API Configuration

Variable Description Required
CONVERSATION_APP_ID Your Conversation API app ID Yes
FROM_NUMBER Your SMS sender number (E.164) For SMS only
CONVERSATION_REGION API region (US, EU, BR) No (default: US)

Request Limits

Limit Value
Request body 10 MB (10485760 bytes)
Request/response body retained in logs First 64 KB

A body larger than the limit is rejected with 413 before your handler runs.

The platform router in front of a deployed function enforces the same 10 MB cap, so a body that your handler accepts locally is not rejected in transit once deployed. Exactly 10485760 bytes is accepted; one byte more returns 413.

Logs retain the first 64 KB of a request or response body, appending …[truncated — N bytes total] when the body was longer. Only text formats are retained — JSON, XML, plain text and form data. Anything else, including uploads, images, audio, video, PDFs and archives, is not logged at all. Your function still receives every body in full.

Documentation

License

MIT License - see LICENSE file for details

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.22 97 9/29/2026
0.3.21 102 9/27/2026
0.3.20 106 9/26/2026
0.3.19 111 9/26/2026
0.3.18 108 9/25/2026
0.3.17 112 9/23/2026
0.3.16 113 9/22/2026
0.3.14 125 9/18/2026
0.3.13 134 9/8/2026
0.3.12 132 9/2/2026
0.3.11 120 8/24/2026
0.3.10 126 8/14/2026
0.3.9 119 8/7/2026
0.3.8 122 8/5/2026
0.3.7 171 5/12/2026
0.3.6 139 4/28/2026
0.3.5 130 4/28/2026
0.3.4 202 3/17/2026
0.3.3 154 3/12/2026
0.3.2 140 3/11/2026
Loading failed

See Sinch.Functions.Runtime for release notes