PraisonAI Call

Guide to PraisonAI's voice-based interaction feature enabling AI customer service through phone calls, including setup and tool integration

Turn an Agent into a phone assistant — start the call server and connect a phone number to talk to it over the line.

bash
export OPENAI_API_KEY="${OPENAI_API_KEY:?Set OPENAI_API_KEY in your shell}"
export NGROK_AUTH_TOKEN="${NGROK_AUTH_TOKEN:?Set NGROK_AUTH_TOKEN in your shell}"
export PRAISONAI_CALL_PUBLIC_BASE="${PRAISONAI_CALL_PUBLIC_BASE:?Set to the wss:// host Twilio will connect to, e.g. wss://your-app.ngrok.io}"
praisonai call --public

When --public prints the ngrok URL, copy the wss:// variant back into PRAISONAI_CALL_PUBLIC_BASE before dialling in — the server refuses to derive it from the request Host header.

Diagram

PraisonAI Call

📞 Caller

🤖 Call Server

🧠 Agent

✅ Voice Reply

AI Customer Service

PraisonAI Call is a feature that enables voice-based interaction with AI models through phone calls. This functionality allows users to have natural conversations with AI agents over traditional phone lines.

Installation

Step 1

bash
pip install "praisonai[call]"
export OPENAI_API_KEY="${OPENAI_API_KEY:?Set OPENAI_API_KEY in your shell}"
export NGROK_AUTH_TOKEN="${NGROK_AUTH_TOKEN:?Set NGROK_AUTH_TOKEN in your shell}"
export PRAISONAI_CALL_PUBLIC_BASE="${PRAISONAI_CALL_PUBLIC_BASE:?Set to the wss:// host Twilio will connect to, e.g. wss://your-app.ngrok.io}"
praisonai call --public

Step 2

Buy a number at PraisonAI Dashboard

Step 3

Enter the Public URL in the PraisonAI Dashboard phone number field

Authentication

The PraisonAI Call server now requires authentication configuration for security. You have two options:

Option 1: Token Authentication (Recommended)

Set a secure token for API authentication:

bash
export CALL_SERVER_TOKEN="${CALL_SERVER_TOKEN:?Set CALL_SERVER_TOKEN in your shell}"
praisonai call --public

API requests must include the token in the Authorization header:

bash
curl -H "Authorization: Bearer ${CALL_SERVER_TOKEN:?Set CALL_SERVER_TOKEN in your shell}" \
     -H "Content-Type: application/json" \
     -d '{"message": "Hello"}' \
     http://localhost:8090/api/v1/agents/assistant/invoke

To make retries safe, generate a key once (KEY=$(uuidgen)), send -H "Idempotency-Key: $KEY", and reuse it on every retry — a duplicate returns 409 instead of running the agent again. See n8n API → Safe Retries.

Option 2: Disable Authentication (Local Development Only)

For local development on your own machine, you can disable authentication. The CLI sets the bind-host env var for you, so this is enough when launching via praisonai call:

bash
export PRAISONAI_CALL_AUTH=disabled
praisonai call

If you launch the server programmatically (custom uvicorn, embedding praisonai.api.agent_invoke in another app, or any path that does NOT go through praisonai call / praisonai serve), you must also pin the bind host:

bash
export PRAISONAI_CALL_AUTH=disabled
export PRAISONAI_CALL_BIND_HOST=127.0.0.1

Without PRAISONAI_CALL_BIND_HOST set to a localhost value, every request is rejected with:

503 Service Unavailable
PRAISONAI_CALL_AUTH=disabled is only permitted for localhost binding;
set PRAISONAI_CALL_BIND_HOST to 127.0.0.1 when binding locally

Authentication Flow

Diagram

Yes

No

Yes

Yes

No

No

Yes

No

📝 Request

🔐 Has Bearer Token?

✅ 200 Success

⚙️ Auth Disabled?

🏠 Bind host is localhost?

❌ 503 Service Unavailable

🔧 Token Configured?

❌ 401 Unauthorized

When CALL_SERVER_TOKEN is not configured and the environment is not development, the server returns:

503 Service Unavailable
CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or PRAISONAI_CALL_AUTH=disabled to run without authentication.

When PRAISONAI_CALL_AUTH=disabled is set but the bind host is not localhost:

503 Service Unavailable
PRAISONAI_CALL_AUTH=disabled is only permitted for localhost binding;
set PRAISONAI_CALL_BIND_HOST to 127.0.0.1 when binding locally

Environment Variables

