Skip to content

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_script competes with a better value. start_consult's context argument 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.py prefers an authored briefing_script and only falls back to context — 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 IVRNavigator is 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 under talk_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 a type: s2s stage — the agent runs on its authored prompt (which already handles the voicemail/refusal failure paths via abort_consult), and IVR menus are simply not auto-navigated. Fixed 2026-08-13.
  • ivr_navigation_goal has nothing to fall back to. Every transfer-agent template pins builtin_tool_configs.ivr_navigation.goal_mode: 'template' with goal_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}} to IVRNavigator as 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, and startConsult in consult-service.ts now agrees. enabled: false is the same as absent.
  • Non-empty list = both the dial number and any X-Target-DNIS header 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_transfer on the stage tools[] and edits builtin_tool_configs.warm_transfer via the shared form.
  • gecx — GECX calls the warm_transfer MCP 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 plain warm_transfer tool to the CES tools[] 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:

  1. Agent B records the normalized merge decision in AgentBus (or the legacy Redis ready envelope).
  2. Agent A asks ari-controller to merge and forwards only that decision.
  3. 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.
  4. If any preflight check fails, the merge is rejected before either AI agent is removed.
  5. The call-control provider merges the two humans and drops both AI agents.
  6. ari-controller changes the surviving session to translation mode and calls buildTranslation for 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

  1. Builder phase: universal_builder.py reads the per-node config via warm_transfer_config_for_stage_config(raw_config) (falling back to the legacy agent-level config.transfer); when transfer config is present it wires the origin consult tools (add_origin_consult_tools) plus the origin system prompt. Per-destination inline briefing_script/ivr_navigation_goal are stashed in the Redis dialer brief by start_consult so 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>.

  1. Tool call: the LLM emits start_consult({ context, consult_agent_id?, destination?, hold_mode? }). (transfer_to_consult is not a tool name — the origin set is start_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.

  1. Tool execution: the orchestrator posts to ari-controller (POST /consult/{session_id}/start, bearer ARI_CONTROLLER_INTERNAL_TOKEN). consult-service.ts then:
  2. Resolves the caller's session — looked up for a SIP caller, synthesized for a browser one (see Two transports).
  3. Creates a new outbound channel via ARI to the consult agent's contact (SIP URI, queue ID, or another agent ID).
  4. Bridges that channel to the existing call's bridge, or links it to the browser leg in media-proxy.
  5. Plays the prompt_for_consult greeting 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.

  1. Hold mode: while waiting for the consult agent to answer, the caller hears whatever hold_mode says: music (default music-on-hold), silence, or the AI continuing to converse.

  2. Three-way merge: when consult answers, the bridge has all three legs briefly. The AI says prompt_for_agent to introduce the situation.

  3. Post-merge: based on post_merge_mode:

  4. ai_exits — AI's leg unbridges; caller and consult continue alone. Most common.
  5. ai_observes — AI stays in but is muted; can resume if called back.
  6. 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 shared trueserve_logic whitelisting AST walker (libs/logic-expressions/, wrapped fail-open by handoff_rules.py; never eval; 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 (default gpt-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 the warm_transfer MCP 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_dtmf can 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 static ORIGIN_CONSULT_PROMPT alone 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 preserves X-Multimodal-Sms-Delivery and X-Caller-Phone for 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 sets X-Call-Source: ai-manager-test unconditionally (auth-context.tsx), [agent-router] forwards it into Stasis (extensions.conf), it is in INTERNALLY_ORIGINATED_CALL_SOURCES so the signed X-Internal-Leg-Sig clears the CCaaS default-deny gate, and allow_draft = call_source == "ai-manager-test" (main.py) then admits :draft. Dial the numeric agent id as the extension — [AGENT_BY_EXT] matches agent_id::text as well as config->>'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-SourceArn resolves to an integration whose id must equal the one StartOutboundVoiceContact was 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 puts X-Amzn-ConnectContactId / X-Amzn-ConnectInitialContactId on the leg, and the correlation depends on them.
  • The flow named by outbound_contact_flow_id must transfer the answered leg back to TrueServe over that Voice Connector. Unlike the workflow-outbound path it does not need to forward trueserve_pipeline_id as UUI — the ContactId names the agent — but forwarding it does no harm.
  • The integration's credentials need connect:StartOutboundVoiceContact and connect:StopContact on top of the connect:UpdateContactAttributes they 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 troubleshooting voice.

See also