Voice Call Plugin: Outbound and Inbound Calls with Twilio, Telnyx, Plivo

Learn to set up the OpenClaw voice call plugin for outbound notifications, two-way audio, live transcription, and allowlist-controlled inbound calls. Covers configuration for Twilio, Telnyx, and Plivo.

Read this when

  • You want to place an outbound voice call from OpenClaw
  • You are configuring or developing the voice-call plugin
  • You need realtime voice or streaming transcription on telephony

Voice call support for OpenClaw comes through a dedicated plugin. It handles outbound notifications, back-and-forth conversations, realtime two-way audio, live transcription, and incoming calls governed by allowlists.

Supported providers: mock (development only, no network), plivo (Voice API, XML transfer, GetInput speech), telnyx (Call Control v2), twilio (Programmable Voice and Media Streams).

Note

The Voice Call plugin executes within the Gateway process. When running a remote Gateway, put the plugin and its configuration on the Gateway host, then restart the Gateway so it picks up the change.

Quick start

Install the plugin

Install via npm

openclaw plugins install @openclaw/voice-call

Install from a local directory (dev)

PLUGIN_SRC=./path/to/local/voice-call-plugin
openclaw plugins install "$PLUGIN_SRC"
cd "$PLUGIN_SRC" && pnpm install

Use the plain package name to track the latest release tag. Only lock a specific version when you require a repeatable setup. After installing, restart the Gateway so the plugin becomes active.

Configure provider and webhook

Place your configuration under plugins.entries.voice-call.config (details in Configuration further down). At a minimum you need provider, the provider's credentials, fromNumber, and a webhook URL that is reachable from the public internet.

For an inbound Twilio number, point its Voice webhook at the public Voice Call webhook URL, using method POST. Configure the number-level Status Callback to that same URL with ?type=status, again using POST, so the plugin receives final inbound call status updates.

Verify setup

openclaw voicecall setup
openclaw voicecall setup --json

Verifies that the plugin is enabled, credentials are present, the webhook is exposed, and exactly one audio mode (streaming or realtime) is selected.

Smoke test

openclaw voicecall smoke
openclaw voicecall smoke --to "+15555550123"

Both commands default to dry runs. Pass --yes to trigger a brief outbound notify call:

openclaw voicecall smoke --to "+15555550123" --yes

Warning

With Twilio, Telnyx, and Plivo, setup must resolve to a public webhook URL. If publicUrl, the tunnel URL, the Tailscale URL, or the serve fallback points to loopback or private network space, setup fails rather than launching a provider that cannot accept carrier webhooks.

Configuration

When enabled: true is set but the chosen provider has no credentials, Gateway startup logs a setup-incomplete warning listing the absent keys and skips runtime startup. Commands, RPC calls, and agent tools still report exactly which configuration is missing when invoked.

Note

SecretRefs work for voice-call credentials. plugins.entries.voice-call.config.twilio.authToken, plugins.entries.voice-call.config.realtime.providers.*.apiKey, plugins.entries.voice-call.config.streaming.providers.*.apiKey, and plugins.entries.voice-call.config.tts.providers.*.apiKey are resolved through the standard SecretRef surface; refer to SecretRef credential surface for details.

