fix: fix agent stream contract with cloudonix

This commit is contained in:
Abhishek Kumar 2026-07-06 23:57:38 +05:30
parent 7e2750f83f
commit a86b25860d
9 changed files with 319 additions and 114 deletions

View file

@ -1,19 +1,15 @@
"""Agent-stream WebSocket endpoint. """Agent-stream WebSocket endpoint.
A single ``/agent-stream/{workflow_uuid}`` socket where a caller can drive A single ``/agent-stream/{provider_name}/{workflow_uuid}`` socket where a
an agent run by passing everything inline in the query string including caller can drive an agent run. The provider is part of the URL path;
provider credentials. The standard ``/telephony/ws/...`` path requires a provider-specific call metadata is read from that provider's stream protocol.
``TelephonyConfigurationModel`` row stored in the org; this one does not.
Auth: the workflow UUID itself acts as the identifier no API key. Auth: the workflow UUID itself acts as the identifier no API key.
Routing: when ``?provider=<registered>`` matches a telephony provider, we Routing: when ``/{provider_name}`` matches a telephony provider, we
dispatch to that provider's ``handle_external_websocket``. The raw-audio dispatch to that provider's ``handle_external_websocket``.
branch (no provider) is reserved for a future protocol decision and
currently rejects with 1011.
""" """
import uuid import uuid
from typing import Optional
from fastapi import APIRouter, WebSocket from fastapi import APIRouter, WebSocket
from loguru import logger from loguru import logger
@ -28,32 +24,20 @@ from api.services.telephony import registry as telephony_registry
router = APIRouter(prefix="/agent-stream") router = APIRouter(prefix="/agent-stream")
@router.websocket("/{workflow_uuid}") @router.websocket("/{provider_name}/{workflow_uuid}")
async def agent_stream_websocket( async def agent_stream_websocket(
websocket: WebSocket, websocket: WebSocket,
provider_name: str,
workflow_uuid: str, workflow_uuid: str,
): ):
"""Generic agent-stream WebSocket. """Generic agent-stream WebSocket.
Query params: ``provider_name`` is the registered telephony provider name
provider: registered telephony provider name (e.g. ``cloudonix``) (e.g. ``cloudonix``).
from / to / callId: call metadata persisted on the workflow run
...: provider-specific credentials/identifiers (e.g. ``session``,
``AccountSid``, ``CallSid`` for cloudonix)
Without ``provider`` the raw-audio branch is currently not implemented.
""" """
await websocket.accept() await websocket.accept()
params = dict(websocket.query_params) params = dict(websocket.query_params)
provider_name: Optional[str] = params.get("provider") params.pop("provider", None)
if not provider_name:
logger.warning(
f"agent-stream raw audio branch not yet supported "
f"(workflow_uuid={workflow_uuid})"
)
await websocket.close(code=1011, reason="Raw audio stream not yet implemented")
return
spec = telephony_registry.get_optional(provider_name) spec = telephony_registry.get_optional(provider_name)
if spec is None: if spec is None:
@ -69,12 +53,9 @@ async def agent_stream_websocket(
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000 numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
workflow_run_name = f"WR-AGS-{numeric_suffix:08d}" workflow_run_name = f"WR-AGS-{numeric_suffix:08d}"
call_id = params.get("callId") or params.get("CallSid")
initial_context = { initial_context = {
**(workflow.template_context_variables or {}), **(workflow.template_context_variables or {}),
"provider": provider_name, "provider": provider_name,
"caller_number": params.get("from"),
"called_number": params.get("to"),
"direction": "inbound", "direction": "inbound",
} }
workflow_run = await db_client.create_workflow_run( workflow_run = await db_client.create_workflow_run(
@ -84,12 +65,6 @@ async def agent_stream_websocket(
user_id=workflow.user_id, user_id=workflow.user_id,
call_type=CallType.INBOUND, call_type=CallType.INBOUND,
initial_context=initial_context, initial_context=initial_context,
gathered_context={"call_id": call_id} if call_id else {},
logs={
"inbound_webhook": {
"domain": params.get("Domain"),
},
},
) )
set_current_run_id(workflow_run.id) set_current_run_id(workflow_run.id)

View file

@ -776,7 +776,9 @@ class ARIConnection:
bridge_id = ctx.get("bridge_id") bridge_id = ctx.get("bridge_id")
transfer_state = ctx.get("transfer_state") transfer_state = ctx.get("transfer_state")
transfer_bridge_id = ctx.get("transfer_bridge_id") or bridge_id transfer_bridge_id = ctx.get("transfer_bridge_id") or bridge_id
transfer_caller_channel_id = ctx.get("transfer_caller_channel_id") or call_id transfer_caller_channel_id = (
ctx.get("transfer_caller_channel_id") or call_id
)
transfer_destination_channel_id = ctx.get("transfer_destination_channel_id") transfer_destination_channel_id = ctx.get("transfer_destination_channel_id")
# Check if this is a call transfer scenario external channel. Skip full teardown if # Check if this is a call transfer scenario external channel. Skip full teardown if
@ -813,7 +815,8 @@ class ARIConnection:
and transfer_bridge_id and transfer_bridge_id
and transfer_caller_channel_id and transfer_caller_channel_id
and transfer_destination_channel_id and transfer_destination_channel_id
and channel_id in ( and channel_id
in (
transfer_caller_channel_id, transfer_caller_channel_id,
transfer_destination_channel_id, transfer_destination_channel_id,
) )
@ -884,9 +887,7 @@ class ARIConnection:
# Clean up the Redis marker for external channel # Clean up the Redis marker for external channel
await self._delete_ext_channel(ext_channel_id) await self._delete_ext_channel(ext_channel_id)
await self._delete_transfer_channel_mapping( await self._delete_transfer_channel_mapping(transfer_destination_channel_id)
transfer_destination_channel_id
)
logger.info( logger.info(
f"[ARI org={self.organization_id}] StasisEnd full teardown for " f"[ARI org={self.organization_id}] StasisEnd full teardown for "

View file

@ -359,13 +359,12 @@ class TelephonyProvider(ABC):
workflow_run_id: int, workflow_run_id: int,
params: Dict[str, str], params: Dict[str, str],
) -> None: ) -> None:
"""Handle the agent-stream WebSocket where credentials are passed inline. """Handle the provider-specific agent-stream WebSocket.
Used by ``/api/v1/agent-stream/{workflow_uuid}`` when the caller carries Used by ``/api/v1/agent-stream/{provider_name}/{workflow_uuid}`` when
provider credentials in the query string (no stored the caller carries a provider stream protocol. ``organization_id`` is
``TelephonyConfigurationModel`` row required). ``organization_id`` is passed so providers can scope any config lookups to the workflow's org.
passed so providers can scope any config lookups to the workflow's Default raises so providers that haven't opted in fail loudly.
org. Default raises so providers that haven't opted in fail loudly.
""" """
raise NotImplementedError( raise NotImplementedError(
f"Agent-stream not supported for provider {self.PROVIDER_NAME}" f"Agent-stream not supported for provider {self.PROVIDER_NAME}"

View file

@ -459,10 +459,15 @@ class CloudonixProvider(TelephonyProvider):
await websocket.close(code=4400, reason="Expected start event") await websocket.close(code=4400, reason="Expected start event")
return return
# Extract Twilio-compatible identifiers start = start_msg.get("start")
if not isinstance(start, dict):
logger.error("Cloudonix start message missing start object")
await websocket.close(code=4400, reason="Missing start metadata")
return
try: try:
stream_sid = start_msg["start"]["streamSid"] stream_sid = start["streamSid"]
call_sid = start_msg["start"]["callSid"] call_sid = start["callSid"]
except KeyError: except KeyError:
logger.error("Missing streamSid or callSid in start message") logger.error("Missing streamSid or callSid in start message")
await websocket.close(code=4400, reason="Missing stream identifiers") await websocket.close(code=4400, reason="Missing stream identifiers")
@ -512,48 +517,21 @@ class CloudonixProvider(TelephonyProvider):
) -> None: ) -> None:
"""Agent-stream entry point. """Agent-stream entry point.
``Domain`` (domain id) is read from the query string. The bearer The Cloudonix domain is read from the ``start.accountSid`` field
token comes from the stored Cloudonix telephony configuration in the start message. The bearer token comes from the stored
matched by ``domain_id`` within the workflow's organization — never Cloudonix telephony configuration matched by ``domain_id`` within
from the URL or stream payload. The websocket handshake (connected the workflow's organization — never from the URL or stream payload.
/ start) is identical to the standard inbound flow. The websocket handshake (connected / start) is identical to the
standard inbound flow.
Before starting the pipeline we (a) require an existing Cloudonix Before starting the pipeline we (a) require an existing Cloudonix
telephony configuration for the supplied ``domain_id`` and (b) telephony configuration for the stream's ``domain_id`` and (b)
validate the call session with Cloudonix using the bearer token validate the call session with Cloudonix using the bearer token
from that configuration. Either failure closes the socket with from that configuration. Either failure closes the socket with
4400. 4400.
""" """
from api.services.pipecat.run_pipeline import run_pipeline_telephony from api.services.pipecat.run_pipeline import run_pipeline_telephony
domain_id = self._normalize_domain(params.get("Domain"))
if not domain_id:
logger.error("Cloudonix agent-stream missing required param: Domain")
await websocket.close(code=4400, reason="Missing Domain query param")
return
config = await self._find_config_by_domain(organization_id, domain_id)
if not config:
logger.error(
f"Cloudonix agent-stream: no telephony configuration found "
f"for domain_id={domain_id}"
)
await websocket.close(
code=4400, reason=f"Unknown Cloudonix domain: {domain_id}"
)
return
bearer_token = (config.credentials or {}).get("bearer_token")
if not bearer_token:
logger.error(
f"Cloudonix agent-stream: telephony configuration {config.id} "
f"is missing bearer_token in credentials"
)
await websocket.close(
code=4400, reason="Cloudonix configuration missing bearer_token"
)
return
try: try:
first_msg = await websocket.receive_text() first_msg = await websocket.receive_text()
msg = json.loads(first_msg) msg = json.loads(first_msg)
@ -568,24 +546,100 @@ class CloudonixProvider(TelephonyProvider):
await websocket.close(code=4400, reason="Expected start event") await websocket.close(code=4400, reason="Expected start event")
return return
start = start_msg.get("start")
if not isinstance(start, dict):
logger.error(
"Cloudonix agent-stream start message missing start object"
)
await websocket.close(code=4400, reason="Missing start metadata")
return
try: try:
stream_sid = start_msg["start"]["streamSid"] stream_sid = start["streamSid"]
call_sid = start_msg["start"]["callSid"] call_sid = start["callSid"]
call_session = start_msg["start"]["session"] call_session = start["session"]
domain_id = self._normalize_domain(start["accountSid"])
except KeyError: except KeyError:
logger.error("Missing streamSid or callSid or session in start message") logger.error(
"Missing streamSid, callSid, session, or accountSid in start message"
)
await websocket.close(code=4400, reason="Missing stream identifiers") await websocket.close(code=4400, reason="Missing stream identifiers")
return return
if not domain_id:
logger.error("Cloudonix agent-stream start message missing accountSid")
await websocket.close(
code=4400, reason="Missing Cloudonix domain in start message"
)
return
config = await self._find_config_by_domain(organization_id, domain_id)
if not config:
logger.error(
f"Cloudonix agent-stream: no telephony configuration found "
f"for domain_id={domain_id}"
)
await websocket.close(
code=4400, reason=f"Unknown Cloudonix domain: {domain_id}"
)
return
bearer_token = (config.credentials or {}).get("bearer_token")
if not bearer_token:
logger.error(
f"Cloudonix agent-stream: telephony configuration {config.id} "
f"is missing bearer_token in credentials"
)
await websocket.close(
code=4400, reason="Cloudonix configuration missing bearer_token"
)
return
if not await self._validate_session(domain_id, call_session, bearer_token): if not await self._validate_session(domain_id, call_session, bearer_token):
await websocket.close( await websocket.close(
code=4400, reason="Cloudonix session validation failed" code=4400, reason="Cloudonix session validation failed"
) )
return return
start_context = start.get("context")
await db_client.update_workflow_run(
run_id=workflow_run_id,
initial_context={
key: value
for key, value in {
"caller_number": start.get("from"),
"called_number": start.get("to"),
"direction": (
"outbound" if start_context == "outbound-api" else "inbound"
),
"cloudonix_context": start_context,
}.items()
if value is not None
},
gathered_context={
"call_id": call_session,
"cloudonix_call_sid": call_sid,
"cloudonix_stream_sid": stream_sid,
},
logs={
"inbound_webhook": {
"domain": domain_id,
"session": call_session,
"callSid": call_sid,
"streamSid": stream_sid,
"from": start.get("from"),
"to": start.get("to"),
"context": start_context,
"tracks": start.get("tracks"),
"mediaFormat": start.get("mediaFormat"),
},
},
)
logger.info( logger.info(
f"Cloudonix agent-stream connected for workflow_run " f"Cloudonix agent-stream connected for workflow_run "
f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} session={call_session}" f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} "
f"session={call_session} "
f"telephony_configuration_id={config.id}" f"telephony_configuration_id={config.id}"
) )
@ -628,7 +682,7 @@ class CloudonixProvider(TelephonyProvider):
if response.status == 200: if response.status == 200:
return True return True
body = await response.text() body = await response.text()
logger.error( logger.warning(
f"Cloudonix session validation failed: " f"Cloudonix session validation failed: "
f"HTTP {response.status} domain_id={domain_id} " f"HTTP {response.status} domain_id={domain_id} "
f"call_id={call_session} body={body}" f"call_id={call_session} body={body}"

View file

@ -6,6 +6,8 @@ or a ``null`` ``session`` / ``disposition``) must produce a graceful error
response, not an unhandled ``AttributeError`` (HTTP 500). response, not an unhandled ``AttributeError`` (HTTP 500).
""" """
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@ -34,6 +36,116 @@ def _json_request(body: bytes) -> Request:
) )
class _FakeWebSocket:
def __init__(self, *messages: str):
self.receive_text = AsyncMock(side_effect=messages)
self.close = AsyncMock()
@pytest.mark.asyncio
async def test_agent_stream_reads_call_metadata_from_start_message():
"""Cloudonix agent-stream uses start metadata, not call query params."""
provider = CloudonixProvider({})
websocket = _FakeWebSocket(
json.dumps({"event": "connected"}),
json.dumps(
{
"event": "start",
"start": {
"streamSid": "stream-123",
"callSid": "call-123",
"session": "session-123",
"accountSid": "acme",
"from": "+15551230001",
"to": "+15551230002",
"context": "inbound",
"tracks": ["inbound"],
"mediaFormat": {
"encoding": "audio/x-mulaw",
"sampleRate": 8000,
"channels": 1,
},
},
}
),
)
config = SimpleNamespace(
id=7,
credentials={
"domain_id": "acme.cloudonix.net",
"bearer_token": "secret-token",
},
)
provider._find_config_by_domain = AsyncMock(return_value=config)
provider._validate_session = AsyncMock(return_value=True)
with (
patch(
"api.services.telephony.providers.cloudonix.provider.db_client"
) as db_client,
patch(
"api.services.pipecat.run_pipeline.run_pipeline_telephony",
new_callable=AsyncMock,
) as run_pipeline,
):
db_client.update_workflow_run = AsyncMock()
await provider.handle_external_websocket(
websocket,
organization_id=44,
workflow_id=101,
user_id=202,
workflow_run_id=303,
params={},
)
provider._find_config_by_domain.assert_awaited_once_with(44, "acme.cloudonix.net")
provider._validate_session.assert_awaited_once_with(
"acme.cloudonix.net", "session-123", "secret-token"
)
db_client.update_workflow_run.assert_awaited_once_with(
run_id=303,
initial_context={
"caller_number": "+15551230001",
"called_number": "+15551230002",
"direction": "inbound",
"cloudonix_context": "inbound",
},
gathered_context={
"call_id": "session-123",
"cloudonix_call_sid": "call-123",
"cloudonix_stream_sid": "stream-123",
},
logs={
"inbound_webhook": {
"domain": "acme.cloudonix.net",
"session": "session-123",
"callSid": "call-123",
"streamSid": "stream-123",
"from": "+15551230001",
"to": "+15551230002",
"context": "inbound",
"tracks": ["inbound"],
"mediaFormat": {
"encoding": "audio/x-mulaw",
"sampleRate": 8000,
"channels": 1,
},
},
},
)
run_pipeline.assert_awaited_once()
_, kwargs = run_pipeline.await_args
assert kwargs["call_id"] == "session-123"
assert kwargs["transport_kwargs"] == {
"call_id": "session-123",
"stream_sid": "stream-123",
"bearer_token": "secret-token",
"domain_id": "acme.cloudonix.net",
}
websocket.close.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cdr_route_handles_payload_without_session(): async def test_cdr_route_handles_payload_without_session():
"""A CDR payload missing the ``session`` object returns a graceful error """A CDR payload missing the ``session`` object returns a graceful error

