Relay Client

View as MarkdownOpen in Claude

What Is Relay?

Relay is SignalWire’s real-time WebSocket protocol for programmatic call control. While the agent-based approach (AgentBase + SWML) lets SignalWire’s AI handle conversations declaratively, Relay gives you imperative, event-driven control over every aspect of a call.

When to Use Relay vs Agents

ApproachBest For
AgentBase (SWML)AI-driven conversations, voice bots, structured workflows
Relay ClientIVR systems, call routing, call center logic, recording pipelines, custom media flows

Use Relay when you need fine-grained control over call flow — answering, playing prompts, collecting digits, recording, bridging, conferencing — without an AI agent in the loop.

Installation

The Relay client is included in the signalwire-sdk package:

pip install signalwire-sdk

It requires the websockets library (installed automatically as a dependency).

Quick Start

relay_hello.py

#!/usr/bin/env python3
"""Minimal Relay example: answer and play a greeting."""
from signalwire.relay import RelayClient
client = RelayClient(
project="your-project-id",
token="your-api-token",
contexts=["default"],
)
@client.on_call
async def handle_call(call):
await call.answer()
action = await call.play([{"type": "tts", "params": {"text": "Hello from Relay!"}}])
await action.wait()
await call.hangup()
client.run()

Authentication

The Relay client supports two authentication methods:

Project + API Token

LanguageSyntax
PythonRelayClient(project="...", token="...")
TypeScriptnew RelayClient({ project: '...', token: '...' })
client = RelayClient(
project="your-project-id",
token="your-api-token",
)

JWT Token

client = RelayClient(
jwt_token="your-jwt-token",
)

Environment Variables

All credentials can be provided via environment variables instead of constructor arguments:

VariablePurpose
SIGNALWIRE_PROJECT_IDProject ID
SIGNALWIRE_API_TOKENAPI token
SIGNALWIRE_JWT_TOKENJWT token (alternative to project+token)
# With env vars set, no arguments needed
client = RelayClient(contexts=["default"])

Connection Lifecycle

The Relay client manages a persistent WebSocket connection with automatic reconnection:

  1. Connect — Establishes WebSocket to wss://relay.signalwire.com
  2. Authenticate — Sends signalwire.connect with credentials
  3. Subscribe — Registers for events on specified contexts
  4. Event Loop — Processes events until disconnected
  5. Reconnect — Automatic reconnection with exponential backoff (1s to 30s max)

Contexts

Contexts determine which inbound calls your client receives. Pass them in the constructor or subscribe dynamically:

# Subscribe at connection time
client = RelayClient(contexts=["sales", "support"])
# Or subscribe dynamically after connecting
await client.receive(["new-context"])
# Unsubscribe from contexts
await client.unreceive(["old-context"])

Async Context Manager

async with RelayClient(contexts=["default"]) as client:
# Connected and authenticated
call = await client.dial(...)
# Automatically disconnects on exit

Handling Inbound Calls

Register a handler with the @client.on_call decorator:

relay_inbound.py

#!/usr/bin/env python3
"""Handle inbound calls with Relay."""
from signalwire.relay import RelayClient
client = RelayClient(contexts=["ivr"])
@client.on_call
async def handle_call(call):
print(f"Inbound call from {call.device}")
await call.answer()
# Play a menu
action = await call.play([
{"type": "tts", "params": {"text": "Press 1 for sales, 2 for support."}}
])
await action.wait()
# Collect a digit
collect = await call.collect(
digits={"max": 1, "digit_timeout": 5.0},
initial_timeout=10.0,
)
result = await collect.wait()
digit = result.params.get("result", {}).get("digits", "")
if digit == "1":
await call.connect([[{"type": "phone", "params": {"to_number": "+15551001000"}}]])
elif digit == "2":
await call.connect([[{"type": "phone", "params": {"to_number": "+15552002000"}}]])
else:
play = await call.play([{"type": "tts", "params": {"text": "Invalid selection. Goodbye."}}])
await play.wait()
await call.hangup()
client.run()

The Call object provides:

PropertyDescription
call_idUnique call identifier
node_idRelay node handling the call
contextContext the call arrived on
direction"inbound" or "outbound"
deviceDevice info dict (type, params)
stateCurrent call state
tagClient-provided correlation tag

Making Outbound Calls

Use client.dial() to initiate outbound calls:

relay_outbound.py

