T
Telephony SOPKnowledge Base
Search
← All topics

SignalWire Call Intelligence Pipeline

runnable

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.

signalwirecall-intelligencesentimenthawkeyebirdseye-roipost-prompt
Agent 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

SignalWire Call Intelligence Pipeline

The canonical pattern for building a call-sentiment dashboard, lead-intelligence pipeline, or any system that turns calls into structured data.

The full flow

  1. Inbound call arrives → SWML script executes.
  2. record_call starts background stereo recording.
  3. live_transcribe streams real-time transcript to your webhook.
  4. ai verb runs the agent with a post_prompt that extracts structured JSON.
  5. Call ends → post_prompt_url receives the payload: transcript, sentiment, intent, CRM fields.
  6. Backend parses and writes to DB.
  7. Dashboard queries DB and renders sentiment scores, summaries, extracted data.

Key principle: record_call, live_transcribe, and ai are non-blocking background verbs. They run concurrently.

Production SWML — concurrent record + transcribe + AI

version: 1.0.0
sections:
  main:
    - answer: {}
    - record_call:
        format: mp3
        stereo: true              # required for per-speaker analysis
        direction: both
        max_length: 3600
        status_url: "https://your.api/webhooks/recording"
    - live_transcribe:
        action:
          start:
            webhook: "https://your.api/webhooks/transcript"
            lang: en
            live_events: true
            ai_summary: true
            direction: [remote-caller, local-caller]
            speech_engine: deepgram
            vad_silence_ms: 300
    - ai:
        prompt:
          text: |
            ## Role
            You are a sales call assistant for Acme Corp.
            Greet the caller, identify their needs, guide them to the right product.
          temperature: 0.7
        params:
          end_of_speech_timeout: 700
          asr_diarize: true
          asr_smart_format: true
          save_conversation: true
          hard_stop_time: "30m"
        languages:
          - { name: English, code: en-US, voice: rime.spore }
        global_data:
          campaign: spring-sale
          agent_id: hawkeye-001
        post_prompt:
          text: |
            Analyze the conversation. Return ONLY valid JSON, no prose:
            {
              "sentiment": "positive|neutral|negative",
              "sentiment_score": 0.0,
              "caller_intent": "string",
              "key_topics": ["string"],
              "outcome": "sold|not_sold|follow_up|transferred|other",
              "follow_up_required": true,
              "caller_name": "string or null",
              "caller_email": "string or null",
              "summary": "2-3 sentence summary"
            }
          temperature: 0.2
        post_prompt_url: "https://your.api/webhooks/post-prompt"

Relay SDK equivalent — call.ai() with full control

For programmatic control (start AI mid-call, swap prompts, inject context) use the Relay SDK.

from signalwire.relay import RelayClient

client = RelayClient(
    project="your-project-id",
    token="your-api-token",
    host="your-space.signalwire.com",
    contexts=["default"],
)

@client.on_call
async def handle_call(call):
    await call.answer()

    action = await call.ai(
        prompt={
            "text": "You are a sales call assistant for Acme Corp...",
            "temperature": 0.7,
            "top_p": 0.9,
        },
        post_prompt={
            "text": """Return ONLY JSON:
{
  "sentiment": "positive|neutral|negative",
  "sentiment_score": 0.0,
  "caller_intent": "string",
  "outcome": "sold|not_sold|follow_up|transferred|other",
  "follow_up_required": true,
  "caller_email": "string or null",
  "summary": "2-3 sentence summary"
}"""
        },
        post_prompt_url="https://your.api/webhooks/post-prompt",
        post_prompt_auth_user="webhook_user",
        post_prompt_auth_password="webhook_pass",
        hints=["Acme", "product names", "pricing tiers"],
        global_data={"campaign": "spring-sale"},
        ai_params={
            "asr_diarize": True,
            "asr_smart_format": True,
            "end_of_speech_timeout": 700,
            "energy_level": 52,
            "attention_timeout": 10000,
            "hard_stop_time": "30m",
            "save_conversation": True,
            "debug_webhook_url": "https://your.api/webhooks/debug",
            "debug_webhook_level": 2,
        },
        on_completed=lambda event: print("AI session ended"),
    )

    await action.wait()

client.run()

Pause and resume the AI mid-call

ai_hold + ai_unhold pause the AI agent while keeping the call alive. Use for supervisor barge-in, CRM lookup, or compliance pause.

# Pause the AI; speak a hold message; auto-resume after timeout.
await call.ai_hold(
    prompt="One moment while I check your account.",
    timeout="60",
)

# Do backend work...
account = await fetch_account(caller_id)

# Inject context as a system message before resuming.
await call.ai_message(
    message_text=f"Customer status: {account['status']}. Balance: ${account['balance']}.",
    role="system",
)

await call.ai_unhold(
    prompt="Thanks for holding. I've pulled up your account."
)

Inject context, simulate input, or reset

| Action | Method | Purpose | |---|---|---| | Inject system instruction (invisible to caller) | call.ai_message(role="system", message_text=...) | Mid-call CRM data, supervisor note. | | Update session data | call.ai_update_global_data({...}) | Add caller_name, account_id after lookup. | | Simulate user input | call.ai_simulate_input(...) | Testing or IVR-style forced injection. | | Reset conversation | call.ai_reset_conversation() | Start over after handoff. |

Post-prompt webhook payload

When the call ends and post_prompt_url is set, SignalWire POSTs:

{
  "action": "post_conversation",
  "ai_session_id": "uuid",
  "ai_start_date": 1640000000,
  "ai_end_date": 1640000900,
  "call_id": "uuid",
  "call_start_date": 1640000000,
  "call_answer_date": 1640000003,
  "call_end_date": 1640000900,
  "caller_id_num": "+15551234567",
  "caller_id_name": "Jane Smith",
  "call_log": [
    { "role": "system", "content": "You are a sales call assistant..." },
    { "role": "user", "content": "Hi, I'm interested in pricing..." },
    { "role": "assistant", "content": "Happy to help..." }
  ],
  "post_prompt_data": {
    "raw": "{ \"sentiment\": \"positive\", ... }",
    "parsed": {
      "sentiment": "positive",
      "sentiment_score": 0.85,
      "caller_intent": "pricing inquiry",
      "outcome": "follow_up",
      "follow_up_required": true,
      "summary": "Caller wants pricing for enterprise tier..."
    }
  },
  "swaig_log": [
    { "function": "lookup_account", "args": {...}, "result": {...} }
  ]
}

Read post_prompt_data.parsed first. Fall back to parsing raw only if the AI included prose.

Database schema for a call-intelligence dashboard

Minimum useful columns:

| Column | Source | |---|---| | call_id | call_id | | ai_session_id | ai_session_id | | from_number, to_number | caller_id_num, fetched DID | | started_at, ended_at | call_start_date, call_end_date | | duration_seconds | derived | | recording_url | from status_url webhook | | transcript_url | from live_transcribe summary webhook | | sentiment, sentiment_score | post_prompt_data.parsed.sentiment | | caller_intent, outcome | post_prompt_data.parsed.* | | follow_up_required | bool from parsed | | summary | text from parsed | | raw_payload | jsonb of the full POST body | | swaig_calls | jsonb of swaig_log |

Anti-patterns

  • 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.
  • Not setting asr_diarize: true when you need per-speaker sentiment.
  • Putting your post-prompt JSON schema in plain prose — LLMs follow code-fenced JSON examples much more reliably.
  • Skipping post_prompt_auth_user/password — anyone who guesses the webhook URL can spoof call results.
  • Storing only the parsed JSON — keep the raw payload in jsonb for re-analysis later.

See also