Skip to content

Start Conversation node

Status: Current | Last reviewed: 2026-09-01

How a workflow hands a person off to one of the tenant's agents — the conversation_actions node. A notification node sends a one-way message; this node starts a two-way conversation.

The channel is the discriminator

config.channel decides what the node does, which other config fields apply, and which family of agents is selectable. There is no other mode switch.

Channel What happens Requires
chat (default) Creates the conversation and returns external_link. Delivering the link is the author's problem (a notification node, a CRM write, an email of their own). a chat agent
sms All of the above, and texts the link. a chat agent, an SMS integration on the node, a recipient number
email All of the above, and emails the link. a chat agent, an email integration on the node, a recipient address
voice Places an outbound call; when the person answers, the voice agent's pipeline runs on that channel. No share link exists. a voice agent, a telephony integration with outbound dialing enabled, a recipient number

The inspector is channel-first for this reason: pick the channel, then its nuances (integration, origination identity / caller ID, recipient, body), then the agent — whose picker is filtered to chat_agents or voice_agents to match. Switching between the chat and voice families clears config.agent_id, because a stored agent from the other family renders as a blank select and fails at runtime.

A workflow containing this node must run in async mode. conversation_actions is on SYNC_FORBIDDEN_NODE_TYPES, so execution_mode: 'sync' fails validation. That rule lives in validateExecutionMode, which reaches a caller only through extendResultWithConfigIssues — the editor page has to fold it in the same way the publish route does. A bare validateWorkflow there left the Publish button enabled right up to a server 400, and since sync is the default mode, a freshly added node hit that immediately.

Chat / SMS / Email

Implemented in apps/async-agent/app/nodes/conversation_actions.py. Two behaviors compose, both optional:

  1. Start live session. start_session: true creates through conversation-api POST /conversations, so the orchestrator boots the agent pipeline immediately (the greeting fires with nobody watching) — required for injection to work. Off = a bare conversations row; the session starts when a visitor opens the share link.
  2. Inject. After creation, POST /conversations/{id}/inject pushes an instruction into the live session. delivered on that response is the pub/sub subscriber count.

On sms/email the node then renders message_body and sends it through the same ChannelRegistry adapters and the same load_integration_with_secrets call the notification node uses, so one integration behaves identically whichever node sends through it. sms_origination_identity, subject, and from_address mirror that node's fields.