{
  plugins: {
    entries: {
      "voice-call": {
        enabled: true,
        config: {
          provider: "twilio", // or "telnyx" | "plivo" | "mock"
          fromNumber: "+15550001234", // or TWILIO_FROM_NUMBER for Twilio
          toNumber: "+15550005678",
          sessionScope: "per-phone", // per-phone | per-call | main
          numbers: {
            "+15550009999": {
              inboundGreeting: "Silver Fox Cards, how can I help?",
              responseSystemPrompt: "You are a concise baseball card specialist.",
              tts: {
                providers: {
                  openai: { speakerVoice: "alloy" },
                },
              },
            },
          },

          twilio: {
            accountSid: "ACxxxxxxxx",
            authToken: "...",
            // region: "ie1", // optional: us1 | ie1 | au1; defaults to us1
          },
          telnyx: {
            apiKey: "...",
            connectionId: "...",
            // Telnyx webhook public key from the Mission Control Portal
            // (Base64; can also be set via TELNYX_PUBLIC_KEY).
            publicKey: "...",
          },
          plivo: {
            authId: "MAxxxxxxxxxxxxxxxxxxxx",
            authToken: "...",
          },

          // Webhook server
          serve: {
            port: 3334,
            path: "/voice/webhook",
          },

          // Webhook security (recommended for tunnels/proxies)
          webhookSecurity: {
            allowedHosts: ["voice.example.com"],
            trustedProxyIPs: ["100.64.0.1"],
          },

          // Public exposure (pick one)
          // publicUrl: "https://example.ngrok.app/voice/webhook",
          // tunnel: { provider: "ngrok" },
          // tailscale: { mode: "funnel", port: 8443, path: "/voice/webhook" },

          outbound: {
            defaultMode: "notify", // notify | conversation
          },

          streaming: { enabled: true /* Twilio only; see Streaming transcription */ },
          realtime: { enabled: false /* see Realtime voice conversations */ },
        },
      },
    },
  },
}

Config reference

Additional top-level keys under plugins.entries.voice-call.config beyond those listed above:

KeyDefaultNotes
enabledfalseGlobal enable/disable control.
inboundPolicy"disabled"disabled | allowlist | pairing | open. Refer to Inbound calls.
allowFrom[]E.164 whitelist applied to inboundPolicy: "allowlist".
maxDurationSeconds300Absolute per-call duration limit, applied no matter whether the call is answered.
staleCallReaperSeconds120Check Stale call reaper. Setting 0 turns it off.
silenceTimeoutMs800Silence detection at end of speech for the legacy (non-realtime) flow.
transcriptTimeoutMs180000Maximum time to await a caller transcript before abandoning a turn.
ringTimeoutMs30000Ring timeout for calls placed outbound.
maxConcurrentCalls1Outbound calls exceeding this threshold are blocked.
outbound.notifyHangupDelaySec3Delay in seconds after TTS before auto-disconnect in notify mode.
skipSignatureVerificationfalseFor local testing only, never activate in production.
storeunsetReplaces the standard $OPENCLAW_STATE_DIR/voice-calls location, which is normally ~/.openclaw/voice-calls.
agentId"main"Agent responsible for response generation and session persistence.
responseModelunsetReplaces the default model for classic (non-realtime) responses.
responseSystemPromptgeneratedCustom system prompt used for classic responses.
responseTimeoutMs30000Timeout for generating classic responses, measured in milliseconds.

Twilio's default REST endpoint is US1. For calls handled in a supported non-US Region, configure twilio.region as ie1 or au1 and supply credentials from that Region. Details are in Twilio's non-US REST API guide.

Provider exposure and security notes

  • Twilio, Telnyx, and Plivo all demand a webhook URL that is accessible from the public internet.
  • As a local development provider, mock makes no network requests.
  • Unless skipSignatureVerification holds true, Telnyx expects either telnyx.publicKey or TELNYX_PUBLIC_KEY.
  • Only for local testing should skipSignatureVerification be used.
  • On the ngrok free plan, assign publicUrl the precise ngrok URL; signature checking is always active.
  • tunnel.allowNgrokFreeTierLoopbackBypass: true permits Twilio webhooks bearing invalid signatures solely when tunnel.provider="ngrok" and serve.bind point to loopback (the ngrok local agent). This is meant for local development only.
  • Free-tier ngrok URLs may shift or introduce interstitial behavior; if publicUrl falls out of sync, Twilio signatures break. For production, a stable domain or a Tailscale funnel is preferable.
  • When the realtime or streaming audio mode is active, Tailscale Serve and Funnel automatically expose the corresponding WebSocket path.
  • tailscale.port chooses the external HTTPS port for both tailscale.mode and the unified tunnel.provider: "tailscale-serve" | "tailscale-funnel". Its default is 443; pick 8443 when a different HTTPS server occupies port 443. Funnel accepts only 443, 8443, or 10000, whereas Serve allows any valid TCP port. Any non-default port shows up in the webhook and realtime stream URLs.