Env varRequired whenEffect
CALL_SERVER_TOKENProduction / any non-local exposureServer requires Authorization: Bearer <token>
PRAISONAI_CALL_AUTH=disabledLocal development onlyBypasses auth — only honored if PRAISONAI_CALL_BIND_HOST is localhost
PRAISONAI_CALL_BIND_HOSTWhenever you launch without the praisonai call/praisonai serve CLIDeclares the real bind host so the auth-disabled guard can verify it
PRAISONAI_CALL_PUBLIC_BASEAny deployment terminating a Twilio inbound callws:///wss:// base URL used to build the outbound /media-stream?session=… URL handed to Twilio. Server returns 503 on /incoming-call if unset. See Public base URL for Twilio media-stream.
PRAISONAI_CALL_LOAD_DOTENVOnly to restore legacy import-time .env loadingWhen "true" (case-insensitive), importing praisonai.api.call loads .env from the current working directory at import time. Unset by default — importing the module no longer touches os.environ. The CLI (praisonai call) always loads .env from the CWD at run time regardless of this flag.
PRAISONAI_ALLOW_LOCAL_TOOLSAny call-server verb that should honour ./tools.pyServer entry points call _load_local_tools(); unset → the tools registry stays empty even if build_call_app(load_local_tools=True) is passed. See Local Tools Loading.

Side-effect-free import + build_call_app()

As of PR #4261, import praisonai.api.call is side-effect-free: no FastAPI app is built, no websockets/twilio/uvicorn/pyngrok/rich/fastapi is imported, no ./tools.py filesystem scan runs, and no user code executes. The heavy deps are lazy-imported inside build_call_app(), the route handlers, run_server(), and setup_public_url().

Diagram
run_server()_load_local_tools_into(state)app.state.call_statebuild_call_app()import praisonai.api.callrun_server()_load_local_tools_into(state)app.state.call_statebuild_call_app()import praisonai.api.callno app, no heavy imports, no tools scanbuild_call_app(load_local_tools=True)create CallAppState (tools=[], pending_sessions={})load ./tools.py into state.tools (if PRAISONAI_ALLOW_LOCAL_TOOLS=true)state.tools populatedapp ready for uvicorn.run(app)

Build the app yourself when embedding the call server in your own process — a build_call_app(*, load_local_tools=False) factory replaces the old top-level app = FastAPI():

python
# embedding.py
from praisonai.api.call import build_call_app

app = build_call_app(load_local_tools=False)  # or True + PRAISONAI_ALLOW_LOCAL_TOOLS=true
# mount under your own FastAPI, or hand to uvicorn.run(app, ...)

The load_local_tools flag is only honoured when PRAISONAI_ALLOW_LOCAL_TOOLS=true is also set, matching the CLI opt-in. Each build_call_app() call creates its own CallAppState (hung off app.state.call_state) holding both the tool registry and the pending one-shot session tokens. Two apps in the same process therefore never share tool schemas or cross-consume each other’s stream tokens.

Point the realtime endpoint elsewhere (Azure / self-hosted)

By default the call server connects the Twilio media leg to OpenAI Realtime at wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01 using OPENAI_API_KEY. Teams on Azure OpenAI, OpenRouter, or a self-hosted OpenAI-compatible realtime gateway can override the endpoint without editing the module.

Env varPurpose
PRAISONAI_REALTIME_URLFull wss://… URL. Takes precedence over the default.
PRAISONAI_REALTIME_MODELModel name for the default OpenAI URL (ignored when PRAISONAI_REALTIME_URL is set).
PRAISONAI_REALTIME_API_KEYBearer key to use for the realtime connection. Falls back to OPENAI_API_KEY when unset.
bash
# Point voice at Azure OpenAI Realtime
export PRAISONAI_REALTIME_URL="wss://<your-resource>.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=my-realtime-deployment"
export PRAISONAI_REALTIME_API_KEY="${AZURE_OPENAI_KEY:?Set AZURE_OPENAI_KEY in your shell}"
praisonai call --public
bash
# Stay on OpenAI but pick a different realtime model
export PRAISONAI_REALTIME_MODEL="gpt-4o-realtime-preview-2024-12-17"
praisonai call --public

When PRAISONAI_REALTIME_URL contains openai.com, the server keeps sending the OpenAI-Beta: realtime=v1 header; for any other host, only Authorization: Bearer … is sent.

Diagram

Yes

No

Realtime connect

PRAISONAI_REALTIME_URL?