The integration is a node-level field, not a config key. Both nodes read node_def["integration_id"] (the inspector writes it with onUpdate({ integration_id }), and it is absent from the manifest's config_schema). A copy placed in config is ignored silently, and the adapters then fall back to the ambient AWS_ACCESS_KEY_ID / AWS_DEFAULT_REGION — so the send appears to work against whatever account the host happens to hold, and fails with a provider error naming a region and account nobody configured.

Nothing sent is not a success

status is 'error' whenever the message did not go out — no recipient, no share link, or an adapter refusal — because a router keyed on status == 'ok' would otherwise treat "nobody was contacted" as "the person has the link". conversation_id / share_code / external_link are still reported on that path, so a recovery branch keeps everything it needs.

An email subject is a MIME header: SES rejects the whole send with Invalid message subject for a line break rather than trimming one. Since a subject-less email falls back to the first 100 characters of the body, and this node appends the share link on its own line, that fallback always contained a newline — every subject-less link email failed at the provider while the node looked correctly configured. EmailAdapter now collapses subject whitespace, which also closes header injection through a templated subject.

Both startup races are silent

Creating a conversation and immediately using it loses two independent races against the orchestrator, and neither announces itself:

  1. The conversations row lands ~100 ms after POST /conversations returns, and every route behind _authorize_conversation answers 404 until it does — including POST /share-codes, which the node calls next. So the share code failed only when nothing slower happened in between, the harder version to diagnose. _post_awaiting_conversation_row retries the 404.
  2. The session's pub/sub subscriber attaches later still (~1 s in dev). An inject landing in that gap returns HTTP 200 with delivered: 0 — accepted, then dropped, because nothing is listening. A freshly created session therefore swallowed its own opening instruction: status: 'ok' with injected: false, and the agent greeted the person as a stranger. The node waits for a non-zero delivered on the session it started.

POST /share-codes is also require_calls_initiate (agents:manage) like /conversations — the bearer is mandatory. Sending none put a 401 in share_code_error while status stayed 'ok', and since an empty external_link short-circuits delivery, one missing header silently disabled both sms and email.

Because the point of those two channels is that the link arrives, sms/email force two things the author would otherwise get wrong: create_share_code is true (the switch is disabled in the inspector and the channel select sets it on the way in), and a body containing no link token gets external_link appended, so "Your order needs a decision" still arrives usable. Neither applies to chat — there the author owns create_share_code, and turning it off is a real configuration: a chat node with inject and no share code starts a conversation for a client that already knows the conversation id.

Voice

Outbound voice is two mechanisms, not one, and the telephony integration's platform chooses between them. The difference is not a preference — it is whether the platform has a SIP trunk that terminates on our edge:

Platform Who dials Why dialed_by
Genesys BYOC TrueServe — ari-controller originates on our own trunk The BYOC trunk terminates on our Kamailio, so Asterisk can place the leg trueserve_sip
Amazon Connect Connect — StartOutboundVoiceContact, then a contact flow transfers the answered leg to us Connect owns its telephony and exposes no trunk we can originate on. We cannot place a call as a Connect instance amazon_connect

Both report through the same call_status, and wait_for_answer works identically on both, because the answer signal is the conversations row the orchestrator writes when it builds the voicebot — and that row appears whichever direction the leg arrived from.

Picking a Connect integration and expecting the SIP path is the likely authoring mistake, so ari-controller's 409 names it: getOutboundRoutingRefusal distinguishes not_sip_originable ("that platform can't originate over TrueServe SIP") from not_enabled ("the right platform, but unauthorized"). Telling an operator to "enable External Consults" on a Connect integration would send them to a switch that does not exist and would not help.

Genesys BYOC — TrueServe dials

This path reuses the External Consult authorization rather than adding a second switch to get wrong. Three services read the same three integration fields (external_consults_enabled, consult_routing_number, byoc_trunk_fqdn): ai-manager's resolveConsultRouting, the orchestrator's _consult_routing_number, and ari-controller's getOutboundRouting — which also restricts the match to SIP_ORIGINABLE_PLATFORMS (today: genesys). A tenant with no External-Consults-enabled integration cannot dial out, and the node reports that as status: 'error' with the reason.

async-agent conversation_actions (channel: voice)
  │  mints conversation_id, resolves tenant
  ▼
POST ari-controller /outbound-call        (Bearer ARI_CONTROLLER_INTERNAL_TOKEN)
  │  getOutboundRouting(tenant, integration?)
  │  getOrCreateSession(conversationId, 'voicebot', agentId)   ← synthesized
  ▼
originateDialThrough → routing number, X-Target-DNIS = real destination
  │  callSource 'workflow-outbound' + signed internal-leg marker
  ▼
StasisStart  → arm pendingOutboundChannels
ChannelStateChange 'Up' → addHuman + buildVoicebot

Three details are load-bearing:

  • There is no A-leg. startConsult can requireSession because the caller is already on a call; a workflow has no call at all. startOutboundCall therefore synthesizes the Redis session before originating, because originateDialThrough reads session.confId / agentId / ccaasByocFqdn and signs the internal-leg marker over them. If origination returns null the synthesized session is deleted — the pending map keys on a channel id that never existed.
  • The routing number is the dial target. Like consult, the real destination rides X-Target-DNIS while the INVITE goes to the tenant's routing number, so the trunk only ever sees a number it authorizes.
  • 'workflow-outbound' must be in INTERNALLY_ORIGINATED_CALL_SOURCES (ccaas-detector.ts). Without that entry the signed B-leg is read as a forged CCaaS bypass and the call is hung up on answer — the call rings, the person picks up, and the line dies.

wait_for_answer polls for the conversations row the orchestrator writes when it builds the voicebot on the answered channel; no row inside the window means call_status: 'no_answer', which is an outcome, not an error.

Amazon Connect — Connect dials, then hands the leg back

Connect owns its telephony. There is no trunk on our side to originate from and no way to place a call as a Connect instance, so the direction inverts: we ask Connect to dial, and the call comes back to us as an ordinary inbound CCaaS call.

This inversion now has a second consumer: External Consult on a Connect call dials the specialist the same way, and AmazonConnectConsultProvider in ari-controller reuses this node's request shape and attribute constants (trueserve_pipeline_id / trueserve_conversation_id) so the two dial paths look identical to the tenant's contact flow. The halves differ only in how the returning leg names its agent: this node forwards the pipeline id as UUI, while a consult leg is recognised by ContactId correlation — see warm-handoff.md. A change to the request built here should be mirrored there.

async-agent conversation_actions (channel: voice, integration type amazon_connect)
  │  connect_outbound_client.place_connect_outbound_call
  ▼
Connect StartOutboundVoiceContact
  │  ContactFlowId  ← integration outbound_contact_flow_id → default_contact_flow_id
  │  SourcePhoneNumber / QueueId ← node caller_id → integration outbound_*
  │  Attributes     ← trueserve_pipeline_id, trueserve_conversation_id
  │  ClientToken    ← conversation_id  (Connect dedupes ~7 days)
  ▼
Connect rings the destination; returns a ContactId immediately
  ▼
person answers → the customer's contact flow runs
  │  it must transfer the leg to TrueServe over the Chime Voice Connector
  ▼
INVITE arrives at Kamailio → [ccaas-inbound-handler] → normal inbound CCaaS gate
  │  (X-Amzn-SourceArn / X-VoiceConnector-ID detected as usual)
  ▼
buildVoicebot on the answered channel

The outbound route lives on the integration, exactly like Genesys' routing number. outbound_contact_flow_id (plus outbound_source_phone_number / outbound_queue_id) are Amazon-telephony integration fields, and there is deliberately no per-node contact flow: one standing outbound route per integration, routed per call by the contact attributes below. Authoring the flow per node would scatter one operational decision across every workflow that dials and diverge from the Genesys half for no reason. All three are discoverable, so Discover AWS resources on the integration form fills them from the live account (contact flows, STANDARD queues, and the instance's claimed E.164 numbers) — see sip-integrations.md.