Streaming connection caps

  • streaming.preStartTimeoutMs, which defaults to 5000, closes sockets that never transmit a legitimate start frame.
  • The default of 32 for streaming.maxPendingConnections limits the total number of unauthenticated pre-start sockets.
  • streaming.maxPendingConnectionsPerIp, defaulting to 4, restricts unauthenticated pre-start sockets per source IP.
  • All open media stream sockets, both pending and active, are capped by streaming.maxConnections at its default of 128.

Legacy config migrations

These legacy keys are normalized automatically during config parsing, with a warning that logs the replacement path; the shim disappears in a future release (2026.6.0), so execute openclaw doctor --fix to convert committed config into the canonical form:

  • provider: "log" becomes provider: "mock"
  • twilio.from becomes fromNumber
  • streaming.sttProvider becomes streaming.provider
  • streaming.openaiApiKey becomes streaming.providers.openai.apiKey
  • streaming.sttModel becomes streaming.providers.openai.model
  • streaming.silenceDurationMs becomes streaming.providers.openai.silenceDurationMs
  • streaming.vadThreshold becomes streaming.providers.openai.vadThreshold
  • realtime.agentContext.includeSystemPrompt is dropped, since the generated agent prompt now supplies the realtime context

Session scope

Voice Call defaults to sessionScope: "per-phone", so repeated calls from one caller retain conversation memory across sessions. Set sessionScope: "per-call" when each carrier call should begin with a blank slate, such as reception, booking, IVR, or Google Meet bridge flows where a single phone number might stand in for different meetings.

To direct every call into the main session of the configured agent, set sessionScope: "main". This respects the core session.mainKey setting and falls back to global when core session.scope is "global". Raw call turns then share history with the agent's primary session, so enable this only when that shared context is deliberate.

For per-phone and per-call, Voice Call keeps generated session keys under the configured agent namespace (agent:<agentId>:voice:*). Explicit integration keys, when raw, resolve into that same namespace: a canonical agent:<configuredAgentId>:* key retains its owner and respects core session.mainKey/global-scope aliasing; foreign or malformed agent:* input gets scoped as an opaque key beneath the configured agent; global and unknown stay as global sentinels.

Realtime voice conversations

realtime picks a full-duplex realtime voice provider for live call audio. It operates independently of streaming, which merely routes audio to realtime transcription providers.

Warning

realtime.enabled cannot be used together with streaming.enabled. Choose one audio mode for each call.

