Telephony text-to-speech, inbound policy and per-number routing, the spoken output contract, conversation startup, and the stale call reaper. Part of the Voice call plugin guide.

TTS for calls

Voice Call uses the core tts configuration for streaming speech on calls. You can override it under the plugin config with the same shape — it deep-merges with tts.

{
  tts: {
    provider: "elevenlabs",
    providers: {
      elevenlabs: {
        speakerVoiceId: "pMsXgVXv3BLzUgSXRplE",
        modelId: "eleven_multilingual_v2",
      },
    },
  },
}

Microsoft speech is ignored for voice calls. Telephony synthesis requires a provider that implements telephony-target output; the Microsoft speech provider does not, so it is skipped for calls and other providers in the fallback chain are tried instead.

Behavior notes:

TTS examples

Core TTS only

{
  tts: {
    provider: "openai",
    providers: {
      openai: { speakerVoice: "alloy" },
    },
  },
}

Override to ElevenLabs (calls only)

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          tts: {
            provider: "elevenlabs",
            providers: {
              elevenlabs: {
                apiKey: "elevenlabs_key",
                speakerVoiceId: "pMsXgVXv3BLzUgSXRplE",
                modelId: "eleven_multilingual_v2",
              },
            },
          },
        },
      },
    },
  },
}

OpenAI model override (deep-merge)

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          tts: {
            providers: {
              openai: {
                model: "gpt-4o-mini-tts",
                speakerVoice: "marin",
              },
            },
          },
        },
      },
    },
  },
}

Inbound calls

Inbound policy defaults to disabled. To enable inbound calls, set:

{
  inboundPolicy: "allowlist",
  allowFrom: ["+15550001234"],
  inboundGreeting: "Hello! How can I help?",
}

inboundPolicy: "allowlist" is a low-assurance caller-ID screen. The plugin normalizes the provider-supplied From value and compares it to allowFrom. Webhook verification authenticates provider delivery and payload integrity, but it does not prove PSTN/VoIP caller-number ownership. Treat allowFrom as caller-ID filtering, not strong caller identity.

Auto-responses use the agent system. Tune with responseModel, responseSystemPrompt, and responseTimeoutMs.

Per-number routing

Use numbers when one Voice Call plugin receives calls for multiple phone numbers and each number should behave like a different line. For example, one number can use a casual personal assistant while another uses a business persona, a different response agent, and a different TTS voice.

Routes are selected from the provider-supplied dialed To number. Keys must be E.164 numbers. When a call arrives, Voice Call resolves the matching route once, stores the matched route on the call record, and reuses that effective config for the greeting, classic auto-response path, realtime consult path, and TTS playback. If no route matches, the global Voice Call config is used. Outbound calls do not use numbers; pass the outbound target, message, and session explicitly when initiating the call.

Route overrides support exactly these fields:

The tts route value deep-merges over the global Voice Call tts config, so you can usually override only the provider voice:

{
  inboundGreeting: "Hello from the main line.",
  responseSystemPrompt: "You are the default voice assistant.",
  tts: {
    provider: "openai",
    providers: {
      openai: { speakerVoice: "coral" },
    },
  },
  numbers: {
    "+15550001111": {
      inboundGreeting: "Silver Fox Cards, how can I help?",
      responseSystemPrompt: "You are a concise baseball card specialist.",
      tts: {
        providers: {
          openai: { speakerVoice: "alloy" },
        },
      },
    },
  },
}

Spoken output contract

For auto-responses, Voice Call appends a strict spoken-output contract to the system prompt requiring a {"spoken":"..."} JSON reply. Voice Call extracts speech text defensively:

This keeps spoken playback focused on caller-facing text and avoids leaking planning text into audio.

Conversation startup behavior

For outbound conversation calls, first-message handling is tied to live playback state:

Twilio stream disconnect grace

When a Twilio classic streaming or realtime media stream disconnects, Voice Call waits 2000 ms before auto-ending the call:

Stale call reaper

Use staleCallReaperSeconds (default 120) to end calls that are never answered and never reach a live conversation state, for example notify-mode calls where the provider never delivers a terminal webhook. Set it to 0 to disable.

The reaper runs every 30 seconds and only ends calls that have no answeredAt timestamp and are not already in a terminal or live (speaking/listening) state, so answered conversations are never reaped by this timer; maxDurationSeconds (default 300) is the separate cap that ends answered calls that run too long.

The Gateway service owns the reaper schedule and joins pending provider hangups before closing its call manager. Missed checks coalesce while provider hangups are pending. Standalone CLI calls bind to their existing executable CLI owner, which stays active until the call runtime closes.

For notify-style flows where carriers can be slow to deliver ring/answer webhooks, raise staleCallReaperSeconds past the default so slow-but-normal calls are not reaped early; 120-300 seconds is a reasonable production range.

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          maxDurationSeconds: 300,
          staleCallReaperSeconds: 120,
        },
      },
    },
  },
}