Control plane (remote SDKs)¶
SIPhon's B2BUA can hand a live call to an out-of-process application over a
WebSocket, the model Asterisk gives you with ARI and FreeSWITCH with ESL. A
script hands a call over with call.handover("app"); siphon holds the INVITE
un-dialed, emits a StasisStart carrying the full SIP context, and your
application answers, progresses, rejects, hangs up, refers, or reads and writes
per-call variables over the socket.
The client SDKs are the supported way to build that application. They hide
the wire — no hand-rolled JSON, no request-id bookkeeping, no reconnect loop —
and they are versioned against the siphon-control.v1 protocol independently of
the siphon server, so a controller you write today keeps working across siphon
upgrades. Reach for the raw protocol only when you need a client in a language
the SDKs don't cover.
| You want to… | Use |
|---|---|
| Build a controller in Python | pip install siphon-control |
| Build a controller in Rust | cargo add siphon-control-client |
| Build a controller in TypeScript | npm i @siphon-project/control |
| Build a controller in another language | the raw siphon-control.v1 protocol |
Python — siphon-control¶
A native (PyO3) extension over the async Rust client. The wheel is published for both GIL and free-threaded CPython 3.14, so it drops into a plain interpreter or the free-threaded runtime siphon itself uses.
import asyncio
from siphon_control import ControlClient, ControlError
client = ControlClient(app="ivr-app", token="s3cr3t",
url="ws://siphon:9090/control/ws")
@client.on_call
async def handle(call):
await call.answer() # UAS 2xx to the parked A-leg
try:
await call.transfer("sip:agent@pbx") # REFER; awaits the correlated reply
except ControlError as error:
print("transfer rejected:", error.code) # stable code: not_found, forbidden, …
await call.hangup()
asyncio.run(client.run()) # connect, dispatch, reconnect + resync
Call verbs: answer() / answer_with(code, …), progress(),
reject(code, reason), hangup(reason=None), refer(to) / transfer(to),
set_header(name, value) / get_header(name), set_var(key, value) /
get_var(key), plus the generic command(verb, args=None) escape hatch and
next_event(). A rejected command raises ControlError carrying a stable
.code. Media verbs (play_file / dtmf) raise with code ==
"unsupported_verb" until the server implements them.
Rust — siphon-control-client¶
use siphon_control_client::{ClientConfig, sip::SipClient};
# async fn demo() -> Result<(), siphon_control_client::ControlError> {
let client = SipClient::connect(
ClientConfig::new("ws://siphon:9090/control/ws", "ivr-app", "s3cr3t"),
)
.await?;
client
.on_call(|call| async move {
call.answer().await?;
call.transfer("sip:agent@pbx").await
})
.await?;
# Ok(())
# }
The client splits into a protocol-agnostic core (ControlClient /
ControlServer — transport, hello, request-id correlation, reconnect +
resync, and a generic command(module, verb, target, args) primitive that
works for any adapter) and a typed sip facade (sip::Call) layered on top. A
rejected command maps to ControlError::Command carrying the stable
ControlErrorCode.
TypeScript — @siphon-project/control¶
import { SipClient, ControlError } from "@siphon-project/control";
const client = await SipClient.connect({
url: "ws://siphon:9090/control/ws",
app: "ivr-app",
token: "s3cr3t",
});
await client.onCall(async (call) => {
await call.answer(); // UAS 2xx to the parked A-leg
try {
await call.transfer("sip:agent@pbx"); // REFER; awaits the correlated reply
} catch (error) {
if (error instanceof ControlError) {
console.log("transfer rejected:", error.code); // stable code
}
}
await call.hangup();
}); // connect, dispatch, reconnect + resync
The same Call verbs as the Python and Rust facades. SipClient / SipServer
are the SIP facade over the generic ControlClient / ControlServer core; both
expose onCall(handler) and the identical Call handle — SipServer is the
per-call-connect twin (siphon dials the app).
Connection modes¶
All three SDKs support the two modes, over the same JSON-over-WebSocket protocol.
- Outbound per-call-connect (the multi-pod default). Your app runs a
WebSocket server; siphon dials it once per handed-over call and the accepting
socket owns that call (the FreeSWITCH-outbound model). Siphon always dials
out, so the "which pod owns the call" affinity problem never arises. There
is no
hello— the first frame isStasisStart. - Inbound persistent. Your app connects in to
control.listenand owns calls assigned to it (round-robin across the app's connections). It sends a firsthelloand canresyncto re-attach its calls after a reconnect.
Handing a call over¶
Handover happens in the in-process B2BUA script (the
call.handover verb), not in the controller:
from siphon import b2bua
@b2bua.on_invite
async def route(call):
if call.to_uri.endswith("@ivr.example.com"):
call.handover("ivr-app") # park + hand to the controller
elif call.to_uri.endswith("@ai.example.com"):
call.handover("ivr-app", answer=True, # answer-first (AI-park):
ws_uri="wss://ai.example/stream/{call_id}")
else:
call.dial(call.ruri) # ordinary B2BUA
answer=True (answer-first / AI-park) answers the call and anchors its media to
a WebSocket bridge before handing over, so the controller drives an
already-connected channel; it requires the siphon-rtp media backend.
siphon configuration¶
control:
# outbound per-call-connect (default) — siphon dials the app per call:
apps:
- name: "ivr-app"
token: "${IVR_APP_TOKEN}"
per_call_connect: true
connect_url: "ws://127.0.0.1:8443/siphon"
# inbound persistent — the app connects in here instead:
# listen: "127.0.0.1:9092"
limits:
event_queue_depth: 1024
reattach_grace_secs: 10
Per-app bearer tokens are constant-time compared and feed the existing auto-ban
store. Dispatch is exactly-one-owner with per-tenant scoping: a command against
another app's call returns forbidden, and a command against a dead or unknown
call returns not_found — neither ever hangs.
Under the hood: the raw protocol¶
The SDKs speak siphon-control.v1: a single WebSocket per connection, JSON text
frames, request-id correlated. You only need this layer to build a client in a
language the SDKs don't cover — otherwise the SDKs handle all of it.
command (client → siphon) { "id":"c-1", "type":"command", "module":"sip",
"verb":"answer", "target":{"channel":"<id>"},
"args":{"code":200} }
reply (siphon → client) { "id":"c-1", "type":"reply", "status":"ok",
"result":{...} } // or "status":"error", "error":{code,message}
event (siphon → client) { "type":"event", "event":"StasisStart",
"channel":"<id>", "call_id":"<uuid>",
"sip_call_id":"<cid>", "payload":{...} }
Every event carries the stable id triple {channel, call_id, sip_call_id} —
sip_call_id is byte-identical to the CDR call_id and the HEP correlation
chunk, so logs join Homer and billing with no mapping table.
Phase-1 verb set¶
| verb | module | args | notes |
|---|---|---|---|
answer |
sip | {code, reason?, body?, content_type?} |
UAS 2xx to the parked A-leg |
progress |
sip | {code, reason?, body?, content_type?} |
1xx / early media |
reject |
sip | {code, reason?} |
final non-2xx + tear down |
hangup |
sip | {reason?} |
BYE an answered call, or reject an unanswered one |
refer |
sip | {to, replaces?} |
in-dialog REFER on the A-leg |
accept_refer |
sip | {target?, next_hop?, mode?} |
accept a pending inbound REFER (from a TransferRequested event) and run the transfer |
reject_refer |
sip | {code?, reason?} |
reject a pending inbound REFER with a final non-2xx (default 603 Decline) |
route |
sip | {targets, strategy?, headers?} |
return control to siphon: un-park the call and dial the B-leg via LCR sequential failover |
set_header / remove_header / get_header |
sip | {name, value?} |
on the stored A-leg INVITE |
play |
sip | {file\|db_id\|blob, repeat?, start_ms?, duration_ms?, to_tag?} |
play an announcement on the A-leg media (fire-and-forget) |
stop |
sip | — | stop the announcement currently playing |
dtmf |
sip | {digits, duration_ms?, volume_dbm0?, pause_ms?, to_tag?} |
inject DTMF digits toward the A-leg |
hold / unhold |
sip | — | media hold via silence |
stream_start |
sip | {ws_uri, direction?, channels?} |
attach a WebSocket audio tee (siphon-rtp backend only) |
stream_stop |
sip | — | detach the WebSocket audio tee |
set_var / get_var |
— | {key, value?} |
per-call variables (drain with the call) |
resync |
— | — | re-attach + enumerate this app's owned calls |
describe |
— | — | list the registered adapters + their verb/event schema |
route is the consult-and-return flow: an app parks a call (deferred handover),
decides routing out-of-process (LCR / rating / business logic), then hands
control back to siphon with the decision. targets is a non-empty array of
either bare URI strings or objects {uri, next_hop?, headers?, timeout?};
strategy defaults to "sequential" (v1 runs the LCR sequential-failover
engine only, so anything else is a typed unsupported_verb, never a silent
sequential); headers is an optional object applied to every attempt's B-leg
INVITE. On success siphon replies {state: "routing", targets: N}, emits a
StasisEnd{reason: "routed"} on the owning connection (control returned, the
call lives on), then owns the call: it dials the first carrier and advances
through the rest on reject/timeout, with @b2bua.on_failure handling carrier
failover. continue (bare hand-back, siphon re-decides routing through the
script's @b2bua.on_* handlers) is a follow-up, pending the control-loss
fallback re-dispatch path.
The media verbs (play / stop / dtmf / hold / unhold / stream_start /
stream_stop) act on the controlled A-leg's anchored media session. They are
resolved against the configured media backend and answer with a typed reply the
same way every other verb does — never a hang:
playis fire-and-forget: the reply confirms the backend accepted the command ({state: "playing"}), it does not wait for the prompt to finish. The source is exactly one offile(a path on the media host),db_id(a prompt in the engine's DB), orblob(base64-encoded audio, since the wire is JSON).holdmaps to the engine's silence (comfort-noise) mode andunholdrestores it — a gentle hold that keeps the media path up. Dropping packets outright (block/unblock) is a separate future gate verb.stream_start/stream_stopattach and detach a WebSocket audio tee — an additive copy of the live call's audio for transcription / agent-assist / compliance, not a takeover of the media path. This is asiphon-rtp-backend feature: on rtpengine / rtpproxy it answersunsupported_verbrather than a hollow success.directionisboth(default) /caller/callee, andchannelsis1(mixed mono) or2(caller/callee stereo).- A call with no anchored media session answers
not_found; a backend that cannot perform the op answersunsupported_verb; any other backend failure answersunavailable.
Inbound in-band DTMF on a controlled call is pushed to the owning connection as
a ChannelDtmfReceived event, payload {digit, duration_ms, volume, from_tag}
(from_tag identifies which party pressed), so an IVR / AI app collects digits
off the event stream rather than through a blocking verb — there is
deliberately no server-side collect_dtmf (it would park an I/O worker). This is
additive to the in-process @rtpengine.on_dtmf dispatch: the digit fires both,
and it needs no extra configuration beyond the DTMF-log wiring the media engine
already uses.
An inbound REFER on a controlled call (a party asking to be transferred) is
handed to the owning app rather than the in-process @b2bua.on_refer path: siphon
holds the REFER un-answered and pushes a TransferRequested event, payload
{refer_to, replaces?, from_tag} (replaces present for an attended transfer;
from_tag identifies the referring party). The app decides with:
accept_refer{target?, next_hop?, mode?}— run the transfer through siphon's shipped machinery.targetoverrides the Refer-To URI,next_hopsteers egress without reshaping the R-URI, andmodeisterminate(siphon-terminated: 202 + sipfrag NOTIFYs + re-dial the target as a new leg — the default, fromb2bua.default_refer_mode) ortransparent(forward the REFER on the far leg's own dialog). On a single-leg call (a voice-AI / IVR call siphon answered itself, no B leg) terminate mode re-dials the target off the A dialog.reject_refer{code?, reason?}— decline with a final non-2xx (default603 Decline).
If the app never decides, a decision deadline answers 603 Decline (the same
default as when no @b2bua.on_refer handler is registered), so a REFER is never
left pending — the referrer is always answered (RFC 3515 §2.4.2). A bad mode
answers bad_request; a decision for a call with no pending REFER (already
decided, timed out, or gone) answers not_found. A REFER on an uncontrolled
call is unaffected — it still runs the Python @b2bua.on_refer path.
bridge / originate verbs arrive in later phases over the same envelope. The
client SDK facade methods for the media verbs and the transfer verbs land
alongside them (until then, reach the verbs through the generic
command(verb, args) escape hatch).
The complete wire reference, both connection modes end to end, and two low-level example clients (one Python, one TypeScript) that drive calls with no SDK live in the repository:
- Protocol + example clients:
examples/remote_control/ - SDK sources:
siphon-control-sdk/(siphon-control-protois the shared DTO crate — the single source of truth for the frames above)