Current runtime behavior:

  • Twilio and Telnyx both support realtime.enabled.
  • realtime.provider is not required. When left unset, Voice Call falls back to the first registered realtime voice provider.
  • Bundled realtime voice providers: Google Gemini Live (google) and OpenAI (openai), registered through their provider plugins.
  • Provider-owned raw config is stored under realtime.providers.<providerId>.
  • Voice Call makes the built-in openclaw_end_call realtime tool available on every call. It accepts no arguments or call ID; the active voice bridge binds it to the current call.
  • Voice Call exposes the shared openclaw_agent_consult realtime tool by default. The realtime model can invoke it when the caller requests deeper reasoning, current information, or standard OpenClaw tools.
  • realtime.consultPolicy optionally adds guidance for when the realtime model should call openclaw_agent_consult.
  • realtime.agentContext.enabled is off by default. When turned on, Voice Call inserts a bounded agent identity and a selected workspace-file capsule into the realtime provider instructions at session setup.
  • realtime.fastContext.enabled is off by default. When turned on, Voice Call first checks indexed memory/session context for the consult question and returns authorized snippets to the realtime model within realtime.fastContext.timeoutMs before falling back to the full consult agent only if realtime.fastContext.fallbackToConsult is true. The active memory plugin authorizes session-transcript hits; plugins lacking that capability fail closed for session hits while ordinary memory hits stay available.
  • If realtime.provider points to an unregistered provider, or no realtime voice provider is registered at all, Voice Call logs a warning and skips realtime media instead of failing the entire plugin.
  • inboundPolicy must not be "disabled" when realtime.enabled is true; validateProviderConfig rejects that combination.
  • Consult session keys reuse the stored call session when available, then fall back to the configured sessionScope (per-phone by default, per-call for isolated calls, or main for the configured agent's main session).

Warning

GPT-Live relies on agent delegation rather than native function tools. Its current Voice Call bridge cannot invoke openclaw_end_call or custom realtime.tools. Use an OpenAI GA realtime model or Google Gemini Live when the call needs those controls; selecting GPT-Live does not make them available through delegation.

Hangup detection

Realtime calls typically end when the carrier sends a stream stop event or closes the media WebSocket. If an intermediary fails to forward that close promptly, OpenClaw treats 30 seconds without inbound media as a disconnect, waits a 2-second grace period for media to resume, and then terminates the call.

If the realtime provider ends its session first, OpenClaw also ends the carrier call, including when the provider reports a normal close. This avoids leaving a silent phone connection open after its voice session has finished.

The realtime model can also call openclaw_end_call when the caller asks to hang up. The model must speak any final words before calling the tool: a successful call ends the current provider session and phone connection immediately, so no later reply is spoken. If the carrier cannot end the call, the bridge stays connected and the model receives an error it can explain to the caller. Configured realtime.tools cannot replace this built-in by name.

For inbound Twilio numbers, also configure a Status Callback using POST to your public webhook URL with ?type=status appended, for example https://voice.example.com/voice/webhook?type=status. Include the completed call event. OpenClaw-created outbound calls configure their callback automatically. The callback provides the fastest teardown signal, while stream close and the inactivity backstop remain independent of it.

Tool policy

realtime.toolPolicy controls only the consult run. It never disables openclaw_end_call:

PolicyBehavior
safe-read-onlyExpose the consult tool and limit the regular agent to read, web_search, web_fetch, x_search, memory_search, and memory_get.
ownerExpose the consult tool and let the regular agent use the normal agent tool policy.
noneDo not expose the consult tool. The built-in end-call tool and custom realtime.tools remain available.

realtime.consultPolicy controls only the realtime model instructions:

PolicyGuidance
autoLeave the prompt as shipped and let the provider choose when to invoke the consult tool.
substantiveHandle routine conversational filler directly, and consult only when facts, memory, tools, or context come into play.
alwaysRun a consult ahead of any answer that carries real substance.

Agent voice context

Turn on realtime.agentContext when the voice bridge should mimic the configured OpenClaw agent's voice without incurring a full agent-consult round trip on everyday turns. The context capsule gets injected once, at the moment the realtime session is created, so per-turn latency stays flat. Invoking openclaw_agent_consult still triggers the complete OpenClaw agent and remains the right choice for tool operations, fresh data, memory queries, or workspace details.

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          agentId: "main",
          realtime: {
            enabled: true,
            provider: "google",
            toolPolicy: "safe-read-only",
            consultPolicy: "substantive",
            agentContext: {
              enabled: true,
              maxChars: 6000,
              includeIdentity: true,
              includeWorkspaceFiles: true,
              files: ["SOUL.md", "IDENTITY.md", "USER.md"],
            },
          },
        },
      },
    },
  },
}

Realtime provider examples

Google Gemini Live

Presets: API key pulled from realtime.providers.google.apiKey, GEMINI_API_KEY, or GOOGLE_API_KEY; model gemini-3.1-flash-live-preview; voice Kore. sessionResumption and contextWindowCompression are switched on by default to support longer, reconnectable sessions. Tune silenceDurationMs, startSensitivity, and endSensitivity for snappier turn-taking on telephony audio.

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          provider: "twilio",
          inboundPolicy: "allowlist",
          allowFrom: ["+15550005678"],
          realtime: {
            enabled: true,
            provider: "google",
            instructions: "Speak briefly. Call openclaw_agent_consult before using deeper tools.",
            toolPolicy: "safe-read-only",
            consultPolicy: "substantive",
            consultThinkingLevel: "low",
            consultFastMode: true,
            agentContext: { enabled: true },
            providers: {
              google: {
                apiKey: "${GEMINI_API_KEY}",
                model: "gemini-3.1-flash-live-preview",
                speakerVoice: "Kore",
                silenceDurationMs: 500,
                startSensitivity: "high",
              },
            },
          },
        },
      },
    },
  },
}