View file

@ -57,7 +57,9 @@ class _RecordingARIConnection(ARIConnection):
async def _handle_transfer_failed( async def _handle_transfer_failed(
self, transfer_id: str, channel_id: str, reason: str self, transfer_id: str, channel_id: str, reason: str
): ):
raise AssertionError("completed transfer hangup should not publish transfer failure") raise AssertionError(
"completed transfer hangup should not publish transfer failure"
)
def _completed_transfer_context(): def _completed_transfer_context():
@ -73,7 +75,9 @@ def _completed_transfer_context():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_completed_transfer_tears_down_destination_when_caller_leaves(monkeypatch): async def test_completed_transfer_tears_down_destination_when_caller_leaves(
monkeypatch,
):
fake_db = _FakeDbClient(_completed_transfer_context()) fake_db = _FakeDbClient(_completed_transfer_context())
monkeypatch.setattr(ari_manager, "db_client", fake_db) monkeypatch.setattr(ari_manager, "db_client", fake_db)
conn = _RecordingARIConnection() conn = _RecordingARIConnection()
@ -89,7 +93,9 @@ async def test_completed_transfer_tears_down_destination_when_caller_leaves(monk
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_completed_transfer_tears_down_caller_when_destination_leaves(monkeypatch): async def test_completed_transfer_tears_down_caller_when_destination_leaves(
monkeypatch,
):
fake_db = _FakeDbClient(_completed_transfer_context()) fake_db = _FakeDbClient(_completed_transfer_context())
monkeypatch.setattr(ari_manager, "db_client", fake_db) monkeypatch.setattr(ari_manager, "db_client", fake_db)
conn = _RecordingARIConnection() conn = _RecordingARIConnection()

View file

@ -0,0 +1,64 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
class _FakeWebSocket:
def __init__(self, query_params: dict[str, str] | None = None):
self.query_params = query_params or {}
self.accept = AsyncMock()
self.close = AsyncMock()
@pytest.mark.asyncio
async def test_agent_stream_uses_provider_path_param_not_query_param():
from api.routes.agent_stream import agent_stream_websocket
websocket = _FakeWebSocket(
{
"provider": "twilio",
"custom": "value",
}
)
workflow = SimpleNamespace(
id=11,
user_id=22,
organization_id=33,
template_context_variables={"existing": "context"},
)
workflow_run = SimpleNamespace(id=44)
provider = SimpleNamespace(handle_external_websocket=AsyncMock())
spec = SimpleNamespace(provider_cls=lambda _config: provider)
with (
patch("api.routes.agent_stream.telephony_registry") as registry,
patch("api.routes.agent_stream.db_client") as db_client,
patch(
"api.routes.agent_stream.authorize_workflow_run_start",
new=AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message=None)
),
),
):
registry.get_optional.return_value = spec
db_client.get_workflow_by_uuid_unscoped = AsyncMock(return_value=workflow)
db_client.create_workflow_run = AsyncMock(return_value=workflow_run)
db_client.update_workflow_run = AsyncMock()
await agent_stream_websocket(websocket, "cloudonix", "agent-uuid")
registry.get_optional.assert_called_once_with("cloudonix")
db_client.create_workflow_run.assert_awaited_once()
create_args = db_client.create_workflow_run.await_args.args
create_kwargs = db_client.create_workflow_run.await_args.kwargs
assert create_args[2] == "cloudonix"
assert create_kwargs["initial_context"] == {
"existing": "context",
"provider": "cloudonix",
"direction": "inbound",
}
provider.handle_external_websocket.assert_awaited_once()
_, provider_kwargs = provider.handle_external_websocket.await_args
assert provider_kwargs["params"] == {"custom": "value"}
websocket.close.assert_not_awaited()

