ari-controller¶
Status: Current | Last reviewed: 2026-10-13
Asterisk's REST + WebSocket API client. Every dialplan decision that needs program logic — answer, bridge, transfer, hangup — runs through here.
Voicebot sessions forward validated Amazon Connect contact IDs through
MediaProxy's custom-header transport. mediaProxySipHeaders joins the separately
parsed system IDs with ordinary SIP headers, replaces competing custom claims,
and omits malformed UUIDs. This retains the original contact identity for a
Voice Agent-to-Copilot handoff; the CCaaS integration gate still runs first.
External media creation can return before Asterisk emits StasisStart.
addChannelWhenReady waits for that event before bridging external media
as well as ordinary channels, then permits bounded retries for a transient
“Channel not in Stasis” response. The event handler marks readiness before
filtering external-media events with empty application arguments. Skipping
this wait caused intermittent immediate disconnects during Connect startup.
Tests cover delayed readiness, transient rejection, timeout and retry exhaustion.
Source¶
Runtime¶
| Aspect | Value |
|---|---|
| Language | TypeScript (Node) |
| Wire protocol | Asterisk ARI (REST + WS on :8087) |
| Stasis app | trueserve |
What it does¶
- Subscribes to ARI events for the
trueserveStasis app. - On
StasisStart, looks up the dialed extension, decides whether it's an inbound call to an agent, an outbound, or a consult-transfer leg. - Bridges channels for warm hand-off (consult-service.ts).
- Plays prompts, ducks audio, handles DTMF.
- Calls ai-orchestrator over gRPC to start a session and stream media via media-proxy.
- Owns the copilot sidecar allocation (
agentAssistAlloc) as well as the voice leg. Persist it before linking audio, then release its port during call teardown. The sidecar has no Asterisk channel, so hanging up the voice channel alone leaves it running until the idle reaper.
CCaaS allow-list gate (default-deny)¶
Every inbound call carrying CCaaS identification is validated before a session
starts — voice agents, translation pipelines, and copilots alike, not just
AudioHook routing. The flow in src/index.ts
onStasisStart:
- Detect the platform (
ccaas-detector.ts):X-Genesys-Organization-Id→ genesys,X-Amzn-SourceArn→ amazon_connect. Internally-originated legs the controller signed (dial-through, consult, ai-manager test calls, emulator) bypass validation. - Resolve the integration —
getCcaasIntegrationByIdentifiermatches the org id / source ARN against activeintegrationsrows. No match → hangup. - Check the allow-list —
getPipelineConfigadds anEXISTS (SELECT 1 FROM ccaas_pipeline_integrations …)clause keyed on(pipeline_id, integration_id, enabled). No enabled row → the lookup returns nothing and the call is rejected withpipeline is not published for the validated CCaaS integration. - Except an External Consult leg we asked Amazon Connect to dial, which
comes back as an ordinary inbound Connect call and is authorized by
ContactId correlation instead: the leg's
initialContactId/contactIdmust match a consult this process is waiting on, the arrivingsource_arnmust resolve to the same integration we dialed through, and the dialer agent's config must be published — unlessconsult-draft-gate.tsallows the draft, which it does only when the consult's origin is a live browser session we synthesized (an Agent Studio Preview call) whoseconsult.consultIdis this leg's. Only the allow-list join is dropped — a mismatch on any of the three is treated as a miss, so default-deny runs. See warm-handoff.md and Rehearsing a consult from the Preview panel.
The allow-list is default-deny: a freshly published agent receives zero
CCaaS calls until at least one integration is enabled for it. Operators
control it from the agent publish dialog (CCaaS Availability section),
which folds the save into the publish request
(POST /api/pipelines/:id/versions/publish body field ccaasIntegrationIds),
or programmatically via PUT /api/pipelines/:id/ccaas-settings. Rows are
keyed on the base agent id, so a change applies to all versions of the
agent immediately.
The table was renamed from genesys_pipeline_integrations in init-script 194
(it always validated every CCaaS platform, not just Genesys); 194 also
backfilled all existing agents onto every active CCaaS integration in their
tenant and left a security_invoker compat view under the old name for
rolling deploys (dropped in a follow-up migration).
Internal-leg signature¶
Telephony Preview correlation¶
GET /telephony-preview/<sessionId> uses the same internal bearer authorization
as other controller endpoints. It accepts only a UUID and reads the exact
TSF_PREVIEW_<sessionId> Asterisk global across connected instances. The
dialplan captures this from the outbound Genesys SIP answer's x-inin-cnv
header and clears it on hangup. A missing answer returns {conversationId:null};
conflicting IDs or unavailable connections fail with 503.
AI Manager checks user/tenant ownership of its server-issued session before calling this endpoint, then retains the binding for post-call Thinking. This endpoint observes an existing call; it does not start, bridge, or transfer one. See Telephony Preview.
Signed internal legs¶
A leg the controller or ai-manager originated proves it with X-Internal-Leg-Sig, an HMAC-SHA256 (base64url) over:
callSource:convId:pipelineId:sha256hex(uui)
Verifying makes the leg trusted internal, which (a) bypasses CCaaS identification/validation entirely and (b) unlocks UUI fields only a trusted leg may set. Everything else stays default-deny. The signature binds the exact semicolon-form UUI; Agent Studio Translation Preview must therefore pass its User-to-User value to AI Manager's signer instead of requesting a UUI-less signature. The secret is ARI_INTERNAL_LEG_SECRET (falling back to ARI_CONTROLLER_INTERNAL_TOKEN); the crypto lives in src/utils/internal-leg-sig.ts with the secret dependency-injected so it's unit-testable, and apps/ai-manager/lib/internal-leg-sig.ts is its lockstep counterpart.
The UUI is in the signed payload, and that's load-bearing. The UUI carries dial-out routing instructions (dest, routingtype, routeto). A signature covering only the call source and ids would let a browser-minted leg — e.g. the Dial & Translate embed — edit routeto after ai-manager granted the call and place arbitrary PSTN calls on the tenant's trunk. The signature is computed over the UUI's semicolon form: the dialplan hands it to Stasis pipe-encoded, and onStasisStart decodes |→; before both parsing and verifying.
The legacy payload form (callSource:convId:pipelineId, no UUI hash) is still accepted, but only when the leg carries no UUI at all. That's rollout compatibility for an ai-manager that hasn't been redeployed yet — plain test calls keep working, and nothing can dial out on a legacy signature. Consequence for deploys: ari-controller goes first, ai-manager second. The reverse order breaks nothing existing, but Dial & Translate only works once both are live.
ai-manager may only mint signatures for browser-reachable sources (ai-manager-test, ai-manager-livekit, emulator-embed); dial-through, consult, and translated_escalation are signed inside the controller when it originates those B-legs.
The packaged Asterisk dialplan must preserve both halves of this contract. It reads X-Internal-Leg-Sig, passes it as argument 7 through [ai-bridge-launch], and places it in Stasis argument slot 10 (args[9]). It must also preserve the exact signed UUI, including a simple ;encoding=ascii suffix; truncating at ;encoding changes the HMAC input. The characteristic failure is Janus registering and receiving SIP accepted, followed immediately by Q.850 cause 21 Call Rejected, with no conversation or AI Orchestrator session. The chart contract is pinned by dialplan-internal-leg-contract.test.ts.
Trusted-leg-only UUI fields¶
| UUI field | Session field | Note |
|---|---|---|
fqdn |
ccaasByocFqdn |
The BYOC trunk to dial out over. Only honoured on a trusted internal leg — otherwise an untrusted caller could redirect the outbound leg. Without it, internal legs fall back to Kamailio's GENESYS_BYOC_FQDN default, which is wrong in a multi-tenant deployment. |
peer_lang |
uuiPeerLanguage |
The far side's language, paired with lang for the translation override. Not privileged, but new alongside fqdn. |
Unrecognized keys land in uuiCustomFields and are forwarded as session_variables. See Dial & Translate for the full contract.
Warm hand-off¶
src/consult-service.ts implements the consult-then-transfer flow:
- Agent (caller) and bot are bridged.
- Bot tool result triggers
transfer. - Controller dials the target, parks the caller, bridges agent + target.
- On confirm, drops the bot leg and bridges caller ↔ target.
Full doc: warm-handoff.md.
A browser caller has no channel, so its session is synthesized¶
A Preview/Studio test call reaches the agent over direct WebRTC and never enters Asterisk, so there is no session to look up and no caller channel to hold or bridge. resolveOriginSession in src/services/consult-service.ts synthesizes one from five values the orchestrator sends on POST /consult/{id}/start — transport, media_proxy_session_id, tenant_id, agent_id, and the authored consult_provider, all snake_case in the body. The fifth exists because the other four cannot name a CCaaS platform: on a real call that is session.ccaasType, a property of the arriving leg, and a browser call has no arriving leg. It is honoured only for transport === 'webrtc_direct' — on a SIP leg ccaasType is validated fact and an authored value must never touch it — and gated on truthiness, so an unauthored selector omits the key and every existing tenant is byte-identical. A Connect one routes to synthesizeConnectOriginSession, which resolves the tenant's Connect consult routing and refuses with a 409 before writing any session, reusing the same resolver a real Connect call uses so the two refusals are word-for-word identical.
pickProviderForSession (src/providers/factory.ts) dispatches on two independent dimensions, not a priority list: caller transport (webrtc_direct) and CCaaS platform (ccaasType). Both can hold at once, and because the two wrappers replace disjoint operation sets they simply stack — MediaProxyConsultProvider(AmazonConnectConsultProvider(GenesysByocProvider)), cached as media_proxy_amazon_connect. All of them compose the SIP provider rather than subclassing it, so each replaces only the operations its transport or platform actually changes: the Connect provider replaces originateConsult alone (it asks Connect to dial via StartOutboundVoiceContact, then waits up to CONNECT_CONSULT_INBOUND_TIMEOUT_MS — default 45 000 ms — for the answered leg to arrive back as inbound SIP, so hold/merge/cancel delegate unchanged), while the transport wrapper replaces hold/merge/cancel and delegates origination. The transport wrapper must be outermost, since it is what the consult service calls; inverted, hold/merge/cancel would reach Asterisk for a caller that has no channel there and 404. Both wrappers report the same name, so the tests assert cached-instance identity plus the _sip delegate chain. A browser Preview consult therefore rehearses either platform — see warm-handoff.md.
The gate order is the contract:
| Condition | Answer |
|---|---|
| A session already exists | use it, refreshing transport/mediaProxySessionId (a browser reload returns on a new media-proxy session under the same conversation id) |
transport !== 'webrtc_direct' |
404 — still the right answer for SIP and anything unrecognised |
no media_proxy_session_id |
400 naming the field |
no tenant_id |
400 naming the field |
getOutboundRouting(tenantId) is null |
409 — "Enable External Consults and set a Routing Number on the integration" |
Past that gate, startConsult re-checks the tenant's transfer_policy as defense in depth. A non-empty allowed_destination_regexes must match both the dial number and any X-Target-DNIS header; an absent or empty list — the default — restricts nothing, matching the orchestrator's own fail-open gate. Treating a present X-Target-DNIS as an LLM-chosen value and denying it on an empty list used to 403 every externally-routed consult; see warm-handoff.md.
The browser transport removes the caller's channel requirement, not the trunk requirement: the consult B-leg still dials out through the tenant's routing number. A synthesized session is only ever written to Redis, so SESSION_TTL_SECONDS cleans up after a caller who closed the tab mid-consult — there is no StasisEnd to do it. session.uuiAni is set to the tenant's routing number because a browser caller has no ANI and some carriers reject an empty caller id.
Dependencies¶
| Service | Purpose |
|---|---|
| Asterisk | ARI on :8087; PJSIP for SIP signaling |
| ai-orchestrator | Session start; gRPC :50051 |
| media-proxy | Audio streaming |
| Postgres | Looks up dialed-extension routes |
Vanguard¶
overmind restart ari-controller
Common bug: "Asterisk ARI not ready after 60s" — usually means controller raced Asterisk boot. overmind restart ari-controller once Asterisk is up at :8087 (NOT 8088 — that is Janus).
Production¶
Helm chart at charts/ari-controller. One replica; Asterisk only emits ARI events to one subscriber per Stasis app.
See also¶
- Warm hand-off
- Real-time translation — incl. the Dial & Translate embed demo, which relies on the internal-leg signature above
- Voice request lifecycle
- Asterisk