#!/usr/bin/env python3
"""Make an outbound call with Relay."""
import asyncio
from signalwire.relay import RelayClient
client = RelayClient(contexts=["default"])
async def main():
async with client:
call = await client.dial(
devices=[[{
"type": "phone",
"params": {
"from_number": "+15551234567",
"to_number": "+15559876543",
"timeout": 30,
},
}]],
)
print(f"Call answered: {call.call_id}")
action = await call.play([
{"type": "tts", "params": {"text": "This is an automated message from SignalWire."}}
])
await action.wait()
await call.hangup()
asyncio.run(main())

The devices parameter supports serial and parallel dialing:

# Serial dial: try first, then second
devices = [
[{"type": "phone", "params": {"to_number": "+15551111111", "from_number": "+15550000000"}}],
[{"type": "phone", "params": {"to_number": "+15552222222", "from_number": "+15550000000"}}],
]
# Parallel dial: ring both simultaneously
devices = [
[
{"type": "phone", "params": {"to_number": "+15551111111", "from_number": "+15550000000"}},
{"type": "phone", "params": {"to_number": "+15552222222", "from_number": "+15550000000"}},
],
]

Call Control Methods

Key call control methods across languages:

MethodPython
Answerawait call.answer()
Play TTSawait call.play([{"type": "tts", "params": {"text": "Hello"}}])
Recordawait call.record(audio={...})
Hang upawait call.hangup()
Connectawait call.connect(devices)

Audio Playback

# Play TTS
action = await call.play([{"type": "tts", "params": {"text": "Hello!"}}])
await action.wait()
# Play audio file
action = await call.play([{"type": "audio", "params": {"url": "https://example.com/audio.mp3"}}])
# Control playback
await action.pause()
await action.resume()
await action.volume(5.0) # -40.0 to 40.0 dB
await action.stop()

Recording

# Start recording
action = await call.record(audio={"direction": "both", "format": "mp3"})
# Pause/resume recording
await action.pause()
await action.resume()
# Stop and get result
await action.stop()
result = await action.wait()
url = result.params.get("record", {}).get("url", "")
print(f"Recording URL: {url}")

Input Collection

# Collect digits with prompt
collect = await call.play_and_collect(
media=[{"type": "tts", "params": {"text": "Enter your account number."}}],
collect={
"digits": {
"max": 10,
"digit_timeout": 3.0,
"terminators": "#",
},
},
)
result = await collect.wait()
digits = result.params.get("result", {}).get("digits", "")
# Standalone collect (no prompt)
collect = await call.collect(
digits={"max": 4, "digit_timeout": 5.0},
speech={"end_silence_timeout": 2.0},
)
result = await collect.wait()

Detection (Answering Machine, Fax, DTMF)

# Answering machine detection
detect = await call.detect(
detect={"type": "machine", "params": {"initial_timeout": 4.5}},
timeout=30.0,
)
result = await detect.wait()
machine_result = result.params.get("detect", {})

Bridging / Connecting

# Bridge to another number
await call.connect(
devices=[[{
"type": "phone",
"params": {"to_number": "+15559876543", "from_number": "+15551234567"},
}]],
ringback=[{"type": "ringtone", "params": {"name": "us"}}],
)
# Disconnect (unbridge)
await call.disconnect()

Conference

# Join a conference
await call.join_conference("team-standup", muted=False, beep="true")
# Leave a conference
await call.leave_conference("conference-id-here")

Hold / Unhold

await call.hold()
# ... do something ...
await call.unhold()

Noise Reduction

await call.denoise()
# ... later ...
await call.denoise_stop()

AI Agent on a Call

You can start an AI agent session on a Relay-controlled call:

ai = await call.ai(
prompt={"text": "You are a helpful assistant."},
languages=[{"name": "English", "code": "en-US", "voice": "rime.spore"}],
)
await ai.wait() # Blocks until AI session ends

The Action Pattern

Most call control methods return an Action object — an async handle for the ongoing operation:

action = await call.play([{"type": "tts", "params": {"text": "Hello"}}])
# Fire-and-forget: don't wait for completion
print(f"Play started, control_id={action.control_id}")
# Or wait for completion
result = await action.wait(timeout=30.0)
print(f"Play finished: {result.params.get('state')}")

Common Action Methods

MethodAvailable OnDescription
wait(timeout)All actionsWait for completion
stop()Play, Record, Detect, Collect, Fax, Tap, Stream, Transcribe, AIStop the operation
pause()PlayAction, RecordActionPause playback/recording
resume()PlayAction, RecordActionResume playback/recording
volume(db)PlayAction, CollectActionAdjust volume

on_completed Callback

Every action-based method accepts an on_completed callback:

async def on_play_done(event):
print(f"Play finished: {event.params.get('state')}")
action = await call.play(
[{"type": "tts", "params": {"text": "Processing..."}}],
on_completed=on_play_done,
)
# No need to await — callback fires automatically

