If your call center wants to use AI to automate call intake and handoff without losing the personal touch, a SignalWire Voice Agent can help. In this tutorial, you’ll build a Python-based AI receptionist that greets callers, captures the information you need, qualifies intent, and hands the call off to a human at the right moment. 

The SignalWire Agents SDK makes orchestration across telephony, speech processing, and call control much easier to manage. That means you can build a useful intake flow without stitching together separate services. It’s a practical pattern for automating intake, improving response time, and keeping callers moving without rigid IVR menu trees or long wait times. 

By the end, you’ll have a working example you can extend for patient intake, legal intake, or any other inbound workflow that benefits from fast, consistent routing.

Why use SignalWire for Voice AI

We’re big fans of SignalWire at WebRTC.ventures. Its modular platform stands out for its flexibility and developer-friendly ecosystem, offering fully customizable turnkey solutions with built-in scalability. As a SignalWire Development Partner, we enjoy helping clients leverage SignalWire’s communication platform, whether integrating its voice, video, and messaging APIs into existing applications or building custom solutions from scratch. And with their advancements in AI capabilities, it’s become an even more powerful tool for modern communication workflows.

SignalWire AI Agents stand out because they unify real-time telephony, speech processing, structured prompts, and call control in a single Python SDK, letting developers build intake agents that handle qualification and human handoff without piecing together disparate services. The SignalWire Agent SDK provides production-ready features like Prompt Object Model (POM) for precise prompting, SWAIG tools for actions like data saving and transfers, and seamless integration with top TTS/STT engines such as Rime, ElevenLabs, and OpenAI.

This makes it ideal for call centers needing instant responses with personalization. For teams looking to replace a legacy IVR, SignalWire’s voice AI handles the same inbound call volume without rigid menu trees or pre-recorded prompts. Your AI agent can greet, gather structured info (name, reason, urgency), confirm details, and route seamlessly. Local testing tools like swaig-test ensure reliability before deployment, reducing trial-and-error in voice-specific scenarios and making it one of the most capable Python telephony AI frameworks available.

Prerequisites for building Voice AI Agents with SignalWire

Note: This post focuses on the agent code. For connecting your phone number to the agent, see the SignalWire Mapping Numbers guide. You’ll create a SWML Script resource with an External URL pointing to your agent, then attach your phone number to it.

Installing the SignalWire Agents SDK

Start by installing the SignalWire Agents SDK:

pip install signalwire-agents

Create a file called agent.py and scaffold the agent class:

from signalwire_agents import AgentBase
from signalwire_agents.core.function_result import SwaigFunctionResult

class IntakeAgent(AgentBase):
  def __init__(self):
    super().__init__(name="intake-agent", route="/agent")

    self.add_language("English", "en-US", "rime.spore")
    self.add_hints(["Acme Corp", "intake", "urgency"])

Two things are happening here.

  1. add_language() configures text-to-speech and speech-to-text for the agent. It takes three positional arguments: a display name, a language code, and a voice identifier. The voice format is engine.voice — in this case, rime.spore uses the Rime TTS engine with the “spore” voice. Other engines like ElevenLabs or OpenAI are also available. See the SignalWire Voice & Language Guide for the full list of engines and voices.
  2. add_hints() takes a list of words or phrases that help the speech recognition engine. Domain-specific terms like company names, product names, and jargon are often misrecognized without hints. Here we’re telling the STT engine to listen for “Acme Corp”, “intake”, and “urgency” so it doesn’t transcribe them as something else.

You can verify this configuration without running a server. The SDK ships with a CLI tool called swaig-test:

swaig-test agent.py --dump-swml

In the output, look for the languages and hints arrays inside the ai verb. That’s the SignalWire Markup Language (SWML) document your agent generates. When a call comes in, SignalWire requests this document from your agent and executes it. You never write SWML by hand as the SDK generates it from your Python code.

Writing structured prompts with SignalWire POM

