---
title: "Build a voice agent"
description: "Answer phone calls with an AI agent that says it is AI, runs your actions as tools, and never records without consent."
section: "guides"
status: "Planned for 0.6"
spec: "ai-voice"
canonical: "https://mainsdk.dev/docs/guides/build-a-voice-agent"
updated: "2026-10-28"
---

# Build a voice agent

> Answer phone calls with an AI agent that says it is AI, runs your actions as tools, and never records without consent.

Status: Planned for 0.6 · Canonical: https://mainsdk.dev/docs/guides/build-a-voice-agent · Updated: 2026-10-28

You will route a phone number to a voice session for one of your agents. It takes about 30 minutes and needs a telephony account and a speech-to-speech provider key for a live call. Tests run with no account, against local room servers and a real WebSocket on the stream route.

## You need

- An agent declared under `agents` in `main.yaml`, with a budget.
- A Twilio, Telnyx or SIP trunk account, and an OpenAI or Google key for the realtime model.
- The provider hosts in `network.allow`. `mainsdk check` reports any that are missing as `MAIN-VOICE-0011`.

## 1. Configure

```yaml title="main.yaml"
agents:
  support: { budget: { spend: $5/day, calls: 500/day } }
ai:
  driver: openai
  models: [openai/*]
  voice:
    driver: openai-realtime
    disclosure: You are speaking with an AI assistant for Pinecart.
    max-duration: 15m
    telephony:
      driver: twilio
      account-sid: secret:TWILIO_SID
      auth-token: secret:TWILIO_TOKEN
      webhook-url: https://app.example.com/voice
      stream-url: wss://app.example.com/voice/stream
      stream-secret: secret:VOICE_STREAM
      numbers: { "+15550100": support }
    sessions:
      support: { voice: alloy, instructions: "Help callers with their orders.", feature: voice-support, agent: support }
```

A number routed to a session with an empty `disclosure` fails `mainsdk check` with `MAIN-VOICE-0010`.

## 2. Answer calls

```ts title="app/voice/route.ts"
import { ai } from "mainsdk";

export async function POST(req: Request) {
  return ai.voice.calls.handle(req);   // verifies the signature, picks the session by the number called
}
```

An unsigned or forged webhook fails with `MAIN-VOICE-0005` and nothing runs. The call state is read back from the provider, never taken from the webhook body.

## 3. Serve the media stream

The webhook answer tells the carrier to stream the call's audio to `stream-url`, with a token for that one call in the path. Your HTTP server hands the upgrade to the session:

```ts title="server.ts"
import { createServer } from "node:http";
import { ai } from "mainsdk";

const server = createServer(/* your request handler */);

server.on("upgrade", (req, socket, head) => {
  if (req.url?.startsWith("/voice/stream/")) {
    void ai.voice.calls.upgrade({ req, socket, head });   // runs until hangup or a limit
  }
});
```

A stream without a valid token for a call that is waiting gets a `401` with `MAIN-VOICE-0005` before the socket opens. MainSDK runs the WebSocket itself: no SIP stack and no SFU. On a runtime with its own upgrade API, accept the socket there and pass it to `ai.voice.calls.attach(callId, socket)`.

## 4. Put the agent in a room (optional)

For a LiveKit or Daily room, add `ai.voice.rooms` with the provider keys and a `bridge`: the `wss://` URL of the media bridge you run next to the room, which carries 16 kHz PCM audio (the providers use WebRTC, which MainSDK does not run).

```ts title="app/voice/room.ts"
import { ai, type AiUser } from "mainsdk";

declare const options: { voice: string; instructions: string; feature: string; agent: string };
declare const user: AiUser;

const session = await ai.voice.rooms.join({ room: "r1", session: { ...options, user } });
const token = await ai.voice.rooms.token({ room: "r1", user });   // for the client SDK, 10 minutes
```

A join with no bridge fails with `MAIN-VOICE-0002`, and a bridge that never confirms fails with `MAIN-VOICE-0006`.

## Check it worked

The first event of every session is a disclosure that the caller is talking to AI. In a test, `ai.driver: local` and a fake voice driver give the whole conversation offline. A session that reaches `max-duration` or its cost limit says a closing line and ends with `MAIN-VOICE-0004`. Recording is off unless you set `record: consent` and the caller's `call-recording` consent is recorded; otherwise it fails with `MAIN-VOICE-0003` and the call goes on unrecorded.

## Next

- [AI calls](https://mainsdk.dev/docs/interfaces/ai)
- [Give an agent a budget](https://mainsdk.dev/docs/guides/govern-an-app-agent)