OpenAI

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          realtime: {
            enabled: true,
            provider: "openai",
            providers: {
              openai: { apiKey: "${OPENAI_API_KEY}" },
            },
          },
        },
      },
    },
  },
}

Provider-specific realtime voice settings live in the Google provider and OpenAI provider docs.

Streaming transcription

streaming links Twilio Media Streams to a realtime transcription provider. The classic streaming route demands provider: "twilio"; setups using Telnyx, Plivo, or mock get rejected. Telnyx live audio instead follows the separately authenticated realtime.enabled path.

How it behaves at runtime:

  • streaming.provider is not required. Leave it unset and Voice Call picks the first registered realtime transcription provider.
  • Realtime transcription providers shipped with the bundle: Deepgram (deepgram), ElevenLabs (elevenlabs), Mistral (mistral), OpenAI (openai), and xAI (xai), each registered through its provider plugin.
  • Provider-owned raw config sits under streaming.providers.<providerId>.
  • Once Twilio delivers an accepted stream start message, Voice Call registers the stream right away, queues inbound media through the transcription provider while that provider connects, and holds the opening greeting until realtime transcription is ready.
  • When streaming.provider names a provider that is not registered, or no provider is registered at all, Voice Call logs a warning and skips media streaming rather than taking down the whole plugin.

Streaming provider examples

OpenAI

Presets: API key streaming.providers.openai.apiKey or OPENAI_API_KEY; model gpt-4o-transcribe; silenceDurationMs: 800; vadThreshold: 0.5.

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          streaming: {
            enabled: true,
            provider: "openai",
            streamPath: "/voice/stream",
            providers: {
              openai: {
                apiKey: "sk-...", // optional if OPENAI_API_KEY is set
                model: "gpt-4o-transcribe",
                silenceDurationMs: 800,
                vadThreshold: 0.5,
              },
            },
          },
        },
      },
    },
  },
}

xAI

Presets: API key streaming.providers.xai.apiKey or XAI_API_KEY (an xAI OAuth auth profile is used if neither key exists); endpoint wss://api.x.ai/v1/stt; encoding mulaw; sample rate 8000; endpointingMs: 800; interimResults: true.

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          streaming: {
            enabled: true,
            provider: "xai",
            streamPath: "/voice/stream",
            providers: {
              xai: {
                apiKey: "${XAI_API_KEY}", // optional if XAI_API_KEY is set
                endpointingMs: 800,
                language: "en",
              },
            },
          },
        },
      },
    },
  },
}

TTS for calls

For call speech streaming, Voice Call relies on the core tts configuration. A plugin-level override is available using the same shape, and it deep-merges with tts.

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

Warning

Microsoft speech does not apply to voice calls. Telephony synthesis needs a provider capable of telephony-target output; the Microsoft speech provider lacks that capability, so it gets skipped for calls and the fallback chain tries other providers instead.

Behavior notes:

  • Plugin configuration that still carries legacy tts.<provider> keys (openai, elevenlabs, microsoft, edge) gets fixed up by openclaw doctor --fix; the config you commit should rely on tts.providers.<provider> instead.
  • With Twilio media streaming enabled, the built-in TTS engine handles speech; otherwise, calls switch to the voice capabilities offered by the provider itself.
  • When a Twilio media stream is already running, Voice Call will not drop back to TwiML <Say>. If telephony TTS is not available in that situation, the playback request errors out rather than merging two separate playback routes.
  • A fallback to a secondary provider for telephony TTS triggers a warning from Voice Call that lists the provider chain (from, to, attempts) to aid troubleshooting.
  • If Twilio barge-in or stream teardown wipes the pending TTS queue, queued playback requests resolve instead of leaving callers stuck waiting for playback to finish.

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

The default for inbound policy is disabled. To accept inbound calls, configure:

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

Warning

inboundPolicy: "allowlist" acts as a caller-ID screen with limited assurance. The plugin normalizes the From value supplied by the provider and checks it against allowFrom. Webhook verification confirms that the provider delivered the payload and that it stayed intact, yet it does not establish ownership of the PSTN/VoIP caller number. View allowFrom as caller-ID filtering rather than strong caller identity.

Auto-responses rely on the agent system. Adjust behavior with responseModel, responseSystemPrompt, and responseTimeoutMs.