In a voice AI agent, the prompt is everything. There’s no UI, no buttons, and no visual cues. The prompt is the only thing controlling what the AI says and does. A vague prompt produces a vague agent.

The SignalWire SDK offers two prompting approaches: set_prompt_text() for a single raw string, and prompt_add_section() for structured sections with titles and bullet points. The structured approach is called POM (Prompt Object Model), and it’s what we’ll use here. The two approaches cannot be mixed in the same agent.

Our intake agent needs three prompt sections: a role, a task list, and guardrails.

self.prompt_add_section(
      "Role",
      body="You are a professional intake agent for Acme Corp."
    )

    self.prompt_add_section(
      "Tasks",
      body="Follow these steps in order:",
      bullets=[
        "Greet the caller warmly and introduce yourself",
        "Ask for their full name",
        "Ask for the reason they are calling",
        "Determine urgency: low, medium or high",
        "Repeat back all collected info and ask the caller to confirm",
        "Once confirmed, use the save_caller_info tool",
        "Then use the transfer_to_human tool"
      ]
    )

    self.prompt_add_section(
      "Rules",
      body="Follow these guidelines",
      bullets=[
        "Be concise and professional",
        "Always confirm info before saving or transferring",
        "Ask clarifying questions if the caller is unclear",
        "Do NOT transfer until you have: name, reason and urgency",
        "Keep responses short - this is a voice conversation"
      ]
    )

Each call to prompt_add_section() adds a titled section. The body parameter is the section’s main text, and bullets add a list of points underneath. The SDK renders these into structured markdown that the LLM receives as its system prompt.

A few things to notice about how these prompts are written for voice:

  • Action-oriented instructions. “Ask for their full name” works better than “The agent should inquire about the caller’s name.” Direct instructions produce direct behavior.
  • Explicit tool references. “Use the save_caller_info tool” tells the AI exactly which tool to call. Vague instructions like “save the information” leave the AI guessing.
  • Voice-aware guardrails. “Keep responses short — this is a voice conversation” prevents the AI from producing long paragraphs that sound unnatural when spoken aloud.

Finally, add a post-call summary. This runs after the call ends (when the caller hangs up or the transfer completes) and has access to the full conversation history:

self.set_post_prompt(
      "Summarize the call: caller name, reason, urgency, and 
whether the transfer was successful"
    )

The summary is delivered to your on_summary() callback or a configured webhook URL. It’s useful for logging, CRM updates, or compliance records.

Dump the SWML again to see how POM renders:

swaig-test agent.py --dump-swml

Look for the pom field inside prompt. You’ll see your sections rendered as structured data with titles, body text, and bullet arrays. The post_prompt field appears alongside it.

For more on prompt structure and best practices, see the SignalWire Prompts & POM guide.

Add SWAIG tools for caller info and human transfer

The prompt tells the AI what to say. Tools tell it what to do. In SignalWire, tools are called SWAIG functions (SignalWire AI Gateway). They’re Python functions that the AI can invoke mid-conversation when it decides it needs to perform an action.

The AI decides when to call a tool based on two things: the tool’s description and the conversation context. You register tools with define_tool(), which takes four required arguments: name, description, parameters (a JSON Schema), and handler (the Python function to run).

Tool 1: Save caller information