Three things follow from that inversion, and they are the whole reason this path is documented separately:

  • There is no channel id and no pending-answer map. Connect returns a ContactId, which is a Connect object, not an Asterisk channel. The node reports it as contact_id; channel_id is empty on this path.
  • The contact flow is the only channel for our ids. We pass trueserve_pipeline_id and trueserve_conversation_id as contact attributes, and the customer-authored flow must forward them on the transfer — either as UUI (pipeline=<id>;conv=<id>, which [ccaas-inbound-handler] already parses when X-Pipeline-Id is absent) or by transferring to the agent's own DID. A flow that drops them produces a call that connects to nothing: the leg arrives, the gate finds no pipeline, and the person hears silence. We cannot enforce this from our side — it is a configuration requirement on the Connect instance, and the likeliest cause of "the call rang but nobody was there".
  • The answered leg is subject to the normal allow-list. It is inbound CCaaS like any other, so the agent must be published for that Connect integration in ccaas_pipeline_integrations (the agent publish dialog's CCaaS Availability section). Outbound authoring does not grant inbound routing.

Because ClientToken is the conversation id, a retried node run cannot double-dial the same person — Connect deduplicates the request.

The integration needs three things, and each missing one is an error naming the field rather than a no-op (call_status: 'not_placed'): a Connect instance ARN, outbound_contact_flow_id (or default_contact_flow_id), and a caller identity — outbound_source_phone_number or outbound_queue_id, since Connect rejects StartOutboundVoiceContact without one. That last check is ours rather than Connect's on purpose: Connect's own refusal reads "Either of QueueId or SourcePhoneNumber should be present", naming API fields that appear on no screen the operator has.

Outputs

Output Populated on
conversation_id every channel
session_id, share_code, external_link, injected chat, sms, email
delivered, message_id sms, email
call_status (dialing | answered | no_answer | failed | not_placed) voice
dialed_by (trueserve_sip | amazon_connect) voice
channel_id — the Asterisk channel voice, SIP path only
contact_id — the Connect contact voice, Amazon Connect path only
channel every channel

Configuration

Var Service Purpose
ARI_CONTROLLER_URL async-agent Controller base URL. Default http://localhost:3000 — note ai-orchestrator's consult_client.py defaults to 8080 and is the odd one out.
ARI_CONTROLLER_INTERNAL_TOKEN async-agent Bearer for POST /outbound-call. Must be the same secret ari-controller reads (trueserve-media-secrets in the charts); the controller 503s when its own token is unset and 401s on a mismatch, so an unset value here can never succeed and is reported as a config error.

See also