SMS/MMS Messaging

The Relay client supports sending and receiving SMS/MMS messages.

LanguageSend Message
Pythonawait client.send_message(to_number="+155...", from_number="+155...", body="Hello")
TypeScriptawait client.sendMessage({ toNumber: '+155...', fromNumber: '+155...', body: 'Hello' })

Sending Messages

message = await client.send_message(
to_number="+15559876543",
from_number="+15551234567",
body="Hello from SignalWire Relay!",
)
print(f"Message ID: {message.message_id}")
# Wait for delivery confirmation
result = await message.wait(timeout=30.0)
print(f"Final state: {message.state}") # delivered, failed, etc.

Sending MMS (with media)

message = await client.send_message(
to_number="+15559876543",
from_number="+15551234567",
body="Check out this image!",
media=["https://example.com/photo.jpg"],
)

Receiving Messages

@client.on_message
async def handle_message(message):
print(f"From: {message.from_number}")
print(f"Body: {message.body}")
if message.media:
print(f"Media: {message.media}")

The Message object provides:

PropertyDescription
message_idUnique message identifier
from_numberSender number
to_numberRecipient number
bodyText content
mediaList of media URLs
stateCurrent state (queued, sent, delivered, failed)
direction"inbound" or "outbound"
segmentsNumber of SMS segments
tagsOptional tags

Event Listeners

Register per-call event listeners for fine-grained control:

from signalwire.relay import EVENT_CALL_STATE, EVENT_CALL_PLAY
@client.on_call
async def handle_call(call):
def on_state_change(event):
print(f"Call state: {event.params.get('call_state')}")
def on_play_event(event):
print(f"Play state: {event.params.get('state')}")
call.on(EVENT_CALL_STATE, on_state_change)
call.on(EVENT_CALL_PLAY, on_play_event)
await call.answer()
action = await call.play([{"type": "tts", "params": {"text": "Hello"}}])
await call.wait_for_ended()

Advanced Configuration

Max Active Calls

Limit concurrent calls to prevent resource exhaustion:

# Via constructor
client = RelayClient(contexts=["default"], max_active_calls=100)

Or via environment variable:

export RELAY_MAX_ACTIVE_CALLS=100

Default: 1000.

Connection Limits

By default, only one RelayClient connection is allowed per process:

export RELAY_MAX_CONNECTIONS=3

Error Handling

The client handles errors gracefully — server errors from call methods return empty dicts rather than raising exceptions. Connection-level errors trigger automatic reconnection.

For explicit error handling:

from signalwire.relay import RelayClient, RelayError
try:
call = await client.dial(devices=[[{...}]])
except RelayError as e:
print(f"Relay error {e.code}: {e.message}")

Complete Example: IVR System

relay_ivr.py

#!/usr/bin/env python3
"""Complete IVR system with Relay."""
from signalwire.relay import RelayClient, EVENT_CALL_STATE
client = RelayClient(contexts=["main-ivr"])
@client.on_call
async def handle_call(call):
await call.answer()
# Start recording
recording = await call.record(audio={"direction": "both", "format": "mp3"})
# Play welcome and collect input
collect = await call.play_and_collect(
media=[
{"type": "tts", "params": {"text": "Welcome to Acme Corp."}},
{"type": "tts", "params": {"text": "Press 1 for sales, 2 for support, or 3 to leave a message."}},
],
collect={"digits": {"max": 1, "digit_timeout": 5.0}},
)
result = await collect.wait()
digit = result.params.get("result", {}).get("digits", "")
if digit == "1":
await call.play([{"type": "tts", "params": {"text": "Connecting you to sales."}}])
await call.connect([[{
"type": "phone",
"params": {"to_number": "+15551001000", "from_number": "+15550000000"},
}]])
elif digit == "2":
await call.play([{"type": "tts", "params": {"text": "Connecting you to support."}}])
await call.connect([[{
"type": "phone",
"params": {"to_number": "+15552002000", "from_number": "+15550000000"},
}]])
elif digit == "3":
play = await call.play([
{"type": "tts", "params": {"text": "Please leave your message after the beep."}},
{"type": "audio", "params": {"url": "https://example.com/beep.wav"}},
])
await play.wait()
voicemail = await call.record(audio={
"direction": "listen",
"format": "mp3",
"end_silence_timeout": 3.0,
})
await voicemail.wait()
else:
play = await call.play([{"type": "tts", "params": {"text": "Invalid option. Goodbye."}}])
await play.wait()
# Stop recording and hang up
await recording.stop()
await call.hangup()
if __name__ == "__main__":
client.run()

See Also

TopicReference
Agent-based approachYour First Agent