Use URL as-is
+ auth from PRAISONAI_REALTIME_API_KEY
or OPENAI_API_KEY

Read PRAISONAI_REALTIME_MODEL
default: gpt-4o-realtime-preview-2024-10-01

wss://api.openai.com/v1/realtime?model=…
+ OpenAI-Beta: realtime=v1

Connection timeouts

The realtime WebSocket runs with bounded connect / heartbeat / close so a dead upstream cannot hold a Twilio media leg (and phone number) indefinitely.

SettingValue
open_timeout10 s
ping_interval20 s
ping_timeout20 s
close_timeout5 s
max_size (frame cap)1 MiB

Media-stream session tokens

The Twilio path is unchanged — /incoming-call mints a one-shot, 60-second, single-use session token and embeds it as ?session=<token> in the returned TwiML stream URL. /media-stream validates and consumes it once.

Direct WebSocket clients (bots, tests, custom stream consumers) must authenticate with the x-call-token header:

bash
websocat "wss://your-host/media-stream" \
  --header "x-call-token: ${CALL_SERVER_TOKEN:?Set CALL_SERVER_TOKEN in your shell}"
ClientAuth pathNotes
Twilio (/incoming-call → /media-stream)?session=<one-shot token> minted by /incoming-callTransparent, single-use, 60 s TTL
Direct WebSocketx-call-token: $CALL_SERVER_TOKEN headerConstant-time compare
Direct WebSocket via ?token=…Rejected (4003 Unauthorized)No longer supported

All token comparisons (HTTP Authorization, header x-call-token, n8n verify_token, media-stream session tokens) are constant-time (hmac.compare_digest). Connection-count and per-IP rate-limit counters are guarded by asyncio.Locks, so MAX_CONCURRENT_CONNECTIONS and MAX_REQUESTS_PER_WINDOW cannot be over-committed by concurrent WebSocket opens.

Diagram
/media-streamCall ServerTwilioCaller/media-streamCall ServerTwilioCallerDial numberPOST /incoming-callmint session token (60s TTL, one-shot)TwiML with WS URL + session tokenWebSocket handshake (session token)consume token (constant-time)audio stream

Public base URL for Twilio media-stream

/incoming-call builds the outbound wss://…/media-stream?session=<token> URL from the server-side PRAISONAI_CALL_PUBLIC_BASE env var — never from the request Host header. The old behaviour let an authenticated /incoming-call caller redirect Twilio’s live media leg to any host (SSRF / call-audio exfiltration), so the server now refuses to serve /incoming-call when the env var is unset.

bash
# Production — a real wss:// domain
export PRAISONAI_CALL_PUBLIC_BASE="wss://praison.example.com"
praisonai call --host 0.0.0.0

# ngrok dev — copy the wss:// host ngrok prints
export NGROK_AUTH_TOKEN="${NGROK_AUTH_TOKEN:?Set NGROK_AUTH_TOKEN in your shell}"
export PRAISONAI_CALL_PUBLIC_BASE="wss://your-app.ngrok.io"
praisonai call --public