Per-number routing

Deploy numbers when a single Voice Call plugin handles calls for several phone numbers and each number needs to act like its own line. As an example, one number might offer a casual personal assistant while another uses a business persona, a separate response agent, and a distinct TTS voice.

The provider-supplied dialed To number determines which route gets selected. Keys must be E.164 numbers. On an incoming call, Voice Call resolves the matching route a single time, saves that route on the call record, and applies that effective config to the greeting, the classic auto-response path, the realtime consult path, and TTS playback. When no route matches, the global Voice Call config applies. Outbound calls bypass numbers; provide the outbound target, message, and session explicitly at call initiation.

Route overrides currently cover:

  • inboundGreeting
  • tts
  • agentId
  • responseModel
  • responseSystemPrompt
  • responseTimeoutMs

The tts route value deep-merges over the global Voice Call tts config, which means you can typically override just 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 adds a strict spoken-output contract to the system prompt that demands a {"spoken":"..."} JSON reply. Voice Call extracts speech text with defensive handling:

  • Skips payloads flagged as reasoning or error content.
  • Handles direct JSON, fenced JSON, or inline "spoken" keys.
  • Falls back to plain text and drops likely planning or meta lead-in paragraphs.

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

Conversation startup behavior

For outbound conversation calls, first-message handling depends on the live playback state:

  • Barge-in queue clear and auto-response are held back only while the initial greeting is actively speaking.
  • If initial playback errors, the call returns to listening and the initial message stays queued for another attempt.
  • Initial playback for Twilio streaming begins on stream connect without any added delay.
  • Barge-in stops active playback and clears Twilio TTS entries that are queued but not yet playing. Cleared entries resolve as skipped, so follow-up response logic can move on without waiting for audio that will never play.
  • Realtime voice conversations use the realtime stream's own opening turn. Voice Call does not send a legacy <Say> TwiML update for that initial message, so outbound <Connect><Stream> sessions remain attached.

Twilio stream disconnect grace

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

  • If the stream reconnects within that window, auto-end gets canceled.
  • If no stream re-registers after the grace period, the call ends to avoid stuck active calls.
  • Realtime bridge and session resources, queued audio, transcript ownership, and in-flight consult work close right away. Only call and provider finalization waits for reconnect.

Stale call reaper

Apply staleCallReaperSeconds (default 120) to terminate calls that are never answered and never reach a live conversation state, such as notify-mode calls where the provider never sends a terminal webhook. Set it to 0 to turn it off.

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 touched by this timer; maxDurationSeconds (default 300) is the separate cap that ends answered calls that go on too long.

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

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

Webhook security

When a proxy or tunnel sits in front of the Gateway, the plugin rebuilds the public URL for signature verification. These options decide which forwarded headers are trusted:

  • webhookSecurity.allowedHosts (string[]), Allowlist hosts from forwarding headers.

  • webhookSecurity.trustForwardingHeaders (boolean), Accept forwarded headers without requiring an allowlist.

  • webhookSecurity.trustedProxyIPs (string[]), Forwarded headers are only trusted when the request's remote IP appears in this list.

Extra safeguards:

  • Replay protection for webhooks is active on Twilio, Telnyx, and Plivo. Valid webhook requests that are replayed get acknowledged, but their side effects are skipped.
  • Each Twilio conversation turn embeds a unique token in <Gather> callbacks, preventing stale or replayed speech callbacks from fulfilling a newer pending transcript turn.
  • If the required signature headers are absent, unauthenticated webhook requests are rejected before the body is read, per provider requirements.
  • The voice-call webhook relies on the shared pre-auth body-read profile (max body 64 KB, read timeout 5 seconds) and a per-key in-flight limit (8 concurrent requests per key by default) before signature checks run.

Example using a stable public host:

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          publicUrl: "https://voice.example.com/voice/webhook",
          webhookSecurity: {
            allowedHosts: ["voice.example.com"],
          },
        },
      },
    },
  },
}

CLI

openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw"
openclaw voicecall start --to "+15555550123"   # alias for call
openclaw voicecall continue --call-id <id> --message "Any questions?"
openclaw voicecall speak --call-id <id> --message "One moment"
openclaw voicecall dtmf --call-id <id> --digits "ww123456#"
openclaw voicecall end --call-id <id>
openclaw voicecall status --call-id <id>
openclaw voicecall tail
openclaw voicecall latency                      # summarize turn latency from logs
openclaw voicecall expose --mode funnel

When the Gateway is up, operational voicecall commands hand off to the Gateway-owned voice-call runtime, so the CLI avoids binding a second webhook server. If no Gateway is available, these commands fall back to a standalone CLI runtime.

latency pulls calls.jsonl from the default voice-call storage path. Point to a different log with --file <path>, or use --last <n> to restrict analysis to the last N records (200 by default). The output reports min/max/avg, p50, and p95 for turn latency and listen-wait times.

Agent tool

Tool name: voice_call.

ActionArgs
initiate_callmessage, to?, mode?, dtmfSequence?
continue_callcallId, message
speak_to_usercallId, message
send_dtmfcallId, digits
end_callcallId
get_statuscallId

A matching agent skill is included with the voice-call plugin.

Gateway RPC

MethodArgsNotes
voicecall.initiateto?, message, mode?, sessionKey?, requesterSessionKey?When to is absent, the toNumber configuration is used as a fallback.
voicecall.startto, message?, mode?, dtmfSequence?, sessionKey?Behaves like initiate, with the extra ability to accept dtmfSequence before the connection is established.
voicecall.continuecallId, messageWaits for the turn to finish, then hands back the transcript.
voicecall.continue.startcallId, messageNon-blocking counterpart: an operationId is returned right away.
voicecall.continue.resultoperationIdChecks a pending voicecall.continue.start operation for its outcome.
voicecall.speakcallId, messageTalks without waiting, and relies on the realtime bridge when realtime.enabled is set.
voicecall.dtmfcallId, digits
voicecall.endcallId
voicecall.statuscallId?Leave out callId to get every active call.

dtmfSequence works only in combination with mode: "conversation"; for notify-mode calls, use voicecall.dtmf once the call is up if post-connect digits are required.

Troubleshooting

Setup fails webhook exposure

Execute setup in the same environment that hosts the Gateway:

openclaw voicecall setup
openclaw voicecall setup --json

For twilio, telnyx, and plivo, webhook-exposure has to be green. Even a properly set publicUrl will fail if it targets local or private address space, since the carrier cannot reach back into those networks. Avoid localhost, 127.0.0.1, 0.0.0.0, 10.x, 172.16.x-172.31.x, 192.168.x, 169.254.x, fc00::/7, fd00::/8, and other carrier-grade-NAT ranges when assigning publicUrl.

Twilio notify-mode outbound calls embed their initial <Say> TwiML directly within the create-call request, so the first spoken message never relies on Twilio retrieving webhook TwiML. Even so, a publicly reachable webhook remains necessary for status callbacks, conversation calls, pre-connect DTMF, realtime streams, and post-connect call control.

Expose a single public path:

{
  plugins: {
    entries: {
      "voice-call": {
        config: {
          publicUrl: "https://voice.example.com/voice/webhook",
          // or
          tunnel: { provider: "ngrok" },
          // or
          tailscale: { mode: "funnel", port: 8443, path: "/voice/webhook" },
        },
      },
    },
  },
}

Once the configuration changes are applied, restart or reload the Gateway and then execute:

openclaw voicecall setup
openclaw voicecall smoke

Unless you supply --yes, voicecall smoke only performs a dry run.

Provider credentials fail

Verify which provider is selected and which credential fields are mandatory:

  • Twilio: twilio.accountSid, twilio.authToken, and fromNumber, or TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER.
  • Telnyx: telnyx.apiKey, telnyx.connectionId, telnyx.publicKey, and fromNumber, or TELNYX_API_KEY, TELNYX_CONNECTION_ID, and TELNYX_PUBLIC_KEY.
  • Plivo: plivo.authId, plivo.authToken, and fromNumber, or PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN.

The credentials have to live on the Gateway host itself. Editing a local shell profile will not affect a Gateway that is already running until that Gateway restarts or reloads its environment.

