External Consult (warm hand-off)¶
Status: Current | Last reviewed: 2026-09-02
External Consult is the product name for the capability historically called
"Warm Transfer" / "Handoff". The rename is UI + code-naming only — the
storage keys are unchanged: the node built-in is still builtin:warm_transfer,
its config still lives at ServiceStage.config.builtin_tool_configs.warm_transfer
(WarmTransferBuiltinConfig), and predefined profiles still live in the
warm_transfer_profiles table (/api/warm-transfer-profiles). Only the labels
(node built-in name, the Tools & Templates tab, the settings tab, the profile
editor) and the ai-manager component names (ExternalConsultForm,
ExternalConsultCapability, ExternalConsultPanel) changed.
A hand-off where the original AI agent stays on the line during the consult. Distinguished from blind transfer (drop and dial).
The flow¶
1. AI agent talking to caller.
2. AI decides to hand off (rule-based or tool-call).
3. ari-controller spins up an outbound channel to the consult agent (human or another AI).
4. All three legs (caller, AI, consult) bridge together briefly:
- AI explains context to consult.
- Consult acknowledges.
5. AI exits the bridge.
6. Caller and consult continue without AI. If the selected Transfer Agent has
post-merge translation configured and its activation criteria resolve true,
ari-controller now starts the configured bidirectional translation pipeline.
The variant where AI sticks around longer is configurable via HoldMode and PostMergeMode.
Config schema¶
Warm transfer is a node built-in (builtin:warm_transfer), configured on a voice agent's LLM/S2S stage. Its config lives at ServiceStage.config.builtin_tool_configs.warm_transfer (WarmTransferBuiltinConfig) — the same per-node pattern as IVR Navigation. Presence of the builtin:warm_transfer ref in the stage's tools[] is the enable gate (there is no enabled field on the node config).
WarmTransferBuiltinConfig {
provider?: ConsultProvider // "genesys_byoc" | "amazon_connect" — read only for a browser caller
consult_agents?: WarmTransferDestination[]
hold_mode?: HoldMode // "talk_and_listen" | "listen_only" | "music"
post_merge_mode?: PostMergeMode // "drop_both_agents" | "keep_agent_a" | "keep_both"
consult_timeout_sec?: number
}
WarmTransferDestination extends ConsultAgentEntry { // label, consult_agent_id, destination, condition?
inline?: { briefing_script?, ivr_navigation_goal? } // per-call brief overrides
profile_name?: string // if prefilled from a saved profile
}
TransferAgentBuiltinConfig {
prompt_override?: string
post_merge_translation?: {
pipeline_id: string // fixed published translation pipeline
activation: "always" | "agent_decides"
language_resolution?: "pipeline_defaults" | "runtime_or_defaults"
criteria_prompt?: string
}
}
Defined in apps/ai-manager/lib/pipeline-types.ts. The legacy cross-cutting transfer block on agent_versions.config (TransferConfig) is still read as a back-compat fallback at runtime; migration 004_migrate_warm_transfer_builtin.sql moves existing origin agents' config.transfer onto the owning node (consult-role agents keep agent-level transfer).
The External Consult form (what the editor authors)¶
The reworked editor is the shared ExternalConsultForm (used by both the inline node capability and the profile panel). It captures Provider, Target DNIS (a static number, or "let the agent choose at call time"), Outbound Agent (a voice agent with the Transfer Agent capability), Place caller on hold during consult (on → hold_mode: 'music', off → 'talk_and_listen'), Timeout (default 180s → consult_timeout_sec), a reuse a predefined profile picker, plus advanced Consult prompt / IVR goal prompt / routing rule / post-merge. The builtin_tool_configs.warm_transfer ↔ form mapping is the adapters module (builtinConfigToForm / formToBuiltinConfig); Target DNIS maps to destination.headers['X-Target-DNIS'] (static) or destination.allow_llm_chosen_dnis (LLM). hold_mode: 'listen_only' is dropped from the UI (still read for legacy configs).
The two inline fields are not symmetric — author the IVR goal, leave the briefing alone¶
inline.briefing_script and inline.ivr_navigation_goal sit next to each other in the form (Consult prompt / IVR goal prompt) and behave in opposite ways, because only one of them has a runtime fallback:
briefing_scriptcompetes with a better value.start_consult'scontextargument is required by the tool schema (consult_tool_helper.py), so the origin agent always writes a per-call briefing naming the customer, the intent, and what's been tried.consult.pyprefers an authoredbriefing_scriptand only falls back tocontext— so authoring one replaces that live summary with the same fixed words on every consult. Leave it blank unless the wording genuinely must not vary (compliance script, a fixed disclosure).- Automatic IVR navigation requires a CASCADED transfer agent — on an S2S stage the wrap is skipped with a warning. Pipecat's
IVRNavigatoris a text protocol: a classifier prompt instructs the LLM to answer<mode>ivr</mode>/<mode>conversation</mode>, and the navigator strips those tags out of the LLM's text stream. An S2S service (gpt-realtime, Nova Sonic, Gemini Live) speaks its output directly, so the tag was spoken to the callee (heard as "mode IVR", by the customer too undertalk_and_listen) and the navigator never engaged, while the model ran under the classifier prompt instead of the transfer agent's own instructions.maybe_wrap_in_ivr_navigator(post_llm.py) now refuses to wrap atype: s2sstage — the agent runs on its authored prompt (which already handles the voicemail/refusal failure paths viaabort_consult), and IVR menus are simply not auto-navigated. Fixed 2026-08-13. ivr_navigation_goalhas nothing to fall back to. Every transfer-agent template pinsbuiltin_tool_configs.ivr_navigation.goal_mode: 'template'withgoal_template: "{{ivr_navigation_goal}}", and an unauthored goal leaves that token unresolved.format_ivr_goal(ivr_integration.py) used to hand the literal string{{ivr_navigation_goal}}toIVRNavigatoras its entire navigation prompt; it now detects a goal that resolved to nothing but unresolved tokens and uses the conservative default ("Navigate the IVR until you reach a human representative") with a warning. A partially-unresolved goal still keeps its tokens in place, matching the prompt token resolver. So the default is safe, but a useful goal — which menu option, never voicemail, never self-service — is authored here or nowhere.
provider is read on exactly one path: a browser caller. On a real call the platform is a property of the leg that arrived — ari-controller reads session.ccaasType, set by the CCaaS gate, and detection has to win, because a Genesys-authored agent answering a Connect call has a Connect leg and nothing else. A Preview test call has no CCaaS leg at all, so the authored value is the only thing that can name who dials the specialist; it travels as consult_provider on POST /consult/{id}/start and becomes the synthesized session's ccaasType. Changing it therefore changes Preview's behaviour and nothing else's. pickProviderForSession (factory.ts) then dispatches on two independent dimensions — caller transport and platform — not on a priority list. See Rehearsing a consult from the Preview panel and Amazon Connect.
The dial number comes from the integration Routing Number¶
The dial target is no longer authored per-destination. The Genesys integration carries an External Consults section (external_consults_enabled + consult_routing_number, in integration-capabilities.ts); every consult dials that single Routing Number, and per-destination routing (queue / target DNIS) rides the SIP headers. The orchestrator resolves it at config-assembly time in resolve_consult_dial_numbers (node_builtin_config.py): each consult destination without its own dial_number gets the integration's consult_routing_number injected, so the runtime start_consult (which reads destination.dial_number) is unchanged. Legacy configs that still embed a dial_number are left untouched. A third fallback covers a tenant with no Genesys integration at all: a destination whose own X-Target-DNIS header is authored gets that value as its dial_number (matched case-insensitively, because ari-controller parses SIP headers that way). That is what makes consult work on Amazon Connect, where the specialist is dialed directly and there is no routing-number indirection. A destination with neither still gets nothing written, so the runtime's own "missing a dial_number" refusal is unchanged.
Publish-time validation follows the same rule: a per-destination dial_number is an override, not a requirement, so it is range-checked against the tenant policy only when one is authored. It had been required, which meant no agent authored through the current form could be published at all.
Tenant transfer policy narrows what a consult may dial — it never enables it¶
The optional transfer_policy tenant setting (Tools & Templates → External Consult) carries allowed_destination_regexes. It is a narrowing control, and almost no tenant configures one:
- Absent or empty list = no restriction. The orchestrator's
_check_tenant_policy(consult.py) fails open, andstartConsultinconsult-service.tsnow agrees.enabled: falseis the same as absent. - Non-empty list = both the dial number and any
X-Target-DNISheader must match it, checked again in the controller as defense in depth. Provenance is deliberately not part of that decision: the check exists for a caller that bypassed the orchestrator, so "the orchestrator says the model did not choose this DNIS" is not a fact the controller could trust.
The controller previously treated any X-Target-DNIS as an LLM-chosen value and rejected it when the regex list was empty. External routing requires that header and it is normally authored on the destination, so with no policy configured — the default — every externally-routed consult returned 403 Destination <n> blocked by tenant policy, which the origin agent relays to the caller as a system restriction. Fixed 2026-08-12.
There is a third gate, and it made the same mistake independently: ai-manager's publish-time validateAgentConfigForTenant (agent-config-validation.ts, called from the publish route and lib/db.ts) required an explicit policy before it would accept an authored X-Target-DNIS at all, so the destination could not be saved even after the controller stopped refusing to dial it. Also fixed 2026-08-12. Any future gate must fail open the same way — three checks that disagree mean the strictest one silently decides, in whichever layer nobody is reading logs for.
Where the capability lives in the editor¶
External Consult is a Capabilities-section item (next to Multimodal UI) in NodeCapabilitiesAndBehaviors, not a selectable entry in the Tools picker anymore (appendNodeBuiltIns no longer offers warm_transfer). Two variants of ExternalConsultCapability:
- inline (native voice agents) — toggling it adds/removes
builtin:warm_transferon the stagetools[]and editsbuiltin_tool_configs.warm_transfervia the shared form. - gecx — GECX calls the
warm_transferMCP tool rather than the orchestrator consult tools, so the capability pins a predefined profile (ces_integration.warm_transfer_profile) and shows a consult-scoped MCP URL; enabling adds the plainwarm_transfertool to the CEStools[]so the MCP?tools=filter includes it. GECX supplies the Consult prompt (briefing_script) + IVR goal prompt (ivr_navigation_goal) at call time.
The transfer agent provides the B-leg model¶
The outbound "consult agent" that dials out, navigates IVR, and briefs the human is a separate voice agent (consult_agent_id) — its own agent_versions.config.pipeline[] supplies the B-leg's service/model. The origin agent never specifies a transfer model. The built-in External Consult Agent template (tpl-outbound-transfer-agent — id unchanged) is the starting point; the form's "Create External Consult agent" link opens the New Agent wizard preselected to it. The Outbound Agent dropdown lists only voice agents that declare the Transfer Agent capability (agentHasTransferAgentBuiltin).
X-Pipeline-Id is what puts that agent on the B-leg¶
The consult agent id travels to ari-controller as the X-Pipeline-Id SIP header on POST /consult/{id}/start. GenesysByocProvider.originateConsult promotes it to pipelineIdOverride, which becomes the originated leg's Stasis appArgs[0]. With the header absent the override is undefined and the B-leg inherits the origin session's pipeline id — so the "specialist" that answers is a second copy of Agent A, and it briefs itself. Nothing errors: origination, answer, and the voicebot build all succeed, and the only signal is the wrong pipelineId on the Session created/updated log line for the consult-<uuid> conversation.
agent-host's MCP warm_transfer tool always sent it (effective_dialer_agent_id); the in-orchestrator built-in did not, so the native path silently mirrored Agent A onto the consult leg — including its published version, which is often a different provider than the draft under test. start_consult now sets it from the resolved destination's consult_agent_id, and identification wins over an authored header of the same name (same rule as the MCP tool). Fixed 2026-08-12.
Marking an agent as a transfer (consult) agent — builtin:transfer_agent¶
An agent becomes the consult leg by selecting the builtin:transfer_agent node built-in on its voice LLM/S2S stage (config at ServiceStage.config.builtin_tool_configs.transfer_agent, TransferAgentBuiltinConfig) — the symmetric counterpart to builtin:warm_transfer on the origin side. It carries no tool list of its own: the orchestrator auto-injects the consult-side tools (send_to_origin / request_merge / abort_consult) and the consult-participant system prompt when the built-in is present. Authors can override the briefing prompt and optionally configure post-merge translation. This is also how the origin's transfer-agent dropdown discovers transfer agents — ServiceConfigForm scans each agent's config for the built-in (no separate flag). The dropped agents.role_hint column is no longer used; the orchestrator keeps is_consult_role (honoring legacy config.transfer.role = 'consult') only as a back-compat fallback.
Dynamic translation after merge¶
post_merge_translation.pipeline_id is an authoring-time authority: neither AI
agent can select or replace it. activation: always turns it on for every
successful merge. agent_decides adds translation_needed and the authored
criteria_prompt to request_merge (and to the legacy
signal_dialer_ready contract). With runtime_or_defaults, the consult agent
may also report customer_language and specialist_language; they must be a
complete pair of concrete BCP-47 tags. The pair is role-stable and maps to
MediaProxy's callerLanguage / agentLanguage keys. If neither is reported,
the translation pipeline's authored defaults apply. pipeline_defaults hides
and ignores runtime language fields.
The consult agent may speak Spanish—or any language supported by its own model and prompt—while it briefs the specialist. That is ordinary B-leg agent audio, not the translation pipeline. Translation remains off until this ordered runtime boundary:
- Agent B records the normalized merge decision in AgentBus (or the legacy Redis ready envelope).
- Agent A asks ari-controller to merge and forwards only that decision.
- ari-controller reloads Agent B's current published config in the session's tenant, validates the fixed translation pipeline as published, translation subtype, and enabled for the call's integration, and validates the complete language pair.
- If any preflight check fails, the merge is rejected before either AI agent is removed.
- The call-control provider merges the two humans and drops both AI agents.
- ari-controller changes the surviving session to translation mode and calls
buildTranslationfor the two human channels.
This path supports SIP/Genesys BYOC and Amazon Connect (whose returning consult leg becomes SIP before merge). Direct WebRTC Preview can run an untranslated consult, but cannot build the existing Asterisk ExternalMedia translation topology. A translation-required Preview merge is therefore rejected during step 4, with both agents still connected; use a real SIP or Connect call to test the translated merge.
Key files¶
| Layer | File | What it does |
|---|---|---|
| UI registry | apps/ai-manager/lib/node-builtins.ts |
Registers builtin:warm_transfer (name "External Consult", singleton, voice-only stageTypes); no longer offered by appendNodeBuiltIns (Capabilities-only) |
| UI capability | apps/ai-manager/components/tools/external-consult/ExternalConsultCapability.tsx |
The Capabilities-section toggle + editor (inline + gecx variants) |
| UI form + adapters | ExternalConsultForm.tsx + adapters.ts |
Shared form + config-shape mapping |
| UI profiles panel | apps/ai-manager/components/tools-templates/ExternalConsultPanel.tsx |
Predefined profiles + tenant policy (Tools & Templates → External Consult) |
| UI types | apps/ai-manager/lib/pipeline-types.ts |
Schema |
| Runtime read | apps/ai-orchestrator/lib/pipecat/pipeline/node_builtin_config.py |
warm_transfer_config_for_stage_config rebuilds the transfer dict + resolve_consult_dial_numbers injects the integration Routing Number; its gate reads authored_tool_refs (never config.tools, which the builder overwrites) |
| Runtime tool injection | apps/ai-orchestrator/lib/pipecat/pipeline/consult_tool_helper.py |
Adds the origin consult tools to the LLM |
| Runtime HTTP client | apps/ai-orchestrator/lib/pipecat/services/consult_client.py |
Calls ari-controller's /consult/* routes. Sends Authorization: Bearer $ARI_CONTROLLER_INTERNAL_TOKEN on every request (they are all behind authorizeInternal) and defaults to ari-controller-node:3000 |
| Call control | apps/ari-controller-node/src/services/consult-service.ts |
Drives Asterisk ARI Channels API to set up the consult bridge |
| Connect provider | apps/ari-controller-node/src/providers/AmazonConnectConsultProvider.ts |
StartOutboundVoiceContact + await the leg Connect hands back; StopContact on teardown. Composes GenesysByocProvider for hold/merge/cancel |
| Connect correlation | apps/ari-controller-node/src/services/connect-consult-registry.ts |
The pending-ContactId map and the await/resolve handshake that recognises the returning leg |
| Provider selection | apps/ari-controller-node/src/providers/factory.ts |
pickProviderForSession — caller transport and CCaaS platform as two independent dimensions, transport wrapper outermost |
| Draft gate | apps/ari-controller-node/src/services/consult-draft-gate.ts |
The one place an inbound leg may run an unpublished config: a consult whose origin is a Preview call |
How it lights up at runtime¶
- Builder phase:
universal_builder.pyreads the per-node config viawarm_transfer_config_for_stage_config(raw_config)(falling back to the legacy agent-levelconfig.transfer); when transfer config is present it wires the origin consult tools (add_origin_consult_tools) plus the origin system prompt. Per-destination inlinebriefing_script/ivr_navigation_goalare stashed in the Redis dialer brief bystart_consultso the transfer agent picks them up as session variables.
The gate is the AUTHORED tool list, which the builder overwrites mid-build. warm_transfer_config_for_stage_config reports the built-in present only if builtin:warm_transfer is in the stage's tool refs — but _resolve_tool_schemas replaces config.tools in place with a resolved ToolsSchema object and writes it back onto the service, and _build_llm_context (where both consult gates run) executes after that. So the gate saw a non-list, reported the built-in absent, and fell through to the legacy agent-level config.transfer. Every gate in node_builtin_config.py now reads authored_tool_refs(stage_config), which prefers the _authored_tool_refs sidecar the overwrite leaves behind (the same sidecar the {{TOOL:Name}} PromptResolver already depended on). Customer Authentication and Payments were unaffected only because their gates happen to run inside _resolve_tool_schemas, before the overwrite — that is positional luck, not design, so use the helper for any new gate.
An enabled transfer config with no reachable destination no longer advertises the tools. A destination is reachable only if it carries a consult_agent_id (start_consult keys its whole destination map on that field), and an empty External Consult panel leaves a blank stub row behind. usable_consult_destinations is checked at build: zero destinations logs Warm transfer is enabled … but no consult destination has a consult_agent_id … consult tools NOT exposed and withholds the tools, rather than handing the model four tools that can only ever return an error. The success path logs the ids and which config won: Warm transfer destinations for service <id>: ['4217'] (source=stage builtin).
Advertising the tool is only half the wiring — the other half is the callback. OriginConsultToolHandler / ConsultParticipantToolHandler are FrameProcessors, but their process_frame branch keys on FunctionCallFromLLM, which is a plain dataclass rather than a Frame (pipecat's run_function_calls broadcasts FunctionCallsStartedFrame and never pushes one), so that branch is reachable only from the Google-ADK frame bridge. What makes the tools run on every provider is _register_local_tool_callbacks → provider_tool_registration.py, which registers each name on the live LLM service so the model's call lands on handler.execute_tool_call. Confirm it in the build log: [LocalTools] Registered origin-consult tools ['start_consult', …] with <ServiceClass>.
- Tool call: the LLM emits
start_consult({ context, consult_agent_id?, destination?, hold_mode? }). (transfer_to_consultis not a tool name — the origin set isstart_consult/send_to_consult/merge_calls/cancel_consult.)
consult_agent_id is optional and is a hint, not a key. It is an internal agent id ("4217") that lives in the agent's config and never in the conversation, so a model asked for one supplies a plausible semantic slug instead. _resolve_destination (tools/builtin/consult.py) resolves in order: exact id → destination label (slugified both sides) → the only configured destination, which is the overwhelmingly common shape and where naming one carries no information at all. Only a real ambiguity — 2+ destinations and no usable hint — is an error, and that error enumerates the ids so the model can retry in the same turn. render_origin_consult_prompt renders the actual destinations into the system prompt: with one it says "chosen automatically … do NOT pass consult_agent_id", with several it lists id — label.
- Tool execution: the orchestrator posts to
ari-controller(POST /consult/{session_id}/start, bearerARI_CONTROLLER_INTERNAL_TOKEN).consult-service.tsthen: - Resolves the caller's session — looked up for a SIP caller, synthesized for a browser one (see Two transports).
- Creates a new outbound channel via ARI to the consult agent's contact (SIP URI, queue ID, or another agent ID).
- Bridges that channel to the existing call's bridge, or links it to the browser leg in media-proxy.
- Plays the
prompt_for_consultgreeting via TTS into the bridge.
The body carries the caller's own leg for a browser call. start_consult adds transport / media_proxy_session_id / tenant_id / agent_id when — and only when — the caller reached us over direct WebRTC; a SIP call omits all four because the controller already owns its session. The keys are snake_case, matching what handleConsultStart reads; a camelCase key there is silently ignored, which presents as the 404 below.
-
Hold mode: while waiting for the consult agent to answer, the caller hears whatever
hold_modesays: music (default music-on-hold), silence, or the AI continuing to converse. -
Three-way merge: when consult answers, the bridge has all three legs briefly. The AI says
prompt_for_agentto introduce the situation. -
Post-merge: based on
post_merge_mode: ai_exits— AI's leg unbridges; caller and consult continue alone. Most common.ai_observes— AI stays in but is muted; can resume if called back.ai_continues— AI stays speaking; consult and AI co-handle the call.
Choosing a consult agent¶
consult_agents[] holds entries that resolve to:
- A SIP URI directly.
- A Genesys queue (resolved by Genesys API).
- An Amazon Connect queue ARN.
- Another TrueServe agent (recursively — chained AI handoffs).
Resolution happens at the moment of transfer; the LLM picks an entry by index based on the reason and urgency it provided.
Routing rules (per-destination gates)¶
Each consult_agents[] entry may carry an optional condition (authored in the External Consult form's advanced "Routing rule" row, HandoffRoutingCondition in pipeline-types.ts) — GECX-style routing beyond the main prompt:
{kind: 'code', expr: 'language == "es" and tier in ["gold"]'}— a restricted boolean expression over the session variables (ani/dnis/lifecycle outputs/…), evaluated by the sharedtrueserve_logicwhitelisting AST walker (libs/logic-expressions/, wrapped fail-open by handoff_rules.py; nevereval; comparisons, and/or/not, in/not in, literals, bare names — no calls or attribute access). Grammar + dialect table: agent-logic.md.{kind: 'llm', prompt: '…', model?}— one small-model completion (defaultgpt-4o-mini) judging the rule against the handoff briefing; must answer ALLOW or DENY.
start_consult evaluates the rule before dialing; a deny returns a tool error the LLM can act on (pick another destination, keep assisting). Rules fail OPEN on evaluation errors — a typo in a routing rule must not strand a caller who needs a human. Session variables reach the evaluator via OriginConsultToolHandler(session_variables=…) → ToolContext.extras.
The same code-or-LLM gate exists on workflow triggers ("callbacks"): trigger_rules.condition (migration 195), authored in the workflow editor's conversation trigger cards (TriggerConditionEditor), evaluated by async-agent's EventRouter (conditions.py) after the structured criteria match and before job dispatch — also fail-open.
IVR navigation (outbound agents)¶
Outbound agents — warm-transfer dialer B-legs and general outbound callers — often hit a phone menu before reaching a human. Selecting builtin:ivr_navigation on an LLM/S2S node opts that node into Pipecat's IVRNavigator: the universal builder wraps only the owning service (_maybe_wrap_in_ivr_navigator → ivr_integration.py), which auto-classifies incoming audio as IVR vs human and drives DTMF from the model's <dtmf>/<ivr> tags; when a human answers, the agent's normal prompt takes over.
- UI: select IVR Navigation in the owning LLM/S2S node's Properties → Tools picker, then open the selected row's settings. The goal is either fixed (
builtin_tool_configs.ivr_navigation.goal, e.g. "reach the billing department") or templated from session variables (goal_template, e.g.{{ivr_navigation_goal}}— what thewarm_transferMCP tool's per-call brief supplies to dialer agents). Empty goal defaults to "reach a human representative". - Persistence: IVR config is per-node at
ServiceStage.config.builtin_tool_configs.ivr_navigation(carried generically by the stage config in the collab shape), same as warm transfer. - Manual keypresses: independent of the navigator, any agent with
builtin:send_dtmfcan press keys; the tool accepts digit sequences (digits='4815162342#', auto-paced) for "enter your account number" prompts.
What the LLM sees¶
consult_tool_helper.py adds:
- A system-prompt section built by
render_origin_consult_prompt(transfer_config), which enumerates the agent's actual destinations (or, for the single-destination case, states that the choice is automatic and the id must be omitted). The staticORIGIN_CONSULT_PROMPTalone used to say "call start_consult with the matching consult_agent_id" while listing nothing — which is exactly what produced invented ids. - Function-calling tool definitions for
start_consult/send_to_consult/merge_calls/cancel_consult.
The prompts use whatever prompt_for_agent / prompt_for_consult the config carries. Default templates exist for both.
An author can also name the consult tools explicitly from their own prompt: with External Consult enabled, the prompt editor's Tools dropdown offers start_consult, send_to_consult, merge_calls and cancel_consult (the participant leg's builtin:transfer_agent offers send_to_origin, request_merge, abort_consult), each inserted as a {{TOOL:<name>}} token. The bare slug warm_transfer is deliberately not offered — it is the name of none of the seven tools, so a {{TOOL:warm_transfer}} token would reach the model as literal text. The insertable set lives in BUILTIN_PROMPT_TOOL_NAMES (node-builtins.ts) and is mirrored, drift-guarded, by BUILTIN_CAPABILITY_TOOL_NAMES in token_resolver.py.
Two transports, one control plane¶
External Consult works from either a SIP call or a direct-WebRTC one (the Agent Studio Preview panel, the emulator's browser dialer). The specialist's leg is a real Asterisk channel in both cases — that half never changed. What differs is the caller's leg, and therefore who performs "hold" and "merge".
| SIP caller | Browser caller (direct WebRTC) | |
|---|---|---|
| ari-controller session | created on Asterisk StasisStart |
synthesized at POST /consult/{id}/start |
| hold | channels.startMoh + mute the agent's ExternalMedia leg |
gain changes in media-proxy + a generated comfort cadence |
| merge | bridges.addChannel, then hang up the dialer's ExternalMedia |
media-proxy carries the audio both ways; the dialer is retired by gain |
| provider | GenesysByocProvider, or AmazonConnectConsultProvider when ccaasType === 'amazon_connect' |
MediaProxyConsultProvider wrapping whichever of those two the platform selects |
Until 2026-08-12 the browser transport answered 404 ("Session … not found") and the tool told the model the call had no telephony leg. Preview-panel capability parity is a product requirement, so the caller-side operations were reimplemented on media-proxy's own audio paths.
What the orchestrator must send¶
ari-controller can look a SIP caller up; it cannot look a browser caller up,
because that call never touched Asterisk. So start_consult sends the facts the
controller needs to create the session, and only this process knows them:
transport: "webrtc_direct"— what opts into the synthesis path. Absent, a missing session is a plain 404, which is the right answer for anything else.media_proxy_session_id— the only handle anything has on the caller's audio.tenant_id— the outbound-routing lookup that finds a trunk to dial the specialist on is tenant-scoped with no fallback, and a browser caller inherits no validated CCaaS integration to take it from.agent_id— stamped on the synthesized session.consult_provider— the authored Provider value, and the only thing that can name the CCaaS platform for a caller with no arriving leg. Sent only when the author selected one; the controller gates on truthiness, so a blank or absent selector omits the key and leaves the platform decision exactly where it was for every existing tenant. See Rehearsing a consult from the Preview panel.
The thread is: runtime_context["session_metadata"]["transport"] →
resolve_consult_caller_leg (consult_tool_helper.py)
→ OriginConsultToolHandler(caller_leg=…) → ToolContext.extras["caller_leg"]
→ ConsultClient.start(transport=…, media_proxy_session_id=…, …) → the
snake_case body keys handleConsultStart reads. Two vocabularies for one
fact meet here: media-proxy stamps metadata["transport"] = "webrtc", while
ari-controller's Session.transport spells it 'webrtc_direct'.
resolve_consult_caller_leg is the single translation point — nothing else may
know both words. media_proxy_session_id is runtime_context["session_id"]
because that is what media-proxy sends as SessionConfig.session_id and what
it keys webrtcSessions on; the browser reusing one uuid for the conversation id
too is a coincidence of ai-manager id minting, so don't read it off the
conversation.
A partial leg is sent deliberately when the transport is known but the tenant is not: the controller's 400 names the missing field, whereas silently omitting the whole leg answers 404 and the model is told the call has no telephony leg — the pre-mixer refusal, back again for a call that can now do this.
The audio: one gain table, both directions¶
internal/audio.ConsultMatrix (mixbus.go)
is pure data — no I/O, no session types — read by both the browser session
and the consult B-leg's RTP session for the same phase, so the two directions
cannot disagree about what "merged" means. Its phases are an audio posture, not
ari-controller's lifecycle: off, hold, hold_listening, merged,
merged_with_agent, merged_with_both.
The asymmetry worth knowing: on SIP, mergeCalls hangs up the dialer agent's
ExternalMedia channel, which removes it from the conversation for free.
media-proxy cannot, because that same leg is its only path to the
specialist's audio (the caller has no channel to bridge the specialist into). So
the dialer is retired by gain while its media leg stays up — that is what
ConsultFromDialer / DialerFromConsult exist for, and why merged_with_both
is a distinct phase rather than a flag on merged_with_agent.
Hold audio is generated rather than shipped: HoldSource
(hold.go) produces the 425 Hz
ITU-T E.180 comfort cadence (1 s on, 3 s off, fade-shaped) optionally over the
agent's existing ambience bed. Dead air is what a caller reads as a dropped call,
and shipping a music asset means licensing it.
Control is three routes, all requireAPIKey, all quoting only the caller's
session id (ari-controller learned the B-leg's id from Asterisk, not from us):
POST /v1/webrtc/consult/link, /phase, /unlink. MediaProxyConsultProvider
(src/providers/)
maps hold_mode and post_merge_mode onto phases and delegates B-leg
origination and teardown to the wrapped GenesysByocProvider — the SIP path
composes in, it isn't reimplemented.
Which transport to rehearse on¶
Either. The choice is now about the other capabilities, not about consult:
- Preview / direct browser media keeps video input, the avatar, and live
frame debugging (
X-Whisker-Mode) without involving Asterisk. Preview's SMS delivery headers are not direct-only: the dialplan also preservesX-Multimodal-Sms-DeliveryandX-Caller-Phonefor its SIP-backed test variants. - SIP (
/emulator→ Janus → Kamailio → Asterisk) is what a real caller gets: 8 kHz PCMU, real codec negotiation, Asterisk-owned hold and bridging. A draft agent is reachable — the Janus path setsX-Call-Source: ai-manager-testunconditionally (auth-context.tsx),[agent-router]forwards it into Stasis (extensions.conf), it is inINTERNALLY_ORIGINATED_CALL_SOURCESso the signedX-Internal-Leg-Sigclears the CCaaS default-deny gate, andallow_draft = call_source == "ai-manager-test"(main.py) then admits:draft. Dial the numeric agent id as the extension —[AGENT_BY_EXT]matchesagent_id::textas well asconfig->>'extension'.
On Genesys, both transports need the tenant's telephony integration to have External Consults enabled with a Routing Number — that is what the specialist's leg is dialed on, and it is the one prerequisite neither transport can synthesize. On Amazon Connect there is no such toggle; see below.
Rehearsing a consult from the Preview panel¶
A Preview test call rehearses a consult on either platform, against the draft on both sides. Two things had to be added for that, and each closes a gap that was invisible from the panel.
Who dials — the authored Provider selector. A real call's platform is a
property of the leg that arrived; a Preview call has no arriving leg, so nothing
could be detected and the browser path could only ever dial the Genesys trunk.
The platform therefore comes from the Provider field already in the agent's
External Consult card — the only place in the product that states which CCaaS
the agent is authored against. start_consult reads it
(config.get("provider"), blank or absent → None) and ConsultClient posts it
as consult_provider; the controller gates on truthiness, so an unauthored
selector omits the key and the platform decision is byte-identical to what every
existing tenant had. There is deliberately no Preview control for this and
no resolution from the tenant's integrations: a tenant can hold both a
Genesys and an Amazon integration, and the agent under test is authored against
exactly one. Nothing new is asked of an agent that has External Consult switched
off — that is an ordinary WebRTC call with no consult in it.
resolveOriginSession (consult-service.ts)
honours the authored value only for transport === 'webrtc_direct'; on a SIP
leg ccaasType is validated fact and an authored value must never touch it. A
Connect one routes to synthesizeConnectOriginSession, which resolves the
tenant's Connect consult routing before writing any session — a
half-configured tenant leaves no synthesized session behind, and reusing the same
resolver keeps Preview's refusal word-for-word identical to a real call's.
Provider selection is then two independent dimensions rather than a priority
list: MediaProxyConsultProvider (caller transport) wraps
AmazonConnectConsultProvider (platform) wraps GenesysByocProvider. The two
wrappers replace disjoint operation sets — the platform one replaces
originateConsult only, the transport one replaces hold/merge/cancel and
delegates origination — so they compose with no third class. The transport
wrapper must be outermost, and getting that backwards does not fail loudly:
hold/merge/cancel would reach Asterisk for a caller that has no channel there.
Which config the specialist's agent runs — draft-first. The B-leg is an inbound leg, and every inbound leg is published-only. That made the common case fail outright: the transfer agent is usually created minutes earlier with Create External Consult agent, so it has a draft and no published version at all, the lookup returned null, and the leg was hung up — a specialist whose phone rang and then went dead. Where a published version did exist the failure was quieter and worse: the rehearsal silently tested last week's agent, which is indistinguishable from a prompt edit that did not take effect.
consult-draft-gate.ts
is the single exception, and it needs three facts to line up, none of which the
leg can assert for itself: callSource === 'consult' (inside the internal-leg
signature on Genesys, written by the ContactId correlation on Connect); an
X-Parent-Conversation-Id naming a live session whose transport is
webrtc_direct, which only resolveOriginSession ever sets; and that session's
consult.consultId matching this leg's X-Consult-Id, so the origin is not
merely a Preview call but the one that started this consult. callSource is
checked first, so an ordinary inbound call costs no Redis read. It is
draft-first, not draft-only — an established transfer agent may have no
unpublished edits, and the fallback is the ordinary published lookup — and the
draft arm needs both the literal version string 'draft' and allowDraft: true.
Which version ran is logged at info (Consult B-leg is running the dialer agent
DRAFT), because "the agent behaved like the old one" has no other signal. It
lives outside index.ts on purpose: that module boots the service on import, so
nothing in it is unit-testable, and a silent widening of this gate is precisely
the change that would fail nothing visibly.
What Preview still cannot rehearse. On a browser caller the merge is a
media-proxy gain change, so Asterisk's bridges.addChannel path and the
merged-peer teardown are exercised on neither platform from Preview. Those need a
real SIP or CCaaS call.
Amazon Connect: Connect dials, and hands the leg back¶
On a Genesys BYOC call we originate the specialist's leg ourselves. On an Amazon Connect
call we cannot: Connect owns its telephony and exposes no trunk of ours to dial on, which
is why SIP_ORIGINABLE_PLATFORMS in
database.ts is ['genesys']
and must stay that way — adding Connect produces an INVITE with nowhere to go.
So the direction inverts, exactly as it already does for the workflow Start Conversation → Voice node (start-conversation-node.md): we ask Connect to dial, and the answered leg arrives back at us as an ordinary inbound SIP call over the Chime SDK Voice Connector.
origin agent calls start_consult
▼
consult-service ──▶ pickProviderForSession (session.ccaasType === 'amazon_connect')
▼
AmazonConnectConsultProvider.originateConsult
│ StartOutboundVoiceContact { InstanceId, ContactFlowId, DestinationPhoneNumber,
│ SourcePhoneNumber | QueueId, Attributes, ClientToken: consultId }
│ ──▶ ContactId → pending-consult registry → persist on the session → AWAIT
▼
Connect rings the specialist → they answer → the tenant's outbound flow transfers the
leg to TrueServe over the Chime Voice Connector
▼
INVITE → Kamailio → [ccaas-inbound-handler] (Answer() then Stasis) → onStasisStart
│ correlate the leg's contactId / initialContactId against the registry ──▶ HIT
│ rewrite identity: confId = consult-<uuid>, pipelineId = the dialer agent,
│ callSource = 'consult', synthesize X-Consult-Id + X-Parent-Conversation-Id
▼
the ordinary consult B-leg path, unchanged — brief the specialist, then merge_calls
Everything after the correlation is the SIP path verbatim: setHoldMode, mergeCalls and
cancelConsult delegate to GenesysByocProvider, because by then both legs are
ordinary Asterisk channels and the merge is a mixing-bridge operation
(bridges.addChannel), not a SIP REFER.
Config: the Amazon integration's existing outbound fields¶
There is no Connect equivalent of the Genesys External Consults toggle, and no new
schema. Consult is available once the Amazon integration carries what an outbound contact
needs, read by getConnectConsultRouting:
| Field | Used for |
|---|---|
source_arn (Instance ARN) |
the InstanceId and the region — the Connect client is addressed at the instance's region, never AWS_REGION |
outbound_contact_flow_id, falling back to default_contact_flow_id |
the flow that transfers the answered leg back to us |
outbound_source_phone_number or outbound_queue_id |
the caller identity Connect presents; either one satisfies it |
Each missing one refuses by name, because that refusal is what the model reads as the
start_consult result and repeats to the caller — Connect's own error says "Either of
QueueId or SourcePhoneNumber should be present", words that appear on no screen the
operator has. The dial target is the destination's X-Target-DNIS if authored, else its
resolved dial_number: Connect dials the specialist directly, so there is no
routing-number indirection to reverse.
The returning leg is authorized by ContactId correlation¶
The leg arrives as an ordinary inbound Connect call, so the
CCaaS allow-list
would reject it — the dialer agent is not published for inbound routing it never receives,
and making operators publish it for routing that never happens would be a step that means
nothing. Instead onStasisStart matches the leg's initialContactId (the id stable across
transfers) and then its contactId against the pending registry, and a hit bypasses the
allow-list join only. It is a narrow bypass, not a hole — a correlated leg must still
prove three things:
- We dialed through this integration. The arriving
X-Amzn-SourceArnresolves to an integration whose id must equal the oneStartOutboundVoiceContactwas sent to. A mismatch is treated as a miss, so ordinary default-deny runs. - The dialer agent's config is published. The pipeline lookup runs with
allowDraft: false, tenant-scoped to the validated integration. - Something is actually waiting. A leg quoting a ContactId nothing is pending on is not a consult leg, whatever it presents.
Rewriting callSource to 'consult' grants nothing on its own:
shouldBypassCcaasValidation returns the signature verdict whenever CCaaS identification
is present, and a Connect-dialed leg carries no internal-leg signature. The correlation is
its own explicit branch for exactly that reason.
If the leg never comes back, originateConsult gives up after
CONNECT_CONSULT_INBOUND_TIMEOUT_MS (default 45 000 ms), stops the contact, and
reports the timeout — leaving it ringing would hold the caller for a specialist who is not
coming.
Teardown ends every leg, then stops the contact we created¶
StopContact is called on the outbound consult contact only. The customer's inbound
contact ends the normal way, when our BYE returns control to their flow. The contact id is
claimed (cleared from the session) by whichever teardown path reaches it first, so the
three that can — the model cancelling, the B-leg leaving Stasis, the origin tearing down —
call it once between them. It is fail-soft: an already-ended contact is the expected case,
and a failure here must never block the BYE. Cancel hangs the leg up first, then stops
the contact; stopping first races the tenant's flow into re-answering a leg we were about
to drop.
One gap this closed is not Connect-specific. mergeCalls moves the specialist into the
origin's bridge and deletes its own session, but never adds it to session.humans — and
teardownSession hangs up exactly session.humans, so on Genesys too nothing ever hung
the merged specialist up. ConsultState.mergedPeerChannelId is recorded at merge and hung
up in teardown; it deliberately stays out of humans, where a second role-less entry would
make _findCallerChannelId resolve whichever came back from Redis first.
Tenant prerequisites¶
Not enforceable from our side, and the likeliest cause of "the specialist's phone rang but nobody was there":
- The Chime SDK Voice Connector must be the external-transfer integration type
(
CONNECT_CALL_TRANSFER_CONNECTOR) — that is what putsX-Amzn-ConnectContactId/X-Amzn-ConnectInitialContactIdon the leg, and the correlation depends on them. - The flow named by
outbound_contact_flow_idmust transfer the answered leg back to TrueServe over that Voice Connector. Unlike the workflow-outbound path it does not need to forwardtrueserve_pipeline_idas UUI — the ContactId names the agent — but forwarding it does no harm. - The integration's credentials need
connect:StartOutboundVoiceContactandconnect:StopContacton top of theconnect:UpdateContactAttributesthey already use.
A browser Preview call reaches this path too. Set the agent's External Consult
Provider to Amazon Connect and a Preview consult dials through
StartOutboundVoiceContact, correlates the returning leg, and runs the transfer agent's
draft — the same code as a real Connect call, plus the media-proxy caller side. The
prerequisites above are unchanged; a Connect-only tenant no longer has to find a real call
to rehearse on. See
Rehearsing a consult from the Preview panel
for what the panel still cannot exercise.
Failure modes¶
| Symptom | Likely cause |
|---|---|
| LLM emits transfer tool but nothing happens | Helper not adding the tool; check External Consult is enabled on the voice node (builtin:warm_transfer) (or legacy transfer.enabled) |
start_consult never connects — no dial, no ARI log, and on a cascaded LLM no error either |
The tool name has no registered callback. A handler that relies on its FunctionCallFromLLM branch alone is wired to nothing outside Google ADK. Look for [LocalTools] Registered origin-consult tools … at build; its absence is the whole fault |
| The agent apologises for a technical issue, then re-greets, then re-answers something already settled — in a loop | Same root cause on AWS Nova Sonic: an unregistered function raises AWSNovaSonicUnhandledFunctionException, which tears the live session down, reconnects, and replays the persistent context. Anything stored there (a verification result, a payment summary) is re-issued as though it had just happened, so the caller is thanked for verifying their identity once per crash |
start_consult returns consult_agent_id '<something_specialist>' is not configured |
The model invented an id, because an internal agent id is not knowable from the conversation. Fixed 2026-08-12 (optional + resolved from the single destination / label); if it recurs with 2+ destinations, the prompt list is the fix — the error now names the valid ids |
start_consult returns No consult destinations are configured for this agent even though the node clearly has one |
The stage gate ran after the builder overwrote config.tools with a resolved ToolsSchema, so the built-in read as absent and the legacy agent-level config.transfer won — typically a blank stub destination. Fixed 2026-08-12 via authored_tool_refs. Check the build log for Warm transfer destinations for service …: [ids] (source=stage builtin); source=legacy config.transfer on a node-configured agent means the gate missed |
| Consult tools are absent from an agent whose External Consult toggle is on | The resolved config had no destination carrying a consult_agent_id — look for Warm transfer is enabled … consult tools NOT exposed, then delete the blank row in the panel and add a real outbound agent |
start_consult returns Failed to start consult: unauthorized, and the agent tells the caller it "can't initiate the warm transfer at this time" |
Every mutating ari-controller route is behind authorizeInternal, and ConsultClient sent no bearer (it also defaulted to port 8080, which nothing listens on — the controller's HTTP_PORT default and chart service.targetPort are both 3000). Fixed 2026-08-12: the client sends Authorization: Bearer $ARI_CONTROLLER_INTERNAL_TOKEN and raises a config error naming the variable rather than sending a request that cannot succeed. A 401 now says the bearer was rejected; a 503 says ari-controller's own copy is unset or under 32 bytes. Same fix in agent-host/warm_transfer_tool.py |
| The agent says it cannot bring in an outside specialist "on this session", and mentions the call has no telephony leg | ari-controller answered 404 and the orchestrator reported no caller leg at all — i.e. neither an Asterisk session nor a direct-media one. Sessions are keyed on the conversation id in Redis (trueserve:ari-sessions:) and created on StasisStart, on the two synthesized SIP cases, or (since 2026-08-12) on a transport: webrtc_direct consult start. So this is a transport we don't implement, or a browser call whose caller_leg never got resolved. Not transient — the refusal forbids the retry deliberately |
| Same refusal, but worded "the telephony controller did not accept this session" | A browser caller sent its caller leg and the controller still 404'd, which means the controller is older than the synthesis path or the body keys didn't arrive. Check the WARNING naming transport/media_proxy_session_id; the body keys are snake_case and a camelCase one is silently ignored rather than rejected |
start_consult on a browser call returns 400 naming media_proxy_session_id or tenant_id |
The caller leg was partial. That 400 is the intended failure — a consult that cannot reach the caller's audio, or has no tenant to find a trunk under, must not be originated at all. resolve_consult_caller_leg sends what it has precisely so the controller can name the gap instead of the call regressing to the 404 above |
start_consult on a browser call returns 409 "enable External Consults and set a Routing Number" |
The tenant has no outbound-enabled telephony integration. Same gate as SIP and as POST /outbound-call; the browser transport removes the caller's channel requirement, not the trunk requirement |
Browser consult MERGES but audio is one-way the other way round — the specialist hears the caller, the caller hears silence, and media-proxy logs consult playout queue full every 20 ms |
The caller session's pacing goroutine was parked. With no background audio it blocks in a select on {interrupt, opusBuf} and only re-checked consultOn when a frame arrived — but activating a consult reroutes the origin agent's audio to botPCMQueue, so opusBuf never delivers the frame that would wake it. The caller→specialist direction never notices (the inbound mic tap doesn't go through the pacer), which is what makes it read as a codec/direction problem. Fixed 2026-08-13: SetConsultPhase queues a wake token (consultWake) the parked select consumes |
| The specialist hangs up after a merge but the caller's test call keeps going — open line, permanent silence | Three layers, all fixed/bounded 2026-08-13. (a) Genesys may hold our B-leg SIP session long after the transferred human hangs up (observed: BYE arrived ~15 min later) — so StasisEnd is not a timely signal; for an instant end, the Architect flow must disconnect once its external transfer returns. (b) The timely signal is the media plane: the specialist's RTP stops, and media-proxy's idle reaper fires at 60 s — but it used to releasePort without telling ari-controller. It now calls notifySessionEnd("idle_reaped") (same contract as the RTP-timeout watchdog), so the caller's leg ends ≤ ~60 s after the specialist leaves even with no BYE. The REVERSE direction is also covered: ending the Preview call (A-leg) used to leave the Genesys consult leg alive until the flow's own timeout — HandleWebRTCHangup (webrtc_handler.go) now notifies ari-controller for a linked consult before unlinking, so the B-leg channel is hung up (BYE to Genesys) as soon as the caller leaves. (c) Whichever signal arrives (StasisEnd, ChannelDestroyed, or the /session-end callback), a MERGED drop_both_agents consult used to be "restored" — but both agents were retired at merge, so there was nobody to restore to. _endOriginAfterMergedConsultEnd (index.ts) now ends the origin call (media-proxy /v1/webrtc/hangup for a browser caller, ordinary teardown for SIP); the keep_* modes still restore, reading consult.postMergeMode recorded at merge |
| A media-proxy fix is tested green but the live behaviour doesn't change | The deployed binary is stale: go build ./... compiles packages but does NOT write ./media-proxy, and start-media-proxy.sh only rebuilt when the binary was missing or ldd failed — so overmind restart media-proxy silently re-ran the old binary. Fixed 2026-08-13: the start script rebuilds when any .go source (including libs/go-audio) is newer than the binary. Same class as the Next.js stale-module gotcha in .devcontainer/CLAUDE.md |
| Browser consult connects but audio is one-way ("the specialist can't hear me") | A phase moved with only one sink wired. LinkConsultToWebRTC installs both directions before setting any phase for exactly this reason; if it recurs, check /v1/webrtc/consult/link succeeded before /phase and that both sessions were found (the caller lives in webrtcSessions, the B-leg in the RTP port table — neither map can find the other, so the manager owns the link) |
| After a browser merge the dialer agent is still talking to the specialist | ConsultFromDialer / DialerFromConsult did not drop. On SIP the merge hangs the dialer's ExternalMedia up; media-proxy cannot, because that leg is its path to the specialist, so the dialer is retired by gain only. A merged_with_both phase where merged was meant keeps both cells at unity |
| The consult connects but the "specialist" behaves like the origin agent (re-greets, re-briefs, has the same voice) | The start request carried no X-Pipeline-Id, so ari-controller had no pipelineIdOverride and the B-leg came up on the origin agent's pipeline id — usually its published version, which may be a different provider than the draft you're testing. Check the Session created/updated line for the consult-<uuid> conversation: pipelineId must be the consult agent's id, not the origin's. Fixed 2026-08-12 |
| The agent tells the caller a system restriction / tenant policy prevents the transfer | ari-controller answered 403 Destination <n> blocked by tenant policy. With a policy configured, either the Routing Number or the destination's X-Target-DNIS is outside allowed_destination_regexes. With no policy configured this was a bug — the empty regex list denied every authored X-Target-DNIS, i.e. every externally-routed destination. Fixed 2026-08-12; see Tenant transfer policy |
| Consult dials nothing / "missing dial_number" | On Genesys: the integration's External Consults section is off or has no Routing Number, and the config carries no legacy dial_number. On Connect: the destination has no authored Target DNIS either, so there was nothing to fall back to |
| On a Connect call the agent says the consult was refused, naming a field (Instance ARN / Outbound Contact Flow ID / Outbound Source Phone Number or Outbound Queue ID) | Exactly what it says — that field is unset on the Amazon integration. The refusal is deliberately worded in the operator's vocabulary rather than Connect's API field names; fill it in on the integration and retry |
| The specialist's phone rings, they answer, and nobody is there — then the agent reports a timeout | Connect dialed, but the outbound flow never transferred the answered leg back over the Chime Voice Connector, so nothing arrived to correlate. Check the flow named by outbound_contact_flow_id and that the VC is a CONNECT_CALL_TRANSFER_CONNECTOR |
The returning Connect leg is rejected pipeline is not published for the validated CCaaS integration |
The correlation missed, so ordinary default-deny ran. Either the arriving X-Amzn-SourceArn resolved to a different integration than the one dialed through, or the leg carried no ContactId (wrong Voice Connector integration type) |
| A Preview consult on a Connect tenant refuses with the Genesys "enable External Consults and set a Routing Number" message, or dials the Genesys trunk | The agent's External Consult Provider is unset or set to Genesys. A browser call has no arriving leg to detect the platform from, so that selector is the only input; the controller gates on truthiness and an unauthored one keeps the pre-existing Genesys behaviour. Set it to Amazon Connect — see Rehearsing a consult from the Preview panel |
| A Preview consult rings the specialist and the leg then goes dead, or the transfer agent behaves like an older version of itself | The B-leg is an inbound leg, so it was published-only: a transfer agent created minutes ago with Create External Consult agent has no published version at all (hang-up), and one that does silently ran it (last week's prompt). Fixed 2026-08-13 — consult-draft-gate.ts runs the draft when the origin is the Preview call that started this consult, and logs at info which version it used. A non-Preview consult is unaffected and still published-only |
Publish fails transfer.consult_agents[0].destination.dial_number is required although Target DNIS is filled in |
ai-manager's publish validator (agent-config-validation.ts) read only the legacy agent-level config.transfer — which the editor keeps as a blank enabled: true stub — and hard-required a dial_number the Routing Number now supplies. Fixed 2026-08-12: unusable rows are skipped (same rows usable_consult_destinations skips at runtime), dial_number is validated only when authored, and the per-stage builtin_tool_configs.warm_transfer.consult_agents[] is what actually gets checked |
Publish fails …headers.X-Target-DNIS requires tenant transfer_policy.allowed_destination_regexes |
Same fix, second blocker queued behind the first: publish treated an authored DNIS as fail-closed and demanded a tenant policy, while both runtime gates fail open. Fixed 2026-08-12 — an absent or empty policy narrows nothing, in all three gates |
| Both legs hear the agent say "mode IVR", then the consult ends within seconds | The transfer agent is S2S and was wrapped in IVRNavigator, whose <mode>ivr</mode> classifier token has no text stream to be stripped from — it was spoken aloud, and the model ran under the classifier prompt instead of its own. Fixed 2026-08-13: the wrap is skipped on type: s2s stages with a build-time WARNING (builtin:ivr_navigation is authored on an S2S stage) |
The consult B-leg's call is missing from the Activity Log — but its events/messages logged Cannot insert …: conversation consult-<uuid> not found in conversation-api |
The B-leg session id is consult-<uuid> and conversations.id is a uuid PK, so create_conversation raised on the insert and the row never existed. Fixed 2026-08-13: derive_conversation_row_id (conversation_store.py) mints a deterministic uuid5 row id when the session id is not a UUID; session_id keeps the original string, which is what readers match on |
| The consult aborts as "reached voicemail" even though you answered | Often genuine — the far side's carrier played "forwarded to voicemail" before/while the human picked up, and the transfer agent's authored failure path (abort_consult) is doing its job. Check the B-leg transcript (visible once the row-id fix above is in): the far-side turn shows exactly what the agent heard |
| Outbound-agent dropdown is empty | Build an External Consult agent first (the form's "Create External Consult agent" link → External Consult Agent template), and enable its Transfer Agent capability |
| Consult never answers | Wrong contact/queue; check ARI logs in ari-controller |
| Caller hears AI muted but no music | hold_mode = "music" but no music class configured in Asterisk |
| AI does not exit after merge | post_merge_mode mismatch with bridge participant tracking |
See also¶
- Universal pipeline builder
- Voice request lifecycle
- SIP integrations
- Start Conversation node — the other consumer of the Amazon Connect dial-and-hand-back inversion
- ari-controller service doc
- Pipecat-native alignment — draft proposal to move the consult control channel onto pipecat's RedisBus