Validation rules — pick a legal value:

  • Scheme MUST be ws:// or wss:// (not http/https).
  • Host MUST be present (bare wss:// with no host is rejected).
  • Cleartext ws:// is permitted ONLY for local hosts (localhost, 127.0.0.1, ::1, [::1]); ws:// to a non-local host is rejected because it would expose live audio AND the one-shot session token over an unencrypted transport.

Failure modes:

503 Service Unavailable
PRAISONAI_CALL_PUBLIC_BASE is not configured; refusing to derive the media-stream URL from the client Host header.
500 Internal Server Error
PRAISONAI_CALL_PUBLIC_BASE must be a ws:// or wss:// URL
500 Internal Server Error
PRAISONAI_CALL_PUBLIC_BASE must include a host
500 Internal Server Error
PRAISONAI_CALL_PUBLIC_BASE must use wss:// for non-local hosts; cleartext ws:// would expose live audio and the session token
Diagram
/media-streamPRAISONAI_CALL_PUBLIC_BASE/incoming-callTwilio/media-streamPRAISONAI_CALL_PUBLIC_BASE/incoming-callTwilioalt[env unset][env set & valid]POST /incoming-call (auth)read base URLnone503 (refuse to trust Host header)wss://praison.example.commint one-shot session tokenTwiML with wss://praison.example.com/media-stream?session=…connect

Binding & Network Access

The call server binds to 127.0.0.1 by default, so it is only reachable from the same machine.

Diagram

Yes

No

Yes

No

Where will clients connect from?

Same machine only?

✅ Default
praisonai call

Local network?

⚠️ praisonai call –host 0.0.0.0
or bind to a specific NIC
–host 192.168.1.x

🌐 praisonai call –public
(uses ngrok)

bash
# Local development (default — same machine only)
praisonai call

# Specific port, still localhost-only
praisonai call --port 8090

# Expose on LAN (warning will be printed)
praisonai call --host 0.0.0.0 --port 8090

# Expose publicly via ngrok
export NGROK_AUTH_TOKEN="${NGROK_AUTH_TOKEN:?Set NGROK_AUTH_TOKEN in your shell}"
praisonai call --public

Features

  • Make and receive phone calls with AI agents
  • Natural language processing for voice interactions
  • Support for multiple phone carriers and providers
  • Call recording and transcription capabilities
  • Integration with other PraisonAI features

Adding Tools

Declare tools = [...] in your tools.py exactly as before — the call server now loads it into each app’s own state.tools registry rather than a module-global list.

  1. Create a file called tools.py
  2. Add the following code:
python
import yfinance as yf

# Get Stock Price definition
get_stock_price_def = {
    "name": "get_stock_price",
    "description": "Get the current stock price for a given ticker symbol",
    "parameters": {
        "type": "object", 
        "properties": {
            "ticker_symbol": {
                "type": "string", 
                "description": "The ticker symbol of the stock (e.g., AAPL, GOOGL)"
            }
        }, 
        "required": ["ticker_symbol"]
    }
}

# Get Stock Price function / Tool
async def get_stock_price_handler(ticker_symbol):
    try:
        stock = yf.Ticker(ticker_symbol)
        hist = stock.history(period="1d")
        if hist.empty:
            return {"error": f"No data found for ticker {ticker_symbol}"}
        current_price = hist['Close'].iloc[-1]  # Using -1 is safer than 0
        return {"price": str(current_price)}
    except Exception as e:
        return {"error": str(e)}



get_stock_price = (get_stock_price_def, get_stock_price_handler)
tools = [
    get_stock_price
]
bash
pip install yfinance
bash
export OPENAI_API_KEY="${OPENAI_API_KEY:?Set OPENAI_API_KEY in your shell}"
export NGROK_AUTH_TOKEN="${NGROK_AUTH_TOKEN:?Set NGROK_AUTH_TOKEN in your shell}"
praisonai call --public

Manage Google Calendar Events

See Google Calendar Tools

Deploy

Docker Deployment

dockerfile
# Use an official Python runtime as a parent image
FROM python:3.11-slim

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# Set work directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# Install PraisonAI with the 'call' extra and ensure it's the latest version
RUN pip install --no-cache-dir --upgrade "praisonai[call]"

# Expose the port the app runs on
EXPOSE 8090

# The wss:// host Twilio will connect to — the server returns 503 on /incoming-call if unset.
ENV PRAISONAI_CALL_PUBLIC_BASE=wss://your-public-hostname.example.com

# Bind to all container interfaces — the container boundary is the security boundary.
# Pair with CALL_SERVER_TOKEN for any externally-published port.
CMD ["praisonai", "call", "--host", "0.0.0.0"]

How It Works

A caller dials your number, the provider forwards audio to the call server, and the Agent responds with synthesized speech.

Diagram
AgentCall ServerCallerAgentCall ServerCallerPhone call audioTranscribed requestResponse textSynthesized voice reply

Best Practices

Always set CALL_SERVER_TOKEN in production

The server fails with 503 unless a token is set or auth is explicitly disabled for localhost. Never disable auth on a public bind.

Keep the default localhost bind

praisonai call binds to 127.0.0.1 by default. Add --host 0.0.0.0 only inside a container or when you deliberately expose the LAN.

Set secrets via environment variables

Export OPENAI_API_KEY, NGROK_AUTH_TOKEN, and CALL_SERVER_TOKEN in your shell — never inline the raw values.

Use the x-call-token header for direct WebSocket clients

/media-stream no longer accepts ?token= in the query string. Direct callers must pass x-call-token: $CALL_SERVER_TOKEN as a WebSocket handshake header. Twilio flows are unaffected.

Set PRAISONAI_CALL_PUBLIC_BASE to the exact host Twilio will connect to

Never rely on the request Host header — the server refuses to (503). Use wss:// for anything non-local; ws:// is only accepted for localhost/127.0.0.1.

Add tools for real actions

Register async tool handlers (like a stock-price lookup) so the phone agent can fetch live data during a call.

Related