This tool collects the caller’s name, reason for calling, and urgency level. The AI extracts these from the conversation and passes them as arguments:

  self.define_tool(
      name="save_caller_info",
      description="Save the caller's collected information: their name, 
reason for calling, and urgency level",
      handler=self._save_caller_info,
      parameters={
        "type": "object",
        "properties": {
          "name": {"type": "string", "description": "Caller's full name"},
          "reason": {"type": "string", "description": "Reason for calling"},
          "urgency": {"type": "string", "description": "Urgency level: 
low, medium, or high"}
        },
        "required": ["name", "reason", "urgency"]
      }
    )

The parameters field uses standard JSON Schema. Each property has a type and a description that helps the AI understand what to extract. The required array ensures the AI doesn’t call the tool until it has all three values.

The handler receives an args dict with the extracted values and returns a SwaigFunctionResult. This is the text that the AI incorporates into its next response:

def _save_caller_info(self, args, raw_data=None):
    name = args.get("name", "")
    reason = args.get("reason", "")
    urgency = args.get("urgency", "")

    # In production, persist to a database or CRM
    print(f"[SAVED] Name: {name}, Reason: {reason}, Urgency: {urgency}")

    return SwaigFunctionResult(
      f"Saved: {name}, Reason: {reason}, Urgency: {urgency}. "
      "Please confirm with the caller, then transfer."
    )

In production, you’d replace the print() with a database write or CRM API call. The return text guides the AI’s next action. Here, it reminds the AI to confirm with the caller before transferring.

Tool 2: Transfer to a human agent

This tool hands the call off from the AI agent to a human agent at a phone number:

  self.define_tool(
      name="transfer_to_human",
      description="Transfer the caller to a human agent 
after info is collected and confirmed",
      handler=self._transfer_to_human,
      parameters={"type": "object", "properties": {}}
    )

The parameters schema is empty because the AI doesn’t need to extract anything. It just triggers the transfer. The description is specific about when to use it: “after info is collected and confirmed.” This prevents the AI from transferring prematurely.

The handler uses SwaigFunctionResult with two key features:

  def _transfer_to_human(self, args, raw_data=None):
    return (
      SwaigFunctionResult(
        "Thank you for that information. Let me connect you with a team member now",
        post_process=True
      )
      .connect(destination="+15551234567")
    )

post_process=True tells the AI to speak the response text (“Thank you for that information…”) before executing the transfer action. Without it, the transfer would happen silently.

.connect(destination="...") transfers the call to the specified phone number in E.164 format. The call permanently leaves the agent — the caller is connected directly to the human.

Writing good tool descriptions

The description is the most important field in a tool definition. The AI reads it to decide when to call the tool. Be specific:

  • “Save the caller’s collected information: their name, reason for calling, and urgency level.” This tells the AI exactly what data this tool expects
  • “Transfer the caller to a human agent after info is collected and confirmed.” This tells the AI the precondition for using this tool

Vague descriptions like “save data” or “transfer call” lead to unpredictable behavior.

Testing the SignalWire Voice Agent locally

Before running the server, test your tools with swaig-test:

# Verify both tools are registered
swaig-test agent.py --list-tools

# Test the save tool with sample data
swaig-test agent.py --exec save_caller_info \
  --name "Jane Doe" \
  --reason "Billing dispute" \
  --urgency "high"

# Test the transfer tool
swaig-test agent.py --exec transfer_to_human

The save tool should return the confirmation text and print [SAVED] to stdout. The transfer tool should return a result with a connect action containing the destination number.

The SDK also provides an add_mcp_server() in case you want to set up tools from an MCP instead of defining these as functions in your code

Running the SignalWire Agent and connecting to a phone number

Add the agent instance and entry point at the bottom of agent.py:

agent = IntakeAgent()

if __name__ == "__main__":
  agent.run()

The run() method starts a FastAPI/uvicorn server on port 3000.

Start the agent:

python agent.py

The startup output shows the agent’s URL, Basic Auth credentials, and registered tools. In a separate terminal, expose it with ngrok:

ngrok http 3000

Now follow SignalWire’s Mapping Numbers Guide to connect your phone number:

  1. Go to My ResourcesScriptNew SWML Script
  2. Set Handle Calls Using to External URL
  3. Set the Primary Script URL to your ngrok URL with credentials: https://signalwire:YOUR_PASSWORD@abc123.ngrok-free.app/agent
  4. Attach your phone number under the Addresses & Phone Numbers tab

Call your SignalWire number. The AI should greet you, ask for your name, reason, and urgency, confirm the info, then transfer you.

SignalWire Voice Agent final code

Here’s the complete agent! Voice configuration, structured prompts, data collection, and human transfer in under 80 lines of Python:

from signalwire_agents import AgentBase
from signalwire_agents.core.function_result import SwaigFunctionResult

class IntakeAgent(AgentBase):
  def __init__(self):
    super().__init__(name="intake-agent", route="/agent")

    self.add_language("English", "en-US", "rime.spore")
    self.add_hints(["Acme Corp", "intake", "urgency"])

    self.prompt_add_section(
      "Role",
      body="You are a professional intake agent for Acme Corp."
    )
    self.prompt_add_section(
      "Tasks",
      body="Follow these steps in order:",
      bullets=[
        "Greet the caller warmly and introduce yourself",
        "Ask for their full name",
        "Ask for the reason they are calling",
        "Determine urgency: low, medium or high",
        "Repeat back all collected info and ask the caller to confirm",
        "Once confirmed, use the save_caller_info tool",
        "Then use the transfer_to_human tool"
      ]
    )
    self.prompt_add_section(
      "Rules",
      body="Follow these guidelines",
      bullets=[
        "Be concise and professional",
        "Always confirm info before saving or transferring",
        "Ask clarifying questions if the caller is unclear",
        "Do NOT transfer until you have: name, reason and urgency",
        "Keep responses short - this is a voice conversation"
      ]
    )

    self.set_post_prompt(
      "Summarize the call: caller name, reason, urgency, and 
whether the transfer was successful"
    )

    self.define_tool(
      name="save_caller_info",
      description="Save the caller's collected information: their name, 
reason for calling, and urgency level",
      handler=self._save_caller_info,
      parameters={
        "type": "object",
        "properties": {
          "name": {"type": "string", "description": "Caller's full name"},
          "reason": {"type": "string", "description": "Reason for calling"},
          "urgency": {"type": "string", "description": "Urgency level: 
low, medium, or high"}
        },
        "required": ["name", "reason", "urgency"]
      }
    )
    self.define_tool(
      name="transfer_to_human",
      description="Transfer the caller to a human agent after info is 
collected and confirmed",
      handler=self._transfer_to_human,
      parameters={"type": "object", "properties": {}}
    )

  def _save_caller_info(self, args, raw_data=None):
    name = args.get("name", "")
    reason = args.get("reason", "")
    urgency = args.get("urgency", "")
    print(f"[SAVED] Name: {name}, Reason: {reason}, Urgency: {urgency}")
    return SwaigFunctionResult(
      f"Saved: {name}, Reason: {reason}, Urgency: {urgency}. "
      "Please confirm with the caller, then transfer."
    )

  def _transfer_to_human(self, args, raw_data=None):
    return (
      SwaigFunctionResult(
        "Thank you for that information. Let me connect you with a team member now",
        post_process=True
      )
      .connect(destination="+15551234567")  # Replace with your number
    )

agent = IntakeAgent()

if __name__ == "__main__":
  agent.run()

The SDK handles everything else: the HTTP server, SWML document generation, Basic Auth, and the telephony layer.

Watch the finished SignalWire agent in action

Taking your SignalWire agent to production

This guide builds the foundation: a working SignalWire intake agent in Python that handles greeting, structured data collection, and live call transfer. It’s deliberately simple so the architecture is clear.

In production, you’ll want to plan what your agent will do, how to control it, and how to handle failure scenarios, including what actions to automate, how to write collected data to your backend, and how to measure AI agent confidence.

Real-world deployments often go further still: national or global call center routing, multilingual caller support, real-time translation, CRM and ticketing integrations, custom escalation logic, compliance requirements, and connections to existing PBX infrastructure like FreeSWITCH or Asterisk. 

As a SignalWire Development Partner, WebRTC.ventures designs and builds production-grade voice AI systems for teams tackling exactly this kind of complexity. We’d love to hear about what you’re building with SignalWire. 

WebRTC.ventures is a SignalWire Development Partner.

Further Reading:

Recent Blog Posts