Calls start but provider webhooks do not arrive

Make sure the provider console points at the precise public webhook URL:

https://voice.example.com/voice/webhook

For an inbound Twilio number, set up both number-level callbacks inside the Twilio Console:

  • Voice webhook: https://voice.example.com/voice/webhook with POST.
  • Status Callback: https://voice.example.com/voice/webhook?type=status with POST.

The primary auto-end mechanism is the Media Streams stop/WebSocket close handling, which operates independently of the HTTP status callback. Twilio's optional <Stream statusCallback> serves as a separate stream-diagnostic signal and is not needed for teardown. openclaw voicecall setup checks local configuration and webhook exposure; it has no ability to inspect or modify Twilio Console settings.

After that, look at the runtime state:

openclaw voicecall status --call-id <id>
openclaw voicecall tail
openclaw logs --follow

Typical causes:

  • publicUrl does not line up with the public webhook URL registered with the provider. A reverse proxy might map that public path to a different serve.path, but publicUrl has to stay as the provider-facing URL.
  • The tunnel URL changed after the Gateway started.
  • A proxy forwards the request but drops or rewrites host/proto headers.
  • Firewall or DNS sends the public hostname somewhere other than the Gateway.
  • The Gateway restarted without the Voice Call plugin enabled.

When a reverse proxy or tunnel sits in front of the Gateway, point webhookSecurity.allowedHosts at the public hostname, or use webhookSecurity.trustedProxyIPs for a proxy address you already know. Only rely on webhookSecurity.trustForwardingHeaders when the proxy boundary is fully within your control.

Signature verification fails

When it is configured, publicUrl is used for Twilio and Plivo URL signatures: its scheme, host, and path stay intact, while the request query gets applied. Without publicUrl, OpenClaw rebuilds the URL from the request. Telnyx signatures omit the request URL entirely. If signatures are failing:

  • Check that the provider webhook URL matches publicUrl exactly, including scheme, host, and path.
  • For ngrok free-tier URLs, refresh publicUrl whenever the tunnel hostname changes.
  • Make sure the proxy keeps the original host and proto headers, or set webhookSecurity.allowedHosts.
  • Do not turn on skipSignatureVerification except during local testing.

Google Meet Twilio joins fail

Google Meet relies on this plugin for Twilio dial-in joins. Start by verifying Voice Call:

openclaw voicecall setup
openclaw voicecall smoke --to "+15555550123"

Then explicitly verify the Google Meet transport:

openclaw googlemeet setup --transport twilio

If Voice Call shows green but the Meet participant never shows up, inspect the Meet dial-in number, PIN, and --dtmf-sequence. The phone leg can be perfectly healthy while the meeting rejects or ignores an incorrect DTMF sequence.

Google Meet launches the Twilio phone leg through voicecall.start with a pre-connect DTMF sequence. PIN-derived sequences include the Google Meet plugin's voiceCall.dtmfDelayMs (default 12000 ms) as leading Twilio wait digits, since Meet dial-in prompts can arrive late. Voice Call then switches back to realtime handling before the intro greeting is requested.

Use openclaw logs --follow for the live phase trace. A healthy Twilio Meet join logs this sequence:

  • Google Meet hands the Twilio join off to Voice Call.
  • Voice Call saves pre-connect DTMF TwiML.
  • Twilio initial TwiML is consumed and served before realtime handling.
  • Voice Call serves realtime TwiML for the Twilio call.
  • Google Meet asks for intro speech with voicecall.speak after the post-DTMF delay.

openclaw voicecall tail still shows persisted call records; useful for call state and transcripts, but not every webhook/realtime transition appears there.

Realtime call has no speech

Ensure only one audio mode is active: realtime.enabled and streaming.enabled cannot both be true.

For realtime Twilio/Telnyx calls, also confirm:

  • The realtime provider plugin must be loaded and registered.
  • realtime.provider remains unset, or it points to a provider that has been registered.
  • The Gateway process has access to the provider's API key.
  • openclaw logs --follow indicates that realtime TwiML is being served, the realtime bridge has launched, and the opening greeting is in the queue.
5,442 words · updated Sep 1, 2026