{"slug":"signalwire-call-intelligence","title":"SignalWire Call Intelligence Pipeline","tags":["signalwire","call-intelligence","sentiment","hawkeye","birdseye-roi","post-prompt"],"agent_summary":"End-to-end pipeline: inbound call → record_call + live_transcribe + ai → post_prompt extracts structured JSON → webhook saves to DB → dashboard. Includes Relay SDK methods (call.ai, ai_hold, ai_unhold, ai_message), debug webhook streaming, and CRM injection patterns.","trigger_phrases":["call sentiment AI","HawkeyePanel pipeline","BirdsEyeROI calls","extract structured data from call","post_prompt webhook","call.ai_hold ai_unhold","real-time AI control"],"runnable":true,"markdown":"\n# SignalWire Call Intelligence Pipeline\n\nThe canonical pattern for building a call-sentiment dashboard, lead-intelligence pipeline, or any system that turns calls into structured data.\n\n## The full flow\n\n1. Inbound call arrives → SWML script executes.\n2. `record_call` starts background stereo recording.\n3. `live_transcribe` streams real-time transcript to your webhook.\n4. `ai` verb runs the agent with a `post_prompt` that extracts structured JSON.\n5. Call ends → `post_prompt_url` receives the payload: transcript, sentiment, intent, CRM fields.\n6. Backend parses and writes to DB.\n7. Dashboard queries DB and renders sentiment scores, summaries, extracted data.\n\nKey principle: `record_call`, `live_transcribe`, and `ai` are non-blocking background verbs. They run concurrently.\n\n## Production SWML — concurrent record + transcribe + AI\n\n```yaml\nversion: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - record_call:\n        format: mp3\n        stereo: true              # required for per-speaker analysis\n        direction: both\n        max_length: 3600\n        status_url: \"https://your.api/webhooks/recording\"\n    - live_transcribe:\n        action:\n          start:\n            webhook: \"https://your.api/webhooks/transcript\"\n            lang: en\n            live_events: true\n            ai_summary: true\n            direction: [remote-caller, local-caller]\n            speech_engine: deepgram\n            vad_silence_ms: 300\n    - ai:\n        prompt:\n          text: |\n            ## Role\n            You are a sales call assistant for Acme Corp.\n            Greet the caller, identify their needs, guide them to the right product.\n          temperature: 0.7\n        params:\n          end_of_speech_timeout: 700\n          asr_diarize: true\n          asr_smart_format: true\n          save_conversation: true\n          hard_stop_time: \"30m\"\n        languages:\n          - { name: English, code: en-US, voice: rime.spore }\n        global_data:\n          campaign: spring-sale\n          agent_id: hawkeye-001\n        post_prompt:\n          text: |\n            Analyze the conversation. Return ONLY valid JSON, no prose:\n            {\n              \"sentiment\": \"positive|neutral|negative\",\n              \"sentiment_score\": 0.0,\n              \"caller_intent\": \"string\",\n              \"key_topics\": [\"string\"],\n              \"outcome\": \"sold|not_sold|follow_up|transferred|other\",\n              \"follow_up_required\": true,\n              \"caller_name\": \"string or null\",\n              \"caller_email\": \"string or null\",\n              \"summary\": \"2-3 sentence summary\"\n            }\n          temperature: 0.2\n        post_prompt_url: \"https://your.api/webhooks/post-prompt\"\n```\n\n## Relay SDK equivalent — `call.ai()` with full control\n\nFor programmatic control (start AI mid-call, swap prompts, inject context) use the Relay SDK.\n\n```python\nfrom signalwire.relay import RelayClient\n\nclient = RelayClient(\n    project=\"your-project-id\",\n    token=\"your-api-token\",\n    host=\"your-space.signalwire.com\",\n    contexts=[\"default\"],\n)\n\n@client.on_call\nasync def handle_call(call):\n    await call.answer()\n\n    action = await call.ai(\n        prompt={\n            \"text\": \"You are a sales call assistant for Acme Corp...\",\n            \"temperature\": 0.7,\n            \"top_p\": 0.9,\n        },\n        post_prompt={\n            \"text\": \"\"\"Return ONLY JSON:\n{\n  \"sentiment\": \"positive|neutral|negative\",\n  \"sentiment_score\": 0.0,\n  \"caller_intent\": \"string\",\n  \"outcome\": \"sold|not_sold|follow_up|transferred|other\",\n  \"follow_up_required\": true,\n  \"caller_email\": \"string or null\",\n  \"summary\": \"2-3 sentence summary\"\n}\"\"\"\n        },\n        post_prompt_url=\"https://your.api/webhooks/post-prompt\",\n        post_prompt_auth_user=\"webhook_user\",\n        post_prompt_auth_password=\"webhook_pass\",\n        hints=[\"Acme\", \"product names\", \"pricing tiers\"],\n        global_data={\"campaign\": \"spring-sale\"},\n        ai_params={\n            \"asr_diarize\": True,\n            \"asr_smart_format\": True,\n            \"end_of_speech_timeout\": 700,\n            \"energy_level\": 52,\n            \"attention_timeout\": 10000,\n            \"hard_stop_time\": \"30m\",\n            \"save_conversation\": True,\n            \"debug_webhook_url\": \"https://your.api/webhooks/debug\",\n            \"debug_webhook_level\": 2,\n        },\n        on_completed=lambda event: print(\"AI session ended\"),\n    )\n\n    await action.wait()\n\nclient.run()\n```\n\n## Pause and resume the AI mid-call\n\n`ai_hold` + `ai_unhold` pause the AI agent while keeping the call alive. Use for supervisor barge-in, CRM lookup, or compliance pause.\n\n```python\n# Pause the AI; speak a hold message; auto-resume after timeout.\nawait call.ai_hold(\n    prompt=\"One moment while I check your account.\",\n    timeout=\"60\",\n)\n\n# Do backend work...\naccount = await fetch_account(caller_id)\n\n# Inject context as a system message before resuming.\nawait call.ai_message(\n    message_text=f\"Customer status: {account['status']}. Balance: ${account['balance']}.\",\n    role=\"system\",\n)\n\nawait call.ai_unhold(\n    prompt=\"Thanks for holding. I've pulled up your account.\"\n)\n```\n\n## Inject context, simulate input, or reset\n\n| Action | Method | Purpose |\n|---|---|---|\n| Inject system instruction (invisible to caller) | `call.ai_message(role=\"system\", message_text=...)` | Mid-call CRM data, supervisor note. |\n| Update session data | `call.ai_update_global_data({...})` | Add caller_name, account_id after lookup. |\n| Simulate user input | `call.ai_simulate_input(...)` | Testing or IVR-style forced injection. |\n| Reset conversation | `call.ai_reset_conversation()` | Start over after handoff. |\n\n## Post-prompt webhook payload\n\nWhen the call ends and `post_prompt_url` is set, SignalWire POSTs:\n\n```json\n{\n  \"action\": \"post_conversation\",\n  \"ai_session_id\": \"uuid\",\n  \"ai_start_date\": 1640000000,\n  \"ai_end_date\": 1640000900,\n  \"call_id\": \"uuid\",\n  \"call_start_date\": 1640000000,\n  \"call_answer_date\": 1640000003,\n  \"call_end_date\": 1640000900,\n  \"caller_id_num\": \"+15551234567\",\n  \"caller_id_name\": \"Jane Smith\",\n  \"call_log\": [\n    { \"role\": \"system\", \"content\": \"You are a sales call assistant...\" },\n    { \"role\": \"user\", \"content\": \"Hi, I'm interested in pricing...\" },\n    { \"role\": \"assistant\", \"content\": \"Happy to help...\" }\n  ],\n  \"post_prompt_data\": {\n    \"raw\": \"{ \\\"sentiment\\\": \\\"positive\\\", ... }\",\n    \"parsed\": {\n      \"sentiment\": \"positive\",\n      \"sentiment_score\": 0.85,\n      \"caller_intent\": \"pricing inquiry\",\n      \"outcome\": \"follow_up\",\n      \"follow_up_required\": true,\n      \"summary\": \"Caller wants pricing for enterprise tier...\"\n    }\n  },\n  \"swaig_log\": [\n    { \"function\": \"lookup_account\", \"args\": {...}, \"result\": {...} }\n  ]\n}\n```\n\nRead `post_prompt_data.parsed` first. Fall back to parsing `raw` only if the AI included prose.\n\n## Database schema for a call-intelligence dashboard\n\nMinimum useful columns:\n\n| Column | Source |\n|---|---|\n| `call_id` | `call_id` |\n| `ai_session_id` | `ai_session_id` |\n| `from_number`, `to_number` | `caller_id_num`, fetched DID |\n| `started_at`, `ended_at` | `call_start_date`, `call_end_date` |\n| `duration_seconds` | derived |\n| `recording_url` | from `status_url` webhook |\n| `transcript_url` | from `live_transcribe` summary webhook |\n| `sentiment`, `sentiment_score` | `post_prompt_data.parsed.sentiment` |\n| `caller_intent`, `outcome` | `post_prompt_data.parsed.*` |\n| `follow_up_required` | bool from parsed |\n| `summary` | text from parsed |\n| `raw_payload` | jsonb of the full POST body |\n| `swaig_calls` | jsonb of `swaig_log` |\n\n## Anti-patterns\n\n- Relying on `live_transcribe` final transcript for analytics — use `record_call` + post-call transcription (or `post_prompt` summary). `live_transcribe` is for real-time UI, not source-of-truth.\n- Not setting `asr_diarize: true` when you need per-speaker sentiment.\n- Putting your post-prompt JSON schema in plain prose — LLMs follow code-fenced JSON examples much more reliably.\n- Skipping `post_prompt_auth_user/password` — anyone who guesses the webhook URL can spoof call results.\n- Storing only the parsed JSON — keep the raw payload in `jsonb` for re-analysis later.\n\n## See also\n\n- [SWML AI verb](/topic/swml-ai-verb)\n- [SWML record and transcribe](/topic/swml-record-and-transcribe)\n- [SWAIG functions](/topic/swaig-functions)\n- [AssemblyAI transcription](/topic/assemblyai-transcription)\n","html":"<h1>SignalWire Call Intelligence Pipeline</h1>\n<p>The canonical pattern for building a call-sentiment dashboard, lead-intelligence pipeline, or any system that turns calls into structured data.</p>\n<h2>The full flow</h2>\n<ol>\n<li>Inbound call arrives → SWML script executes.</li>\n<li><code>record_call</code> starts background stereo recording.</li>\n<li><code>live_transcribe</code> streams real-time transcript to your webhook.</li>\n<li><code>ai</code> verb runs the agent with a <code>post_prompt</code> that extracts structured JSON.</li>\n<li>Call ends → <code>post_prompt_url</code> receives the payload: transcript, sentiment, intent, CRM fields.</li>\n<li>Backend parses and writes to DB.</li>\n<li>Dashboard queries DB and renders sentiment scores, summaries, extracted data.</li>\n</ol>\n<p>Key principle: <code>record_call</code>, <code>live_transcribe</code>, and <code>ai</code> are non-blocking background verbs. They run concurrently.</p>\n<h2>Production SWML — concurrent record + transcribe + AI</h2>\n<pre><code class=\"language-yaml\">version: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - record_call:\n        format: mp3\n        stereo: true              # required for per-speaker analysis\n        direction: both\n        max_length: 3600\n        status_url: \"https://your.api/webhooks/recording\"\n    - live_transcribe:\n        action:\n          start:\n            webhook: \"https://your.api/webhooks/transcript\"\n            lang: en\n            live_events: true\n            ai_summary: true\n            direction: [remote-caller, local-caller]\n            speech_engine: deepgram\n            vad_silence_ms: 300\n    - ai:\n        prompt:\n          text: |\n            ## Role\n            You are a sales call assistant for Acme Corp.\n            Greet the caller, identify their needs, guide them to the right product.\n          temperature: 0.7\n        params:\n          end_of_speech_timeout: 700\n          asr_diarize: true\n          asr_smart_format: true\n          save_conversation: true\n          hard_stop_time: \"30m\"\n        languages:\n          - { name: English, code: en-US, voice: rime.spore }\n        global_data:\n          campaign: spring-sale\n          agent_id: hawkeye-001\n        post_prompt:\n          text: |\n            Analyze the conversation. Return ONLY valid JSON, no prose:\n            {\n              \"sentiment\": \"positive|neutral|negative\",\n              \"sentiment_score\": 0.0,\n              \"caller_intent\": \"string\",\n              \"key_topics\": [\"string\"],\n              \"outcome\": \"sold|not_sold|follow_up|transferred|other\",\n              \"follow_up_required\": true,\n              \"caller_name\": \"string or null\",\n              \"caller_email\": \"string or null\",\n              \"summary\": \"2-3 sentence summary\"\n            }\n          temperature: 0.2\n        post_prompt_url: \"https://your.api/webhooks/post-prompt\"\n</code></pre>\n<h2>Relay SDK equivalent — <code>call.ai()</code> with full control</h2>\n<p>For programmatic control (start AI mid-call, swap prompts, inject context) use the Relay SDK.</p>\n<pre><code class=\"language-python\">from signalwire.relay import RelayClient\n\nclient = RelayClient(\n    project=\"your-project-id\",\n    token=\"your-api-token\",\n    host=\"your-space.signalwire.com\",\n    contexts=[\"default\"],\n)\n\n@client.on_call\nasync def handle_call(call):\n    await call.answer()\n\n    action = await call.ai(\n        prompt={\n            \"text\": \"You are a sales call assistant for Acme Corp...\",\n            \"temperature\": 0.7,\n            \"top_p\": 0.9,\n        },\n        post_prompt={\n            \"text\": \"\"\"Return ONLY JSON:\n{\n  \"sentiment\": \"positive|neutral|negative\",\n  \"sentiment_score\": 0.0,\n  \"caller_intent\": \"string\",\n  \"outcome\": \"sold|not_sold|follow_up|transferred|other\",\n  \"follow_up_required\": true,\n  \"caller_email\": \"string or null\",\n  \"summary\": \"2-3 sentence summary\"\n}\"\"\"\n        },\n        post_prompt_url=\"https://your.api/webhooks/post-prompt\",\n        post_prompt_auth_user=\"webhook_user\",\n        post_prompt_auth_password=\"webhook_pass\",\n        hints=[\"Acme\", \"product names\", \"pricing tiers\"],\n        global_data={\"campaign\": \"spring-sale\"},\n        ai_params={\n            \"asr_diarize\": True,\n            \"asr_smart_format\": True,\n            \"end_of_speech_timeout\": 700,\n            \"energy_level\": 52,\n            \"attention_timeout\": 10000,\n            \"hard_stop_time\": \"30m\",\n            \"save_conversation\": True,\n            \"debug_webhook_url\": \"https://your.api/webhooks/debug\",\n            \"debug_webhook_level\": 2,\n        },\n        on_completed=lambda event: print(\"AI session ended\"),\n    )\n\n    await action.wait()\n\nclient.run()\n</code></pre>\n<h2>Pause and resume the AI mid-call</h2>\n<p><code>ai_hold</code> + <code>ai_unhold</code> pause the AI agent while keeping the call alive. Use for supervisor barge-in, CRM lookup, or compliance pause.</p>\n<pre><code class=\"language-python\"># Pause the AI; speak a hold message; auto-resume after timeout.\nawait call.ai_hold(\n    prompt=\"One moment while I check your account.\",\n    timeout=\"60\",\n)\n\n# Do backend work...\naccount = await fetch_account(caller_id)\n\n# Inject context as a system message before resuming.\nawait call.ai_message(\n    message_text=f\"Customer status: {account['status']}. Balance: ${account['balance']}.\",\n    role=\"system\",\n)\n\nawait call.ai_unhold(\n    prompt=\"Thanks for holding. I've pulled up your account.\"\n)\n</code></pre>\n<h2>Inject context, simulate input, or reset</h2>\n<p>| Action | Method | Purpose |\n|---|---|---|\n| Inject system instruction (invisible to caller) | <code>call.ai_message(role=\"system\", message_text=...)</code> | Mid-call CRM data, supervisor note. |\n| Update session data | <code>call.ai_update_global_data({...})</code> | Add caller_name, account_id after lookup. |\n| Simulate user input | <code>call.ai_simulate_input(...)</code> | Testing or IVR-style forced injection. |\n| Reset conversation | <code>call.ai_reset_conversation()</code> | Start over after handoff. |</p>\n<h2>Post-prompt webhook payload</h2>\n<p>When the call ends and <code>post_prompt_url</code> is set, SignalWire POSTs:</p>\n<pre><code class=\"language-json\">{\n  \"action\": \"post_conversation\",\n  \"ai_session_id\": \"uuid\",\n  \"ai_start_date\": 1640000000,\n  \"ai_end_date\": 1640000900,\n  \"call_id\": \"uuid\",\n  \"call_start_date\": 1640000000,\n  \"call_answer_date\": 1640000003,\n  \"call_end_date\": 1640000900,\n  \"caller_id_num\": \"+15551234567\",\n  \"caller_id_name\": \"Jane Smith\",\n  \"call_log\": [\n    { \"role\": \"system\", \"content\": \"You are a sales call assistant...\" },\n    { \"role\": \"user\", \"content\": \"Hi, I'm interested in pricing...\" },\n    { \"role\": \"assistant\", \"content\": \"Happy to help...\" }\n  ],\n  \"post_prompt_data\": {\n    \"raw\": \"{ \\\"sentiment\\\": \\\"positive\\\", ... }\",\n    \"parsed\": {\n      \"sentiment\": \"positive\",\n      \"sentiment_score\": 0.85,\n      \"caller_intent\": \"pricing inquiry\",\n      \"outcome\": \"follow_up\",\n      \"follow_up_required\": true,\n      \"summary\": \"Caller wants pricing for enterprise tier...\"\n    }\n  },\n  \"swaig_log\": [\n    { \"function\": \"lookup_account\", \"args\": {...}, \"result\": {...} }\n  ]\n}\n</code></pre>\n<p>Read <code>post_prompt_data.parsed</code> first. Fall back to parsing <code>raw</code> only if the AI included prose.</p>\n<h2>Database schema for a call-intelligence dashboard</h2>\n<p>Minimum useful columns:</p>\n<p>| Column | Source |\n|---|---|\n| <code>call_id</code> | <code>call_id</code> |\n| <code>ai_session_id</code> | <code>ai_session_id</code> |\n| <code>from_number</code>, <code>to_number</code> | <code>caller_id_num</code>, fetched DID |\n| <code>started_at</code>, <code>ended_at</code> | <code>call_start_date</code>, <code>call_end_date</code> |\n| <code>duration_seconds</code> | derived |\n| <code>recording_url</code> | from <code>status_url</code> webhook |\n| <code>transcript_url</code> | from <code>live_transcribe</code> summary webhook |\n| <code>sentiment</code>, <code>sentiment_score</code> | <code>post_prompt_data.parsed.sentiment</code> |\n| <code>caller_intent</code>, <code>outcome</code> | <code>post_prompt_data.parsed.*</code> |\n| <code>follow_up_required</code> | bool from parsed |\n| <code>summary</code> | text from parsed |\n| <code>raw_payload</code> | jsonb of the full POST body |\n| <code>swaig_calls</code> | jsonb of <code>swaig_log</code> |</p>\n<h2>Anti-patterns</h2>\n<ul>\n<li>Relying on <code>live_transcribe</code> final transcript for analytics — use <code>record_call</code> + post-call transcription (or <code>post_prompt</code> summary). <code>live_transcribe</code> is for real-time UI, not source-of-truth.</li>\n<li>Not setting <code>asr_diarize: true</code> when you need per-speaker sentiment.</li>\n<li>Putting your post-prompt JSON schema in plain prose — LLMs follow code-fenced JSON examples much more reliably.</li>\n<li>Skipping <code>post_prompt_auth_user/password</code> — anyone who guesses the webhook URL can spoof call results.</li>\n<li>Storing only the parsed JSON — keep the raw payload in <code>jsonb</code> for re-analysis later.</li>\n</ul>\n<h2>See also</h2>\n<ul>\n<li><a href=\"/topic/swml-ai-verb\">SWML AI verb</a></li>\n<li><a href=\"/topic/swml-record-and-transcribe\">SWML record and transcribe</a></li>\n<li><a href=\"/topic/swaig-functions\">SWAIG functions</a></li>\n<li><a href=\"/topic/assemblyai-transcription\">AssemblyAI transcription</a></li>\n</ul>\n"}