Skip to content

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

apps/ari-controller-node

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 trueserve Stasis 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:

  1. 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.
  2. Resolve the integration — getCcaasIntegrationByIdentifier matches the org id / source ARN against active integrations rows. No match → hangup.
  3. Check the allow-list — getPipelineConfig adds an EXISTS (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 with pipeline is not published for the validated CCaaS integration.
  4. 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 / contactId must match a consult this process is waiting on, the arriving source_arn must resolve to the same integration we dialed through, and the dialer agent's config must be published — unless consult-draft-gate.ts allows the draft, which it does only when the consult's origin is a live browser session we synthesized (an Agent Studio Preview call) whose consult.consultId is 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:

  1. Agent (caller) and bot are bridged.
  2. Bot tool result triggers transfer.
  3. Controller dials the target, parks the caller, bridges agent + target.
  4. 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