From twilio-developer-kit
Configure automatic conversation capture and routing with Twilio Conversation Orchestrator. Covers Configuration creation, channel capture rules, grouping types, status timeouts, Memory Store linkage, Intelligence linkage, and conversation lifecycle. Use this skill to automatically capture SMS, voice, WhatsApp, RCS, and web chat traffic into unified conversations without manually creating conversations or participants.
npx claudepluginhub twilio/ai --plugin twilio-developer-kitThis skill uses the workspace's default tool permissions.
Decision-making guide for Twilio's Conversation Orchestrator (Conversations v2) — automatic conversation capture and routing across Voice, SMS, WhatsApp, RCS, and web chat. Covers Configurations, capture rules, grouping types, channel settings, status timeouts, and linkage to Conversation Memory and Conversation Intelligence.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Share bugs, ideas, or general feedback.
Decision-making guide for Twilio's Conversation Orchestrator (Conversations v2) — automatic conversation capture and routing across Voice, SMS, WhatsApp, RCS, and web chat. Covers Configurations, capture rules, grouping types, channel settings, status timeouts, and linkage to Conversation Memory and Conversation Intelligence.
GA — Conversation Orchestrator is generally available.
Conversation Orchestrator powers automatic conversation capture and integration with existing voice implementations — replacing manual conversation creation with either:
Note: Active and passive ingestion can be configured on a per-channel basis. For example, you can use passive capture rules for SMS while using active TwiML parameters for voice calls.
WARNING: You can be charged for STT (speech-to-text) twice on the same call if misconfigured.
If you are using voice with ConversationRelay or Transcription in TwiML:
We do not recommend using passive voice capture rules (captureRules) in your Configuration when using active TwiML.
See the full Voice Double Billing Warning section below for details.
Capture all channels (voice, SMS, WhatsApp, RCS, CHAT (via Conversation API (classic))) into a single conversation thread per customer. Conversation Memory resolves identity across channels and maintains persistent context. Start here — this is the most common pattern.
GROUP_BY_PROFILEKeep voice transcripts separate from SMS threads for per-channel analysis. Intelligence operators run independently on each channel's conversation.
GROUP_BY_PARTICIPANT_ADDRESSES_AND_CHANNEL_TYPECapture conversations for AI-to-human escalation via Agent Connect (TAC SDK). Uses address-pair grouping required by the SDK.
GROUP_BY_PARTICIPANT_ADDRESSESAutomatically extract observations from conversations into Conversation Memory. Opt-in — configure once, every conversation feeds the memory loop.
memoryExtractionEnabled: true┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. Inbound/outbound traffic arrives (SMS, Voice, WhatsApp, RCS) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2. Capture rules match on phone number patterns │
│ - from/to with wildcards (e.g., from: *, to: +15551234567) │
│ - Per-channel rules (SMS, VOICE, WHATSAPP, RCS) │
│ - Metadata filters (callType for CLIENT/SIP) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. Conversation auto-created (or existing one matched via grouping) │
│ - GROUP_BY_PROFILE: merge by Memory Profile identity │
│ - GROUP_BY_PARTICIPANT_ADDRESSES: merge by address pair │
│ - GROUP_BY_..._AND_CHANNEL_TYPE: separate per channel │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 4. Linked services activate │
│ - Memory Store: identity resolution, profile auto-creation │
│ - Intelligence: operators fire per Communication or at close │
│ - Status timeouts: ACTIVE → INACTIVE → CLOSED lifecycle │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 5. On conversation close │
│ - Memory extraction: observations written to Memory Store │
│ - CONVERSATION_END Intelligence operators fire (Summary, etc.) │
│ - Status callbacks delivered (if configured) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. Voice call arrives OR you create conversation via API │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2a. TwiML: Pass conversationConfiguration or conversationId parameter │
│ - <ConversationRelay conversationConfiguration="CONFIG_ID"> │
│ - <Transcription conversationId="CONV_ID"> │
│ 2b. API: POST to /v2/Conversations with participants │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. Conversation created or matched based on parameter │
│ - conversationConfiguration: uses grouping rules │
│ - conversationId: routes to specific conversation │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 4. Voice transcription or API communications added │
│ - Same linked services activate (Memory Store, Intelligence) │
│ - Same lifecycle: ACTIVE → INACTIVE → CLOSED │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 5. On conversation close │
│ - Memory extraction: observations written to Memory Store │
│ - CONVERSATION_END Intelligence operators fire (Summary, etc.) │
│ - Status callbacks delivered (if configured) │
└─────────────────────────────────────────────────────────────────────────────┘
GROUP_BY_PROFILEconversationsV1BridgestatusCallbacks for webhook notifications on conversation state changesconversationConfiguration or conversationId in <ConversationRelay> or <Transcription> TwiML to create or route to a conversation (Active TwiML mode) — both parameters supported in both TwiML verbsconversationGroupingType is immutable on a Configuration. Create a new config if you need a different grouping.metadata.callType in capture rules.conversationConfiguration or conversationId in <ConversationRelay> or <Transcription> TwiML, you are using active ingestion which has its own STT engine. Both engines will run = double STT billing. Use active TwiML (pass conversation parameters) OR passive capture rules (captureRules), not both for the same traffic. See the Voice Double Billing Warning section above.memoryStoreId points to a deleted or invalid store, capture still works but identity resolution and extraction silently fail. See twilio-debugging-observability.twilio-customer-memory.statusUrl until status is COMPLETED, then retrieve the configuration ID from the operation result.memoryStoreId is mandatory when creating a Configuration.Content-Type: application/json. Form-encoded bodies are rejected.| Need | Use | Why |
|---|---|---|
| Already have ConversationRelay or Transcription voice implementation | Pass conversationConfiguration or conversationId in TwiML (Active ingestion) — do NOT add passive VOICE capture rules | More granular control over which calls are captured. Avoids double STT billing. |
| Capture all messaging into unified customer conversations | Configuration with passive capture rules + Memory Store + GROUP_BY_PROFILE | Automatic capture with cross-channel identity resolution |
| Keep voice and SMS conversations separate | Configuration with GROUP_BY_PARTICIPANT_ADDRESSES_AND_CHANNEL_TYPE | Channel-isolated threads for per-channel analytics |
| Auto-extract customer observations from conversations | Set memoryExtractionEnabled: true on Configuration | Triggers on conversation close, writes to linked Memory Store |
| Analyze conversations with Intelligence operators | Link intelligenceConfigurationIds on Configuration | Operators fire per Communication or at conversation close |
| Capture browser voice calls (Client SDK) | Add VOICE capture rule with metadata.callType: "CLIENT" | PSTN-only by default; CLIENT needs explicit rule |
| Capture CHAT (via Conversation API (classic)) | Set conversationsV1Bridge.serviceId on Configuration | CHAT flows through a Conversations (v1) Service bridged into Orchestrator |
WARNING: You can be charged for STT (speech-to-text) twice on the same call if misconfigured.
We do not recommend using passive voice capture rules (captureRules) in your Configuration when using active TwiML.
When you pass conversationConfiguration or conversationId in your TwiML:
<ConversationRelay conversationConfiguration="CONFIG_ID"> — Active TwiML mode<ConversationRelay conversationId="CONVERSATION_ID"> — Active TwiML mode<Transcription conversationConfiguration="CONFIG_ID"> — Active TwiML mode<Transcription conversationId="CONVERSATION_ID"> — Active TwiML modeYou are using active ingestion. Your voice is already being captured and transcribed.
What causes double billing:
Correct configuration for active voice (TwiML):
{
"channelSettings": {
"VOICE": {
"statusTimeouts": null // ✅ Define channel settings
// ❌ NO captureRules — omit this field entirely
}
}
}
When to use passive voice capture rules:
When to use active voice (TwiML parameters):
The conversationGroupingType on your configuration controls how new traffic groups into conversations.
| Type | Behavior | When to use |
|---|---|---|
GROUP_BY_PARTICIPANT_ADDRESSES_AND_CHANNEL_TYPE | Separate conversations per channel. SMS and Voice between the same numbers create different conversations. | The default. Keeps channels separate. |
GROUP_BY_PARTICIPANT_ADDRESSES | Same conversation across channels when participants share an address. | Omnichannel on the same addresses—customer can switch between SMS and Voice seamlessly. |
GROUP_BY_PROFILE | Groups by customer profile. The same customer from different devices or channels goes to one conversation. | Preferred for production. Recommended when channels use different addresses (chat and voice). |
Immutable after creation. Choose before creating the Configuration. To change grouping, create a new Configuration.
Conversation Orchestrator supports voice, SMS, RCS, and WhatsApp channels. You can also bring Chat traffic in through the Conversations API (classic) bridge.
| Channel | Address Format | Example | Ingestion Modes |
|---|---|---|---|
| Voice (PSTN) | E.164 phone number | +15559876543 | Passive and active |
| Voice (CLIENT) | Client identity string | agent-1 | Passive and active |
| Voice (PUBLIC_SIP) | SIP URI or E.164 phone number | sip:user@example.com | Passive and active |
| SMS | E.164 phone number | +15551234567 | Passive and active |
| RCS | E.164 phone number | +15551234567 | Passive and active |
| E.164 phone number | +15551234567 | Passive and active | |
| Chat | Identity string | user123 | Conversations API (classic) bridge only |
Voice:
callType metadata in passive capture rules to distinguish call types:
PSTN — Standard phone calls over the public networkCLIENT — In-app calls using Twilio Voice SDKPUBLIC_SIP — Calls over a SIP interfacecontent.type of TRANSCRIPTIONCLIENT call type.SMS:
content.type of TEXTdeliveryStatus in recipients arrayinactive: 10, closed: 60RCS:
inactive: 10, closed: 60WhatsApp:
whatsapp: prefix)inactive: 10, closed: 60Chat (via Conversations API (classic)):
conversationsV1Bridge.serviceId on ConfigurationConversationSid carried on address as channelIdinactive: 15, closed: 60Code samples use raw fetch() for clarity. All Conversation Orchestrator APIs use Basic Auth — see twilio-iam-auth-setup.
const CONVERSATIONS_V2_BASE = 'https://conversations.twilio.com/v2';
function getAuthHeaders() {
const credentials = Buffer.from(
`${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
).toString('base64');
return {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json',
};
}
const configResponse = await fetch(
`${CONVERSATIONS_V2_BASE}/ControlPlane/Configurations`,
{
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
displayName: 'my-app-config',
description: 'Production conversation config',
conversationGroupingType: 'GROUP_BY_PROFILE',
memoryStoreId: 'mem_store_...', // Required — create via Memory API first
memoryExtractionEnabled: true,
channelSettings: {
SMS: {
captureRules: [
{ from: '+15551234567', to: '*', metadata: {} },
{ from: '*', to: '+15551234567', metadata: {} },
],
statusTimeouts: { inactive: 10, closed: 60 },
},
VOICE: {
captureRules: [
{ from: '*', to: '+15551234567', metadata: {} },
],
},
},
}),
}
);
// May return 202 without config ID — poll GET /ControlPlane/Configurations to find by displayName
import os, requests
account_sid = os.environ["TWILIO_ACCOUNT_SID"]
auth_token = os.environ["TWILIO_AUTH_TOKEN"]
twilio_phone = os.environ["TWILIO_PHONE_NUMBER"]
config = requests.post(
"https://conversations.twilio.com/v2/ControlPlane/Configurations",
auth=(account_sid, auth_token),
json={
"displayName": "my-app-config",
"description": "Production conversation config",
"conversationGroupingType": "GROUP_BY_PROFILE",
"memoryStoreId": "mem_store_...",
"memoryExtractionEnabled": True,
"channelSettings": {
"SMS": {
"captureRules": [
{"from": twilio_phone, "to": "*", "metadata": {}},
{"from": "*", "to": twilio_phone, "metadata": {}}
],
"statusTimeouts": {"inactive": 10, "closed": 60}
},
"VOICE": {
"captureRules": [
{"from": "*", "to": twilio_phone, "metadata": {}}
]
}
}
}
).json()
// Step 1: Fetch current config (ALWAYS re-fetch before updating)
const current = await fetch(
`${CONVERSATIONS_V2_BASE}/ControlPlane/Configurations/${configId}`,
{ headers: getAuthHeaders() }
).then(r => r.json());
// Step 2: Modify the field you need
current.channelSettings.VOICE.captureRules.push(
{ from: '*', to: '+15551234567', metadata: { callType: 'CLIENT' } }
);
// Step 3: PUT the complete object back
await fetch(
`${CONVERSATIONS_V2_BASE}/ControlPlane/Configurations/${configId}`,
{
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(current),
}
);
# Fetch current config
current = requests.get(
f"https://conversations.twilio.com/v2/ControlPlane/Configurations/{config_id}",
auth=(account_sid, auth_token)
).json()
# Modify and PUT the whole thing back
current["channelSettings"]["VOICE"]["captureRules"].append(
{"from": "*", "to": twilio_phone, "metadata": {"callType": "CLIENT"}}
)
requests.put(
f"https://conversations.twilio.com/v2/ControlPlane/Configurations/{config_id}",
auth=(account_sid, auth_token),
json=current
)
// Fetch current config, add Intelligence, PUT back
const current = await fetch(
`${CONVERSATIONS_V2_BASE}/ControlPlane/Configurations/${configId}`,
{ headers: getAuthHeaders() }
).then(r => r.json());
current.intelligenceConfigurationIds = [intelligenceConfigId];
await fetch(
`${CONVERSATIONS_V2_BASE}/ControlPlane/Configurations/${configId}`,
{
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(current),
}
);
// List active conversations
const conversations = await fetch(
`${CONVERSATIONS_V2_BASE}/Conversations?Status=ACTIVE&PageSize=10`,
{ headers: getAuthHeaders() }
).then(r => r.json());
for (const conv of conversations.conversations ?? []) {
// Note: List view has minimal data. For full details, fetch individual conversation
console.log(`Conversation: ${conv.id}, Created: ${conv.createdAt || 'N/A'}`);
// List communications (messages + voice utterances)
const comms = await fetch(
`${CONVERSATIONS_V2_BASE}/Conversations/${conv.id}/Communications`,
{ headers: getAuthHeaders() }
).then(r => r.json());
for (const comm of comms.communications ?? []) {
// Use optional chaining - channel and body may be undefined in list view
console.log(`[${comm.channel ?? 'N/A'}] ${comm.body ?? 'N/A'}`);
}
}
conversations = requests.get(
"https://conversations.twilio.com/v2/Conversations",
auth=(account_sid, auth_token),
params={"Status": "ACTIVE", "PageSize": 10}
).json()
for conv in conversations.get("conversations", []):
conv_id = conv["id"]
# Note: List view has minimal data. Use .get() for defensive access
print(f"Conversation: {conv_id}, Created: {conv.get('createdAt', 'N/A')}")
comms = requests.get(
f"https://conversations.twilio.com/v2/Conversations/{conv_id}/Communications",
auth=(account_sid, auth_token)
).json()
for comm in comms.get("communications", []):
# Use .get() - channel and body may be missing in list view
print(f" [{comm.get('channel', 'N/A')}] {comm.get('body', 'N/A')}")
Closing triggers Memory extraction (if enabled) and CONVERSATION_END Intelligence operators.
await fetch(
`${CONVERSATIONS_V2_BASE}/Conversations/${convId}`,
{
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify({ status: 'CLOSED' }),
}
);
requests.patch(
f"https://conversations.twilio.com/v2/Conversations/{conv_id}",
auth=(account_sid, auth_token),
json={"status": "CLOSED"}
)
Active TwiML (recommended for AI voice agents): Pass conversationConfiguration on <ConversationRelay> to create a new conversation. Do NOT add passive VOICE captureRules — this avoids double STT billing. See the Voice Double Billing Warning section above.
<Response>
<Connect>
<ConversationRelay
url="wss://your-relay/voice"
conversationConfiguration="CONFIG_ID_HERE"
ttsProvider="ElevenLabs"
voice="your-voice-id"
/>
</Connect>
</Response>
Still define VOICE in channelSettings for lifecycle/timeouts — just omit captureRules:
{
"channelSettings": {
"VOICE": {
"statusTimeouts": null
}
}
}
Attach voice to an existing conversation (Real-Time Transcription): Use <Transcription> with conversationId to add a voice call's transcription to a conversation you created via API:
<Response>
<Start>
<Transcription conversationId="CONVERSATION_ID"/>
</Start>
<Say>Welcome to support. How can I help you today?</Say>
</Response>
Passive voice capture (human agent calls): Use VOICE captureRules to automatically capture calls without TwiML changes. Appropriate for human agent scenarios where ConversationRelay is not used:
{
"VOICE": {
"captureRules": [
{ "from": "*", "to": "+15551234567", "metadata": {} }
]
}
}
Warning: Do NOT combine passive VOICE capture rules with active TwiML voice. See the Voice Double Billing Warning section above.
Memory Store is required. You cannot create a Configuration without a memoryStoreId. Create the Memory Store first via twilio-customer-memory.
JSON-only API. All Conversation Orchestrator endpoints require Content-Type: application/json. Form-encoded bodies are rejected. This matches Intelligence v3 but differs from most Twilio APIs.
Async creation. POST to /ControlPlane/Configurations returns 202 with an operation. Poll the operation's statusUrl until status is COMPLETED, then retrieve the configuration ID from the operation result.
PUT replaces everything. The most common bug: fetching a config, modifying one field, PUTting back — but forgetting to include channelSettings or memoryStoreId. The API accepts the PUT and silently removes the omitted fields. Always re-fetch, modify, PUT.
Grouping type is immutable. conversationGroupingType cannot be changed after creation. To switch grouping, create a new Configuration and close conversations on the old one.
10 Configuration limit per account. Hard limit at GA (up to 100 capture rules per channel per config). Delete unused Configurations to make room. For customers with large phone number portfolios, partition numbers across multiple Configurations.
CLIENT voice capture is opt-in. Browser-originated calls via the Twilio Client SDK are not captured by default VOICE rules. You need a separate capture rule with "metadata": {"callType": "CLIENT"}. SIP calls similarly need {"callType": "PUBLIC_SIP"}. PSTN is the only type captured by default.
conversationConfiguration (no "Id" suffix) is the correct TwiML attribute name. The attribute on <ConversationRelay> and <Transcription> is conversationConfiguration, NOT conversationConfigurationId. The incorrect name is silently ignored (unrecognized TwiML attributes produce no error), resulting in no conversation being created.
Timeout precedence across channels. If a customer is on a voice call and sends an SMS, both channels are active in the same Conversation (with GROUP_BY_PROFILE). When the voice call ends, the SMS channel's timeout still governs — the Conversation won't close until the SMS timeout expires. Channel close events are proposals, not commands.
Config versioning pins at creation. Intelligence rules and capture rules are pinned to the Configuration version at conversation creation time. Upgrading Intelligence (adding operators, changing rules) doesn't affect existing conversations. Close active conversations to pick up the new version.
ConversationRelay TTS fragmentation. ConversationRelay writes one Communication per TTS fragment, not per complete utterance. A single agent response may produce 3-5 Communications. Intelligence operators fire per Communication, so operator cost scales with fragment count.
Overly broad wildcard VOICE rules match multiple call types. A rule {"from": "*", "to": "*", "metadata": {"callType": "PSTN"}} will match all PSTN calls in your account, not just those to/from specific numbers. If you also have CLIENT capture rules, each call could match multiple rules, leading to unexpected conversation grouping. Always use specific from or to addresses to limit rule scope.
Active TwiML voice and passive capture rules cause double STT billing. See the Voice Double Billing Warning section for full details. Do not use passive VOICE captureRules when passing conversation parameters in TwiML.
Silent Memory linkage failure. If memoryStoreId points to a deleted or invalid store, capture still works but identity resolution and extraction silently fail. No error is returned. See twilio-debugging-observability.
No participant type filtering for Intelligence. Operators fire on ALL Communications — customer messages AND agent responses. There is no config-level filter. Use the operator prompt to specify which participant to analyze.
Memory extraction is opt-in and fires on INACTIVE and/or CLOSED. Extraction does not run automatically — it must be enabled. It can be configured to fire on the INACTIVE transition, the CLOSED transition, or both. It does NOT fire while a conversation is ACTIVE. For mid-conversation Memory writes, post directly to the Observations endpoint via twilio-customer-memory.
List endpoints return partial data. When listing Conversations or Communications via GET /Conversations or /Conversations/{id}/Communications, response objects are missing fields that are present when fetching individual resources. Missing fields include dateCreated (list) vs createdAt (single GET), channels, body, and channel. Always use defensive field access (conv?.createdAt or conv.get('createdAt')) and fetch individual resources if you need complete data. Example:
// List returns partial data
const list = await fetch(`${BASE}/Conversations?PageSize=10`);
for (const conv of list.conversations) {
console.log(conv.dateCreated); // undefined
console.log(conv.createdAt); // also undefined in list view
// Fetch full details if needed
const full = await fetch(`${BASE}/Conversations/${conv.id}`);
console.log(full.createdAt); // ✅ present (note: 'createdAt' not 'dateCreated')
}