View file

@ -29,7 +29,7 @@ Use the Agent UUID for workflow-level routes such as:
- `POST /api/v1/public/agent/workflow/{workflow_uuid}` - `POST /api/v1/public/agent/workflow/{workflow_uuid}`
- `POST /api/v1/public/agent/test/workflow/{workflow_uuid}` - `POST /api/v1/public/agent/test/workflow/{workflow_uuid}`
- `wss://<your-host>/api/v1/agent-stream/{agent_uuid}` - `wss://<your-host>/api/v1/agent-stream/{provider}/{agent_uuid}`
<Note> <Note>
Do not confuse the Agent UUID with an API Trigger node UUID (`trigger_path`). Do not confuse the Agent UUID with an API Trigger node UUID (`trigger_path`).

View file

@ -5,7 +5,7 @@ description: "Stream audio to a Dograh agent over a WebSocket"
## Overview ## Overview
Agent Stream is a WebSocket endpoint that lets a telephony provider point its media stream at a single URL and drive a Dograh agent run. The agent UUID in the URL selects the agent; provider-specific identifiers in the query string (for Cloudonix: `Domain`) tell Dograh which stored telephony configuration to use for that call. The bearer token and other credentials are never passed in the URL — they live in the stored configuration and are used by Dograh to validate the session and to issue provider API calls (hangup, transfer) during the call. Agent Stream is a WebSocket endpoint that lets a telephony provider point its media stream at a single URL and drive a Dograh agent run. The agent UUID in the URL selects the agent, and the provider path segment selects the streaming protocol. Provider-specific identifiers come from the stream protocol itself. For Cloudonix, Dograh reads the domain from the `start.accountSid` field, then uses the matching stored telephony configuration to validate the session and issue provider API calls (hangup, transfer) during the call.
This is useful when: This is useful when:
@ -23,15 +23,15 @@ This is useful when:
## Endpoint ## Endpoint
``` ```
wss://app.dograh.com/api/v1/agent-stream/{agent_uuid} wss://app.dograh.com/api/v1/agent-stream/{provider}/{agent_uuid}
``` ```
`{agent_uuid}` is the agent's stable UUID (see [Get the Agent UUID](#get-the-agent-uuid) below). On self-hosted deployments, replace `app.dograh.com` with your backend host. `{provider}` is the registered provider name, currently `cloudonix`. `{agent_uuid}` is the agent's stable UUID (see [Get the Agent UUID](#get-the-agent-uuid) below). On self-hosted deployments, replace `app.dograh.com` with your backend host.
## Prerequisites ## Prerequisites
- A Dograh agent (workflow) — published or in draft is fine - A Dograh agent (workflow) — published or in draft is fine
- A Cloudonix telephony configuration in your Dograh organization whose `domain_id` matches the `Domain` you pass on the URL. Dograh uses the bearer token from this configuration to validate the call session and to issue provider API calls (hangup, transfer). - A Cloudonix telephony configuration in your Dograh organization whose `domain_id` matches the `accountSid` Cloudonix sends in the stream `start` message. Dograh uses the bearer token from this configuration to validate the call session and to issue provider API calls (hangup, transfer).
## Get the Agent UUID ## Get the Agent UUID
@ -41,25 +41,17 @@ To find and copy it in the UI, see [Agent UUID](/configurations/agent-uuid).
## Connect to the WebSocket ## Connect to the WebSocket
### URL parameters ### Path parameters
| Param | Required | Description | | Param | Required | Description |
| --- | --- | --- | | --- | --- | --- |
| `provider` | Yes | Provider name. Currently only `cloudonix` is supported. | | `provider` | Yes | Provider name. Currently only `cloudonix` is supported. |
| `Domain` | Yes (cloudonix) | Cloudonix domain ID. Dograh uses this to look up the matching stored telephony configuration and retrieve the bearer token used for provider API calls. | | `agent_uuid` | Yes | Stable UUID of the Dograh agent to run. |
| `callId` / `CallSid` | No | Call identifier from your side; persisted on the workflow run's `gathered_context` as `call_id`. The Cloudonix call SID used for streaming is taken from the `start` event payload, not this param. |
| `from` | No | Caller phone number, persisted on the workflow run as `caller_number`. |
| `to` | No | Called phone number, persisted on the workflow run as `called_number`. |
### Cloudonix example ### Cloudonix example
``` ```
wss://app.dograh.com/api/v1/agent-stream/{agent_uuid} wss://app.dograh.com/api/v1/agent-stream/cloudonix/{agent_uuid}
?provider=cloudonix
&Domain={CLOUDONIX_DOMAIN_ID}
&callId={CALL_ID}
&from=+15555550100
&to=+15555550199
``` ```
Use this URL inside the CXML `<Stream>` your Cloudonix Voice Application returns when the call needs to be bridged to the Dograh agent: Use this URL inside the CXML `<Stream>` your Cloudonix Voice Application returns when the call needs to be bridged to the Dograh agent:
@ -68,13 +60,13 @@ Use this URL inside the CXML `<Stream>` your Cloudonix Voice Application returns
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Response> <Response>
<Connect> <Connect>
<Stream url="wss://app.dograh.com/api/v1/agent-stream/{agent_uuid}?provider=cloudonix&Domain=...&callId=...&from=...&to=..."/> <Stream url="wss://app.dograh.com/api/v1/agent-stream/cloudonix/{agent_uuid}"/>
</Connect> </Connect>
<Pause length="40"/> <Pause length="40"/>
</Response> </Response>
``` ```
The first two messages on the socket should be Cloudonix's standard `connected` and `start` events (Twilio-compatible framing). Dograh extracts `streamSid` and `callSid` from the `start` event payload, validates the session against Cloudonix using the bearer token from the stored telephony configuration matched by `Domain`, and then begins streaming audio. The first two messages on the socket should be Cloudonix's standard `connected` and `start` events (Twilio-compatible framing). Dograh extracts `streamSid`, `callSid`, `session`, `accountSid`, `from`, `to`, `context`, `tracks`, and `mediaFormat` from the `start` event payload. It validates the session against Cloudonix using the bearer token from the stored telephony configuration matched by `accountSid`, then begins streaming audio.
## Workflow run lifecycle ## Workflow run lifecycle
@ -82,8 +74,10 @@ When the WebSocket is accepted, Dograh:
1. Looks up the workflow by `agent_uuid` 1. Looks up the workflow by `agent_uuid`
2. Runs a quota check against the workflow's owning user 2. Runs a quota check against the workflow's owning user
3. Creates a new `WorkflowRun` (`call_type=inbound`, `mode=cloudonix`, name `WR-AGS-XXXXXXXX`) with the `from`/`to` numbers stamped on `initial_context`, the `callId`/`CallSid` stored as `call_id` on `gathered_context`, and `Domain` recorded under the run's `inbound_webhook` log 3. Creates a new `WorkflowRun` (`call_type=inbound`, `mode=cloudonix`, name `WR-AGS-XXXXXXXX`)
4. Transitions the run to `running` and starts the agent pipeline 4. Transitions the run to `running`
5. Reads and validates the Cloudonix `start` message, then stamps the `from`/`to` numbers on `initial_context`, stores `session` as `call_id` on `gathered_context`, and records `accountSid` under the run's `inbound_webhook` log
6. Starts the agent pipeline
The run is visible under the agent's **Runs** tab as soon as it's minted, just like an inbound or outbound call. The run is visible under the agent's **Runs** tab as soon as it's minted, just like an inbound or outbound call.
@ -93,9 +87,9 @@ The run is visible under the agent's **Runs** tab as soon as it's minted, just l
| --- | --- | | --- | --- |
| `1008` | Routing failure — unknown provider, workflow not found, or quota exceeded | | `1008` | Routing failure — unknown provider, workflow not found, or quota exceeded |
| `1011` | Server-side failure or unsupported provider for Agent Stream | | `1011` | Server-side failure or unsupported provider for Agent Stream |
| `4400` | Provider-level handshake error — for cloudonix, missing `Domain`, no matching telephony configuration, missing bearer token on the configuration, malformed `connected`/`start` events, or session validation failed against Cloudonix | | `4400` | Provider-level handshake error — for cloudonix, missing `start.accountSid`, no matching telephony configuration, missing bearer token on the configuration, malformed `connected`/`start` events, or session validation failed against Cloudonix |
## Security notes ## Security notes
- Treat the URL as a secret — the agent UUID itself authorizes the connection. Store and transmit it only over TLS, and avoid logging the raw URL in places where access is broader than your operations team. - Treat the URL as a secret — the agent UUID itself authorizes the connection. Store and transmit it only over TLS, and avoid logging the raw URL in places where access is broader than your operations team.
- No bearer tokens or provider secrets are passed in the URL. Provider credentials live in the stored telephony configuration (matched by `Domain` for Cloudonix) and are used server-side by Dograh to validate the session and issue provider API calls. - No bearer tokens or provider secrets are passed in the URL. Provider credentials live in the stored telephony configuration (matched by `start.accountSid` for Cloudonix) and are used server-side by Dograh to validate the session and issue provider API calls.