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:
- Start live session.
start_session: truecreates through conversation-apiPOST /conversations, so the orchestrator boots the agent pipeline immediately (the greeting fires with nobody watching) — required for injection to work. Off = a bareconversationsrow; the session starts when a visitor opens the share link. - Inject. After creation,
POST /conversations/{id}/injectpushes an instruction into the live session.deliveredon 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:
- The
conversationsrow lands ~100 ms afterPOST /conversationsreturns, and every route behind_authorize_conversationanswers 404 until it does — includingPOST /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_rowretries the 404. - 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'withinjected: false, and the agent greeted the person as a stranger. The node waits for a non-zerodeliveredon 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.
startConsultcanrequireSessionbecause the caller is already on a call; a workflow has no call at all.startOutboundCalltherefore synthesizes the Redis session before originating, becauseoriginateDialThroughreadssession.confId/agentId/ccaasByocFqdnand 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-DNISwhile the INVITE goes to the tenant's routing number, so the trunk only ever sees a number it authorizes. 'workflow-outbound'must be inINTERNALLY_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 ascontact_id;channel_idis empty on this path. - The contact flow is the only channel for our ids. We pass
trueserve_pipeline_idandtrueserve_conversation_idas 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 whenX-Pipeline-Idis 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¶
- Warm handoff / External Consult — the authorization path this reuses.
- LangGraph construction — how the node is built into the graph.
- Workflow triggers — what starts the workflow in the first place.
- ari-controller — the internal-leg signature and the CCaaS gate.