This is a practical setup guide for getting inbound phone calls working with the built-in OpenClaw voice-call plugin, Twilio, ngrok, and OpenAI realtime voice.
The working flow is:
Caller
-> Twilio phone number
-> OpenClaw voice-call webhook
-> OpenClaw voice-call plugin
-> OpenAI realtime voice
-> Twilio media stream
-> Caller hears the agent
This guide is for the OpenClaw-native voice-call path. It is not the same thing as putting an ElevenLabs Agent in front of OpenClaw.
-
OpenClaw installed and running.
-
A Twilio account.
-
A Twilio phone number with voice capability.
-
An OpenAI API key with realtime model access.
-
A public HTTPS tunnel to your local machine, such as ngrok.
sequenceDiagram
participant Caller
participant Twilio as Twilio Phone Number
participant Ngrok as ngrok publicUrl<br/>https://YOUR-NGROK-DOMAIN.ngrok-free.dev
participant OC as OpenClaw voice-call plugin<br/>localhost:3334
participant OpenAI as OpenAI Realtime
Caller->>Twilio: Places phone call
Twilio->>Ngrok: POST /voice/webhook
Note over Ngrok: publicUrl + path
Ngrok->>OC: POST /voice/webhook
OC-->>Twilio: TwiML with WebSocket URL
Twilio->>Ngrok: WSS /voice/stream/realtime/{callId}
Note over Ngrok: publicUrl + streamPath
Ngrok->>OC: WebSocket /voice/stream/realtime/{callId}
OC->>OpenAI: Realtime audio session
OpenAI-->>OC: Realtime audio response
OC-->>Twilio: Audio over media stream
Twilio-->>Caller: Agent voice
| JSON value | Meaning | Used by |
|---|---|---|
webhook.publicUrl |
Public HTTPS base URL for your local voice-call server | Twilio reaches this through ngrok |
webhook.path |
HTTP webhook path for inbound call setup | Twilio sends POST <publicUrl><path> |
realtime.streamPath |
WebSocket path for live call audio | OpenClaw returns wss://...<streamPath>/<callId> in TwiML |
webhook.port |
Local port where OpenClaw voice-call listens | ngrok forwards public traffic to this port |
In openclaw.json, configure the voice-call plugin under:
{
"plugins": {
"entries": {
"voice-call": {
"enabled": true,
"config": {}
}
}
}
}A working realtime-style configuration looks like this:
{
"plugins": {
"entries": {
"voice-call": {
"enabled": true,
"config": {
"provider": "twilio",
"webhook": {
"port": 3334,
"path": "/voice/webhook",
"publicUrl": "https://YOUR-NGROK-DOMAIN.ngrok-free.dev"
},
"twilio": {
"accountSid": "YOUR_TWILIO_ACCOUNT_SID",
"authToken": "YOUR_TWILIO_AUTH_TOKEN",
"phoneNumber": "+15555550123"
},
"inbound": {
"enabled": true,
"mode": "allowlist",
"allowlist": [
"+15555550199"
]
},
"streaming": {
"enabled": false
},
"realtime": {
"enabled": true,
"provider": "openai",
"streamPath": "/voice/stream/realtime",
"providers": {
"openai": {
"apiKey": "YOUR_OPENAI_API_KEY",
"model": "gpt-realtime-1.5",
"voice": "alloy",
"silenceDurationMs": 500,
"vadThreshold": 0.5
}
}
},
"responseModel": "anthropic/claude-sonnet-4-6"
}
}
}
}
}Notes:
publicUrlshould be the base public URL, not the full webhook path.- Twilio should point to
<publicUrl>/voice/webhook. - The voice-call webhook server is separate from the main OpenClaw gateway dashboard.
- The example uses port
3334for the voice-call webhook server. - Prefer
silenceDurationMs; do not usesilenceDurationSeconds. - Keep secrets out of git, gists, screenshots, and logs.
Forward ngrok to the voice-call plugin port:
ngrok http 3334Copy the HTTPS forwarding URL, for example:
https://YOUR-NGROK-DOMAIN.ngrok-free.dev
Put that base URL in webhook.publicUrl.
In the Twilio Console, open your phone number's voice settings.
Set the inbound voice webhook to:
https://YOUR-NGROK-DOMAIN.ngrok-free.dev/voice/webhook
Use:
HTTP POST
Validate the config:
openclaw config validateRestart the daemon:
openclaw daemon restartThen confirm the voice-call server is listening:
lsof -nP -iTCP -sTCP:LISTEN | rg ':(3334|18789|4040)\b|ngrok|node'You want to see:
- OpenClaw gateway on
18789. - OpenClaw voice-call plugin on
3334. - ngrok inspector on
4040, if ngrok is running locally.
Tail the logs:
tail -F ~/.openclaw/logs/gateway.log ~/.openclaw/logs/gateway.err.logOn startup, look for lines like:
[voice-call] Webhook server listening on http://127.0.0.1:3334/voice/webhook
[plugins] [voice-call] Realtime voice provider: openai
[plugins] [voice-call] Runtime initialized
On an inbound call, look for:
[voice-call] Inbound call accepted
[voice-call] Created inbound call record
ngrok's inspector should also show:
- A
POST /voice/webhookreturning TwiML. - A WebSocket upgrade to
/voice/stream/realtime/...returning HTTP101 Switching Protocols.
You can inspect ngrok requests at:
http://127.0.0.1:4040
or via API:
curl -sS http://127.0.0.1:4040/api/requests/httpIf calls do not work, check these in order:
- Is OpenClaw running?
- Is the voice-call plugin enabled?
- Is the voice-call plugin listening on the port you are forwarding with ngrok?
- Is ngrok forwarding to the voice-call port, not the main OpenClaw gateway port?
- Does
webhook.publicUrlexactly match the current ngrok HTTPS base URL? - Does Twilio point to
<publicUrl>/voice/webhookwith methodPOST? - Is the caller's phone number allowed by your inbound allowlist?
- Does ngrok show Twilio hitting
/voice/webhook? - Does ngrok show a WebSocket upgrade to
/voice/stream/realtime/...? - Do OpenClaw logs show the realtime provider initialized?
- Does your OpenAI API key have access to the configured realtime model?
Check:
- Twilio webhook URL.
- ngrok is still running.
- ngrok URL has not changed.
- Twilio is using
POST.
Check:
- inbound allowlist mode.
- caller number format, usually E.164 like
+15555550199. - Twilio signature validation and
publicUrl.
Check ngrok for the WebSocket upgrade. The webhook can succeed while the media stream fails.
For realtime mode, the stream URL should look like:
wss://YOUR-NGROK-DOMAIN.ngrok-free.dev/voice/stream/realtime/...
If using the older split streaming setup, you may see errors like:
Telephony TTS synthesis timed out
OpenAI realtime transcription connection timeout
In that case, try the simpler realtime provider path:
{
"streaming": {
"enabled": false
},
"realtime": {
"enabled": true,
"provider": "openai"
}
}In testing, the OpenAI realtime path was lower friction and gave a fast, natural response.
The baseline that worked:
- Twilio inbound voice webhook pointed at
/voice/webhook. - ngrok forwarded to local port
3334. - OpenClaw
voice-callplugin used realtime mode. streaming.enabledwasfalse.realtime.enabledwastrue.realtime.providerwasopenai.realtime.streamPathwas/voice/stream/realtime.- OpenAI realtime model was
gpt-realtime-1.5.
- Do not paste real API keys into public examples.
- Do not publish Twilio auth tokens.
- Do not publish your personal phone number or caller allowlist.
- For a stable setup, use a fixed public domain or a reserved ngrok domain. Free ngrok URLs may change.
- If your public URL changes, update both OpenClaw
webhook.publicUrland the Twilio webhook URL.
If you started ngrok manually, it probably will not keep running after your computer restarts.
After a restart, check:
lsof -nP -iTCP -sTCP:LISTEN | rg ':(3334|4040)\b|ngrok|node'If ngrok is not running, start it again:
ngrok http 3334If the ngrok public URL changed, update both places:
openclaw.json: webhook.publicUrl
Twilio Console: Voice webhook URL
For a durable setup, use a reserved/static ngrok domain and run ngrok from your system's startup manager, such as a macOS LaunchAgent.
Great write up.
Trying to get STT/TTS working, and have no idea what to use.
What is the path of least resistance?
What is the path of least cost?
Can you say a few words about how to do this?
Also, why is this needed: