diff --git a/api/routes/agent_stream.py b/api/routes/agent_stream.py index 32bf5743..847c3228 100644 --- a/api/routes/agent_stream.py +++ b/api/routes/agent_stream.py @@ -1,19 +1,15 @@ """Agent-stream WebSocket endpoint. -A single ``/agent-stream/{workflow_uuid}`` socket where a caller can drive -an agent run by passing everything inline in the query string — including -provider credentials. The standard ``/telephony/ws/...`` path requires a -``TelephonyConfigurationModel`` row stored in the org; this one does not. +A single ``/agent-stream/{provider_name}/{workflow_uuid}`` socket where a +caller can drive an agent run. The provider is part of the URL path; +provider-specific call metadata is read from that provider's stream protocol. Auth: the workflow UUID itself acts as the identifier — no API key. -Routing: when ``?provider=`` matches a telephony provider, we -dispatch to that provider's ``handle_external_websocket``. The raw-audio -branch (no provider) is reserved for a future protocol decision and -currently rejects with 1011. +Routing: when ``/{provider_name}`` matches a telephony provider, we +dispatch to that provider's ``handle_external_websocket``. """ import uuid -from typing import Optional from fastapi import APIRouter, WebSocket from loguru import logger @@ -28,32 +24,20 @@ from api.services.telephony import registry as telephony_registry router = APIRouter(prefix="/agent-stream") -@router.websocket("/{workflow_uuid}") +@router.websocket("/{provider_name}/{workflow_uuid}") async def agent_stream_websocket( websocket: WebSocket, + provider_name: str, workflow_uuid: str, ): """Generic agent-stream WebSocket. - Query params: - provider: registered telephony provider name (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. + ``provider_name`` is the registered telephony provider name + (e.g. ``cloudonix``). """ await websocket.accept() params = dict(websocket.query_params) - provider_name: Optional[str] = params.get("provider") - - 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 + params.pop("provider", None) spec = telephony_registry.get_optional(provider_name) if spec is None: @@ -69,12 +53,9 @@ async def agent_stream_websocket( numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000 workflow_run_name = f"WR-AGS-{numeric_suffix:08d}" - call_id = params.get("callId") or params.get("CallSid") initial_context = { **(workflow.template_context_variables or {}), "provider": provider_name, - "caller_number": params.get("from"), - "called_number": params.get("to"), "direction": "inbound", } workflow_run = await db_client.create_workflow_run( @@ -84,12 +65,6 @@ async def agent_stream_websocket( user_id=workflow.user_id, call_type=CallType.INBOUND, 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) diff --git a/api/services/telephony/ari_manager.py b/api/services/telephony/ari_manager.py index 58920d9c..3663df88 100644 --- a/api/services/telephony/ari_manager.py +++ b/api/services/telephony/ari_manager.py @@ -776,7 +776,9 @@ class ARIConnection: bridge_id = ctx.get("bridge_id") transfer_state = ctx.get("transfer_state") 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") # 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_caller_channel_id and transfer_destination_channel_id - and channel_id in ( + and channel_id + in ( transfer_caller_channel_id, transfer_destination_channel_id, ) @@ -884,9 +887,7 @@ class ARIConnection: # Clean up the Redis marker for external channel await self._delete_ext_channel(ext_channel_id) - await self._delete_transfer_channel_mapping( - transfer_destination_channel_id - ) + await self._delete_transfer_channel_mapping(transfer_destination_channel_id) logger.info( f"[ARI org={self.organization_id}] StasisEnd full teardown for " diff --git a/api/services/telephony/base.py b/api/services/telephony/base.py index e399cf2b..87b96265 100644 --- a/api/services/telephony/base.py +++ b/api/services/telephony/base.py @@ -359,13 +359,12 @@ class TelephonyProvider(ABC): workflow_run_id: int, params: Dict[str, str], ) -> 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 - provider credentials in the query string (no stored - ``TelephonyConfigurationModel`` row required). ``organization_id`` is - passed so providers can scope any config lookups to the workflow's - org. Default raises so providers that haven't opted in fail loudly. + Used by ``/api/v1/agent-stream/{provider_name}/{workflow_uuid}`` when + the caller carries a provider stream protocol. ``organization_id`` is + passed so providers can scope any config lookups to the workflow's org. + Default raises so providers that haven't opted in fail loudly. """ raise NotImplementedError( f"Agent-stream not supported for provider {self.PROVIDER_NAME}" diff --git a/api/services/telephony/providers/cloudonix/provider.py b/api/services/telephony/providers/cloudonix/provider.py index 31b84fb6..8fd6caf6 100644 --- a/api/services/telephony/providers/cloudonix/provider.py +++ b/api/services/telephony/providers/cloudonix/provider.py @@ -459,10 +459,15 @@ class CloudonixProvider(TelephonyProvider): await websocket.close(code=4400, reason="Expected start event") 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: - stream_sid = start_msg["start"]["streamSid"] - call_sid = start_msg["start"]["callSid"] + stream_sid = start["streamSid"] + call_sid = start["callSid"] except KeyError: logger.error("Missing streamSid or callSid in start message") await websocket.close(code=4400, reason="Missing stream identifiers") @@ -512,48 +517,21 @@ class CloudonixProvider(TelephonyProvider): ) -> None: """Agent-stream entry point. - ``Domain`` (domain id) is read from the query string. The bearer - token comes from the stored Cloudonix telephony configuration - matched by ``domain_id`` within the workflow's organization — never - from the URL or stream payload. The websocket handshake (connected - / start) is identical to the standard inbound flow. + The Cloudonix domain is read from the ``start.accountSid`` field + in the start message. The bearer token comes from the stored + Cloudonix telephony configuration matched by ``domain_id`` within + the workflow's organization — never from the URL or stream payload. + The websocket handshake (connected / start) is identical to the + standard inbound flow. 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 from that configuration. Either failure closes the socket with 4400. """ 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: first_msg = await websocket.receive_text() msg = json.loads(first_msg) @@ -568,24 +546,100 @@ class CloudonixProvider(TelephonyProvider): await websocket.close(code=4400, reason="Expected start event") 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: - stream_sid = start_msg["start"]["streamSid"] - call_sid = start_msg["start"]["callSid"] - call_session = start_msg["start"]["session"] + stream_sid = start["streamSid"] + call_sid = start["callSid"] + call_session = start["session"] + domain_id = self._normalize_domain(start["accountSid"]) 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") 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): await websocket.close( code=4400, reason="Cloudonix session validation failed" ) 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( 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}" ) @@ -628,7 +682,7 @@ class CloudonixProvider(TelephonyProvider): if response.status == 200: return True body = await response.text() - logger.error( + logger.warning( f"Cloudonix session validation failed: " f"HTTP {response.status} domain_id={domain_id} " f"call_id={call_session} body={body}" diff --git a/api/tests/telephony/cloudonix/test_routes.py b/api/tests/telephony/cloudonix/test_routes.py index 31cf220a..b6cc8a92 100644 --- a/api/tests/telephony/cloudonix/test_routes.py +++ b/api/tests/telephony/cloudonix/test_routes.py @@ -6,6 +6,8 @@ or a ``null`` ``session`` / ``disposition``) must produce a graceful error response, not an unhandled ``AttributeError`` (HTTP 500). """ +import json +from types import SimpleNamespace from unittest.mock import AsyncMock, patch 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 async def test_cdr_route_handles_payload_without_session(): """A CDR payload missing the ``session`` object returns a graceful error diff --git a/api/tests/telephony/test_ari_manager_transfer_teardown.py b/api/tests/telephony/test_ari_manager_transfer_teardown.py index 6490d650..b0e5b98e 100644 --- a/api/tests/telephony/test_ari_manager_transfer_teardown.py +++ b/api/tests/telephony/test_ari_manager_transfer_teardown.py @@ -57,7 +57,9 @@ class _RecordingARIConnection(ARIConnection): async def _handle_transfer_failed( 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(): @@ -73,7 +75,9 @@ def _completed_transfer_context(): @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()) monkeypatch.setattr(ari_manager, "db_client", fake_db) conn = _RecordingARIConnection() @@ -89,7 +93,9 @@ async def test_completed_transfer_tears_down_destination_when_caller_leaves(monk @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()) monkeypatch.setattr(ari_manager, "db_client", fake_db) conn = _RecordingARIConnection() diff --git a/api/tests/test_agent_stream_route.py b/api/tests/test_agent_stream_route.py new file mode 100644 index 00000000..b789aa5c --- /dev/null +++ b/api/tests/test_agent_stream_route.py @@ -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() diff --git a/docs/configurations/agent-uuid.mdx b/docs/configurations/agent-uuid.mdx index b7fcf469..386c39b0 100644 --- a/docs/configurations/agent-uuid.mdx +++ b/docs/configurations/agent-uuid.mdx @@ -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/test/workflow/{workflow_uuid}` -- `wss:///api/v1/agent-stream/{agent_uuid}` +- `wss:///api/v1/agent-stream/{provider}/{agent_uuid}` Do not confuse the Agent UUID with an API Trigger node UUID (`trigger_path`). diff --git a/docs/integrations/telephony/agent-stream.mdx b/docs/integrations/telephony/agent-stream.mdx index f08a6b66..5ec64d60 100644 --- a/docs/integrations/telephony/agent-stream.mdx +++ b/docs/integrations/telephony/agent-stream.mdx @@ -5,7 +5,7 @@ description: "Stream audio to a Dograh agent over a WebSocket" ## 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: @@ -23,15 +23,15 @@ This is useful when: ## 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 - 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 @@ -41,25 +41,17 @@ To find and copy it in the UI, see [Agent UUID](/configurations/agent-uuid). ## Connect to the WebSocket -### URL parameters +### Path parameters | Param | Required | Description | | --- | --- | --- | | `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. | -| `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`. | +| `agent_uuid` | Yes | Stable UUID of the Dograh agent to run. | ### Cloudonix example ``` -wss://app.dograh.com/api/v1/agent-stream/{agent_uuid} - ?provider=cloudonix - &Domain={CLOUDONIX_DOMAIN_ID} - &callId={CALL_ID} - &from=+15555550100 - &to=+15555550199 +wss://app.dograh.com/api/v1/agent-stream/cloudonix/{agent_uuid} ``` Use this URL inside the CXML `` 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 `` your Cloudonix Voice Application returns - + ``` -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 @@ -82,8 +74,10 @@ When the WebSocket is accepted, Dograh: 1. Looks up the workflow by `agent_uuid` 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 -4. Transitions the run to `running` and starts the agent pipeline +3. Creates a new `WorkflowRun` (`call_type=inbound`, `mode=cloudonix`, name `WR-AGS-XXXXXXXX`) +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. @@ -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 | | `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 - 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.