Bottom Linear Gradient  Lines image

Article

20 min

read

How SignalWire Voice AI Agents Work

Code-governed voice AI

Brian West

Brian West

Director of Support and Head of Developer Experience

In this article

Share

Angular Gradient Image

Build it free.

Create a space and ship your first call flow in minutes.

Subscribe

Tags

Developer Tutorials

Latency & Performance

AI Agents

Voice AI

SignalWire Server SDK · SWML · SWAIG

The agent does the talking. Your code runs the show.

A SignalWire AI agent works like a stage play. SignalWire is the theater, the AI agent is the actor, and your code is the director. This guide explains the whole production from the beginning. Each chapter opens with the idea in plain words, then shows the real documents and code that cross the wire, so you can stop at whatever depth you need.

The whole thing in five sentences
  1. When a call comes in, SignalWire asks your code for a script (SWML) that says who the agent is, how it should behave, and which tools it may use.
  2. The agent talks with the caller, one scene (step) at a time.
  3. When the agent needs a fact or needs something done, SignalWire sends your code a note (a SWAIG request).
  4. Your code writes back a stage direction for the agent, plus actions for SignalWire to carry out.
  5. The agent reads your direction and says it to the caller in its own words, while the actions happen exactly as you wrote them.
In every picture SignalWire Your code What the agent says The caller

Chapter 1 · The cast

Three players, with SignalWire in the middle

In plain words

The caller is the audience. SignalWire is the theater: it owns the phone lines, the microphones and the speakers, and the actor lives there. The actor is the AI agent, who talks to the caller. Your code is the director. It never goes on stage, but it writes the script and makes every decision that matters.

Caller the audience SignalWire the theater phone lines · listening · speaking · turn-taking AI agent the actor talks with the caller Your code the director voice web script + notes your code never talks to the caller

RememberEverything passes through SignalWire. That is where the rules you write are enforced.

Chapter 2 · The script

SWML is the script, and your code writes it

In plain words

Before the show starts, the theater asks the director, “What do I do with this call?” The director hands back a script. The script is a list of instructions read from top to bottom: pick up, record, start the agent with this personality, these scenes and these tools, then hang up. That script is called SWML.

Caller SignalWire Your agent 1 a call arrives 2 “What do I do?” 3 the script (SWML)
SignalWire does not guess what to do with a call. It asks, and it runs whatever SWML comes back.

A tiny script, line by line

Each instruction is called a verb. SignalWire starts at the top and works down.

{
  "version": "1.0.0",
  "sections": {
    "main": [

Every script has a version and a main section. main is a list of verbs, and SignalWire starts at the top.

      { "answer": {} },

Pick up the phone.

      { "record_call": { "format": "wav" } },

Record the call. Leave this verb out and nothing is recorded.

      { "ai": {
          "prompt": {
            "text": "You are a support agent for Acme."
          },

Start the AI agent. The prompt tells the actor who it is and how to behave.

          "languages": [
            { "name": "English",
              "code": "en-US",
              "voice": "elevenlabs.rachel" }
          ],

Which language it speaks and which voice it uses.

          "SWAIG": {
            "defaults": {
              "web_hook_url": "https://you.example/swaig"
            },
            "functions": [
              { "function": "lookup_order",
                "description": "Look up an order by number" }
            ]
          }
      } },

The tools the actor may ask for, and the URL those requests go to. That URL is your own code, which is how the script and the backstage notes connect.

      { "hangup": {} }
    ]
  }
}

End the call when the agent is finished.

This is the JSON SignalWire receives. SWML can also be written as YAML, which some people find easier to read by hand. The full example below adds steps, parameters and more.

SWML is more than AI

The ai verb is one verb among many. A plain phone menu with no AI at all is also SWML, and an AI agent can use the other verbs before, after, or in the middle of a conversation.

Speak and listen
play prompt record Play audio or speech, collect key presses or spoken answers, take a message.
Connect people
connect transfer Ring one number, several at once, or one after another. Hand the call to a different script.
Make decisions
if switch cond goto Branch on what the caller pressed or said. Loop back to a menu.
Reach other systems
request send_sms Call a web API and keep the result. Send a text message.
Run an AI agent
ai Start a governed conversation with a prompt, tools, steps and a voice.

It says what, not how

You list the steps. SignalWire handles the audio, the timing and the phone network.

It runs in order

Verbs run top to bottom. A script can also branch, loop, and call other sections like small subroutines.

The SignalWire Server SDK writes it for you

Your agent is a short Python class. The SignalWire Server SDK turns it into SWML each time SignalWire asks, so you rarely type SWML by hand.

The details

The full script for a real call, and the code that writes it

Meet Dana, who will call about order 8841 throughout this guide. Below is the complete SWML document SignalWire receives when Dana calls. Under it is the SignalWire Server SDK class that produces it and also answers every note in Chapter 6. You write the class. The SDK turns it into SWML fresh for every call.

SWML · what SignalWire receives for Dana’s call
{
  "version": "1.0.0",
  "sections": {
    "main": [
      {
        "answer": {}
      },
      {
        "record_call": {
          "format": "wav",
          "stereo": true
        }
      },
      {
        "ai": {
          "prompt": {
            "text": "You are Ava, a friendly support agent for Acme. Keep replies short and conversational. Only share order details that a tool returned.",
            "contexts": {
              "default": {
                "steps": [
                  {
                    "name": "verify",
                    "text": "Greet the caller. Ask for their order number and ZIP code, then call verify_caller.",
                    "step_criteria": "verify_caller has confirmed the caller.",
                    "functions": [
                      "verify_caller"
                    ],
                    "valid_steps": [
                      "help"
                    ]
                  },
                  {
                    "name": "help",
                    "text": "The caller is ${global_data.caller_name}. Answer questions about their orders. Use lookup_order for any order status. For refunds, offer transfer_to_human.",
                    "step_criteria": "The caller has no more questions.",
                    "functions": [
                      "lookup_order",
                      "transfer_to_human"
                    ],
                    "valid_steps": [
                      "wrap_up"
                    ]
                  },
                  {
                    "name": "wrap_up",
                    "text": "Ask if there is anything else, then say goodbye.",
                    "functions": []
                  }
                ]
              }
            }
          },
          "post_prompt": {
            "text": "Summarize the call as JSON with order_id and outcome."
          },
          "post_prompt_url": "https://user:pass@you.example/post_prompt",
          "languages": [
            {
              "name": "English",
              "code": "en-US",
              "voice": "elevenlabs.rachel",
              "function_fillers": [
                "Let me check on that."
              ]
            }
          ],
          "hints": [
            "Acme",
            "UPS"
          ],
          "global_data": {
            "refund_limit": 50
          },
          "SWAIG": {
            "defaults": {
              "web_hook_url": "https://user:pass@you.example/swaig"
            },
            "functions": [
              {
                "function": "verify_caller",
                "description": "Verify the caller with their order number and ZIP code. Call before sharing any order details.",
                "parameters": {
                  "type": "object",
                  "properties": {
                    "order_id": {
                      "type": "string",
                      "description": "Order number, digits only"
                    },
                    "zip": {
                      "type": "string",
                      "description": "5-digit billing ZIP code"
                    }
                  },
                  "required": [
                    "order_id",
                    "zip"
                  ]
                }
              },
              {
                "function": "lookup_order",
                "description": "Look up an order's shipping status by order number.",
                "parameters": {
                  "type": "object",
                  "properties": {
                    "order_id": {
                      "type": "string",
                      "description": "Order number, digits only"
                    }
                  },
                  "required": [
                    "order_id"
                  ]
                },
                "meta_data_token": "orders",
                "fillers": {
                  "en-US": [
                    "One moment while I pull that up."
                  ]
                }
              },
              {
                "function": "transfer_to_human",
                "description": "Transfer the caller to a billing specialist. Use for refunds or when the caller asks for a person.",
                "parameters": {
                  "type": "object",
                  "properties": {}
                }
              }
            ]
          }
        }
      },
      {
        "hangup": {}
      }
    ]
  }
}
SignalWire Server SDK (Python) · acme_support.py
from signalwire import AgentBase, FunctionResult

ACME_NUMBER = "+15125550100"      # the number Acme texts from
BILLING_NUMBER = "+15125550199"   # where refund requests go

# Stand-in for your order system
ORDERS = {
    "8841": {"name": "Dana", "zip": "78701", "shipped": "Tuesday", "carrier": "UPS",
             "eta": "Thursday", "tracking_url": "https://acme.example/t/1Z999"},
}


class AcmeSupport(AgentBase):
    def __init__(self):
        super().__init__(name="acme-support", route="/",
                         record_call=True, record_format="wav", record_stereo=True)

        # Base prompt, voice, recognition hints, starting state, post-call summary
        self.set_prompt_text(
            "You are Ava, a friendly support agent for Acme. Keep replies short and "
            "conversational. Only share order details that a tool returned."
        )
        self.add_language("English", "en-US", "elevenlabs.rachel",
                          function_fillers=["Let me check on that."])
        self.add_hints(["Acme", "UPS"])
        self.set_global_data({"refund_limit": 50})
        self.set_post_prompt("Summarize the call as JSON with order_id and outcome.")

        # Steps: each one lists exactly which tools exist while it is active
        ctx = self.define_contexts().add_context("default")
        ctx.add_step("verify") \
            .set_text("Greet the caller. Ask for their order number and ZIP code, "
                      "then call verify_caller.") \
            .set_step_criteria("verify_caller has confirmed the caller.") \
            .set_functions(["verify_caller"]) \
            .set_valid_steps(["help"])
        ctx.add_step("help") \
            .set_text("The caller is ${global_data.caller_name}. Answer questions about "
                      "their orders. Use lookup_order for any order status. For refunds, "
                      "offer transfer_to_human.") \
            .set_step_criteria("The caller has no more questions.") \
            .set_functions(["lookup_order", "transfer_to_human"]) \
            .set_valid_steps(["wrap_up"])
        ctx.add_step("wrap_up") \
            .set_text("Ask if there is anything else, then say goodbye.") \
            .set_functions([])

        # Tools: name, description and parameters become SWAIG.functions in the SWML
        self.define_tool(
            name="verify_caller",
            description="Verify the caller with their order number and ZIP code. "
                        "Call before sharing any order details.",
            parameters={
                "type": "object",
                "properties": {
                    "order_id": {"type": "string", "description": "Order number, digits only"},
                    "zip": {"type": "string", "description": "5-digit billing ZIP code"},
                },
                "required": ["order_id", "zip"],
            },
            handler=self.verify_caller,
        )
        self.define_tool(
            name="lookup_order",
            description="Look up an order's shipping status by order number.",
            parameters={
                "type": "object",
                "properties": {
                    "order_id": {"type": "string", "description": "Order number, digits only"},
                },
                "required": ["order_id"],
            },
            handler=self.lookup_order,
            fillers={"en-US": ["One moment while I pull that up."]},
            meta_data_token="orders",
        )
        self.define_tool(
            name="transfer_to_human",
            description="Transfer the caller to a billing specialist. Use for refunds "
                        "or when the caller asks for a person.",
            parameters={"type": "object", "properties": {}},
            handler=self.transfer_to_human,
        )

    # ---- Handlers: each one receives the hop 3 POST and returns the hop 4 reply ----

    def verify_caller(self, args, raw_data):
        order = ORDERS.get(args.get("order_id", "").strip())
        if not order or order["zip"] != args.get("zip", "").strip():
            return FunctionResult(
                "Those details don't match an order. Ask the caller to repeat "
                "the order number and ZIP code."
            )
        return (
            FunctionResult(f"Verified. The caller is {order['name']}. "
                           "Ask how you can help with their order.")
            .update_global_data({"caller_name": order["name"], "verified": True})
            .swml_change_step("help")
        )

    def lookup_order(self, args, raw_data):
        if not raw_data.get("global_data", {}).get("verified"):
            return FunctionResult("The caller is not verified. Verify them first.")
        order_id = args.get("order_id", "").strip()
        order = ORDERS.get(order_id)
        if not order:
            return FunctionResult(
                f"No order {order_id} found. Ask the caller to read the number "
                "again, one digit at a time."
            )
        return (
            FunctionResult(
                f"Order {order_id} shipped {order['shipped']} by {order['carrier']} "
                f"and is due {order['eta']}. Tell the caller it shipped and when it "
                "arrives, and that a tracking link is on its way by text. "
                "Do not read the tracking number aloud."
            )
            .update_global_data({"last_order": order_id})
            .send_sms(to_number=raw_data.get("caller_id_num"),
                      from_number=ACME_NUMBER,
                      body=f"Track order {order_id}: {order['tracking_url']}")
        )

    def transfer_to_human(self, args, raw_data):
        return (
            FunctionResult("Transferring to a billing specialist. Tell the caller "
                           "and say goodbye.", post_process=True)
            .connect(BILLING_NUMBER, final=True)
        )

    # ---- Receives the post_prompt POST when the call ends ----

    def on_summary(self, summary, raw_data=None):
        call_id = raw_data.get("call_id") if raw_data else "unknown"
        print(f"Call {call_id} summary: {summary}")


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

Things to notice: each step lists only the tools it allows, so lookup_order doesn’t exist until the caller is verified. refund_limit sits in global_data, which your webhook receives but the agent never sees. caller_name isn’t known yet. verify_caller saves it, and the agent hears it only because the help step’s text expands ${global_data.caller_name}.

Where each line of the class ends up
In the classSWML it becomes
set_prompt_textai.prompt.text
define_contexts().add_context().add_step()ai.prompt.contexts.default.steps
add_language, add_hintsai.languages, ai.hints
set_global_dataai.global_data
define_toolan entry in ai.SWAIG.functions, routed to the handler
set_post_prompt, on_summaryai.post_prompt and post_prompt_url
FunctionResult(...)the reply: response
.update_global_data, .send_sms, .swml_change_step, .connectentries in action
post_process=Truepost_process: true

The constructor declares the prompt, voice, steps and tools. Each handler receives the hop 3 POST and returns the hop 4 reply. on_summary receives the post-call report. The SDK also serves the /swaig and /post_prompt endpoints, protects them with basic auth, and adds a per-call security token to every tool URL, so the SWML it actually renders carries those URLs and tokens where the SWML above shows placeholders.

Run the class with python acme_support.py and point a SignalWire phone number at its URL. To test a handler without placing a call, run swaig-test acme_support.py --exec lookup_order --order_id 8841 --custom-data '{"global_data": {"verified": true}}'. ORDERS and the phone numbers are stand-ins for your own systems.

Two habits from the walkthrough show up here. lookup_order checks verified in global_data even though the step rules already hide it, because args and steps are not a security boundary. And verify_caller moves the call to help with swml_change_step, so your code decides when verification has passed, not the agent.

RememberThe script is the whole plan for a call in one document. It is written fresh for every call, so one agent can treat each caller differently.

Chapter 3 · Two doors

Inbound and outbound calls enter the same room

In plain words

Someone can call you, or your code can ask SignalWire to call them. Either way, SignalWire fetches the same script from the same place, and the show runs the same way from there.

Inbound A caller dials your number Outbound Your code asks for a call SignalWire Your agent the phone rings REST: “dial this number” fetches the script same show from here
On an outbound call, your code asks SignalWire to place the call. The request names the number to dial and the URL of your script, so the script is fetched the same way as for an inbound call.

RememberOnly the first ring is different.

Chapter 4 · Scenes

One scene on stage at a time, each with its own props

In plain words

A play is split into scenes. In each scene the actor gets only that scene’s directions and only that scene’s props. If a prop isn’t in the scene, the actor can’t use it, no matter what the audience says. On SignalWire, scenes are called steps, and the props are tools.

your code can jump to any scene ON STAGE NOW Greet TOOLS none Verify TOOLS verify_caller Help TOOLS lookup_order transfer_to_human Wrap up TOOLS none a move the agent may ask for a move your code makes
  1. One step at a time. While Help is active, the agent has Help’s instructions and nothing else.
  2. Missing tools are absent, not locked. During Verify there is no order lookup for the agent to find or be talked into using.
  3. The agent moves only along paths you allowed. From Verify it can ask to go to Help. It cannot skip ahead.
  4. Your code can move the show anywhere. In Dana’s agent, verify_caller moves the call to Help itself, so your code decides when verification has passed.

The details

In Dana’s SWML (Chapter 2), each step has text (its instructions), functions (the only tools that exist in it), valid_steps (where the agent may go next) and step_criteria (a plain-language hint about when it is done). In the SDK these are set_text, set_functions, set_valid_steps and set_step_criteria.

Two things catch people out. First, step_criteria steers the agent but is not a hard gate, so anything that must be true is checked in your code. Second, a step with no functions list does not get “no tools.” It keeps the previous step’s tools, so set functions on every step, even if it is an empty list.

RememberThe step rules limit the agent. They never limit your code.

Chapter 5 · The director’s clipboard

Your code keeps the facts and shows the agent only what it needs

In plain words

The director carries a clipboard with private notes about this performance. The actor never reads the clipboard. The director decides which facts to pass on. On SignalWire the clipboard is called global_data, and it lasts for the whole call.

Backstage · global_data
caller_name
Dana
shown
verified
yes
shown
order
#8841, shipped Tuesday
shown
risk_flag
low
backstage
refund_limit
$50
backstage
On stage · what the agent is told
The caller is Dana and is verified. Talk about order #8841 only. It shipped Tuesday.

The agent never learns the risk flag or the refund limit, so it cannot repeat them or be argued out of them.

The details

  • Starts in the script. ai.global_data in SWML, or set_global_data() in the SDK. Dana’s starts with refund_limit: 50.
  • Arrives with every note. Each tool request includes the current global_data, so your code always has it (Chapter 6, hop 3).
  • Changes through actions. A reply can add or change values with set_global_data, or .update_global_data() in the SDK.
  • Reaches the agent only when you say so. Writing ${global_data.caller_name} in a step’s text is how Dana’s name gets to the agent.

RememberThe agent can’t leak or invent what it never saw.

Chapter 6 · Notes passed backstage

SWAIG: how the agent asks your code for help

In plain words

The actor is good with words but doesn’t know where anyone’s order is. When it needs a fact, it asks for one of the tools listed in the script. SignalWire writes that request on a note and passes it backstage to your code. Your code writes a note back. The caller never sees the notes. They just hear a short “one moment” while the notes travel. This note-passing system is called SWAIG, the SignalWire AI Gateway, and each tool is a SWAIG function.

Dana is verified and asks where their order is. One question makes six hops. Your server is contacted exactly once, in hops 3 and 4.

Caller SignalWire AI agent Your webhook 1 “Where’s my order? It’s 8841.” 2 agent picks a tool lookup_order(8841) filler: “One moment…” 3 HTTP POST content_type: text/swaig 4 { response, action[], post_process } 5 run the actions agent reads response and writes its reply in its own words SMS arrives 6 “Good news, Dana. It shipped Tuesday…” your webhook never talks to the caller
Hops 3 and 4 are the only traffic between SignalWire and your server. The sentence in hop 6 is written by the agent in hop 5, after it has read your reply.

The details, hop by hop

  1. 1

    The caller speaks

    Caller → SignalWire

    “Hi, where’s my order? The number is eighty-eight forty-one.”

    SignalWire handles the audio, detects when the caller has finished, and transcribes what they said.

  2. 2

    The agent decides it needs your tool

    inside SignalWire

    The agent can’t know where an order is, so it reaches for a tool. It can only choose from the functions declared in the SWML from Chapter 2 that are active in the current step, here lookup_order and transfer_to_human in the help step. The declaration is all it has to go on: the description tells it when to use the tool, and parameters tells it which arguments to pull from what the caller said.

    While the request goes out, SignalWire plays one of the fillers (or a wait_file) so the caller doesn’t hear silence.

  3. 3

    SignalWire POSTs the tool call to your webhook

    SignalWire → your webhook

    One HTTP POST to the function’s web_hook_url, with basic auth if credentials are set. One POST and one JSON reply. No connection stays open.

    POST https://you.example/swaig · body
    {
      "content_type": "text/swaig",
      "content_disposition": "SWAIG Function",
      "version": "2.0",
      "function": "lookup_order",
      "description": "Look up an order's shipping status by order number.",
      "argument": {
        "parsed": [ { "order_id": "8841" } ],
        "raw": "{\"order_id\":\"8841\"}"
      },
      "argument_desc": { "type": "object", "properties": { "order_id": { "type": "string" } } },
      "call_id": "3f1c…-call",
      "ai_session_id": "9a2e…-session",
      "app_name": "acme-support",
      "conversation_type": "voice",
      "channel_active": true, "channel_offhook": true, "channel_ready": true,
      "caller_id_name": "DANA R", "caller_id_num": "+15125550142",
      "project_id": "…", "space_id": "…",
      "global_data": { "caller_name": "Dana", "verified": true, "refund_limit": 50 },
      "meta_data_token": "orders",
      "meta_data": {},
      // only if swaig_post_conversation is on:
      "call_log": [ … redacted conversation so far … ],
      "raw_call_log": [ … unredacted … ],
      // only if swaig_post_swml_vars is on:
      "SWMLVars": { … }, "SWMLCall": { … }
    }

    The highlighted fields are the ones most handlers use: the arguments the agent pulled from the conversation, and the state your code saved earlier. Treat argument as untrusted input. It came from speech, and SignalWire does not enforce your schema on it. The version value is only an example.

    Reading this POST in the SignalWire Server SDK (Python)

    The SDK receives the POST, checks basic auth and the per-call token, and calls your handler with two arguments. args is argument.parsed[0], already pulled out. raw_data is the entire body above, as a Python dict.

    def lookup_order(self, args, raw_data):
        # The agent's arguments. The SDK pulls these out of argument.parsed[0] for you.
        order_id = args.get("order_id", "").strip()            # "8841"
        raw_args = raw_data["argument"]["raw"]                  # '{"order_id":"8841"}'
    
        # Which call and session this is
        call_id = raw_data.get("call_id")                       # same on every POST for this call
        session = raw_data.get("ai_session_id")
        channel = raw_data.get("conversation_type")             # "voice" or "chat"
        caller = raw_data.get("caller_id_num")                  # "+15125550142"
        call_is_up = raw_data.get("channel_active", False)
    
        # State your code saved earlier with update_global_data()
        state = raw_data.get("global_data", {})
        if not state.get("verified"):
            return FunctionResult("The caller is not verified. Verify them first.")
        refund_limit = state.get("refund_limit")                # 50, never shown to the agent
    
        # State shared only by functions with the same meta_data_token ("orders")
        orders_meta = raw_data.get("meta_data", {})
    
        # Only present when you turn them on in the ai params
        history = raw_data.get("call_log", [])                  # swaig_post_conversation
        swml_vars = raw_data.get("SWMLVars", {})                # swaig_post_swml_vars
    
        ...  # look up the order, then return the hop 4 reply

    Use .get() with a default for everything except function and argument. Fields like caller_id_num, global_data and call_log are optional and may be missing.

    If you prefer typed handlers, the SDK builds the parameter schema from the function signature and docstring. Each argument arrives as a keyword argument, and raw_data is still available when you include it in the signature:

    @AgentBase.tool(name="lookup_order")
    def lookup_order(self, order_id: str, raw_data=None):
        """Look up an order's shipping status by order number.
    
        Args:
            order_id: Order number, digits only.
        """
        state = (raw_data or {}).get("global_data", {})
        ...
  4. 4

    Your webhook replies: direction for the agent, actions for SignalWire

    your webhook → SignalWire

    Your code does the real lookup and returns one JSON object. Every field is optional, so {} is a valid reply.

    JSON on the wire
    {
      "response": "Order 8841 shipped Tuesday by UPS and is due Thursday. Tell the caller it shipped and when it arrives, and that a tracking link is on its way by text. Do not read the tracking number aloud.",
      "action": [
        { "set_global_data": { "last_order": "8841" } },
        { "SWML": { "version": "1.0.0", "sections": { "main": [
            { "send_sms": { "to_number": "+15125550142", "from_number": "+15125550100",
                            "body": "Track order 8841: https://acme.example/t/1Z999" } } ] } } }
      ]
      // no "post_process" key: it is only sent when true
    }
    SignalWire Server SDK (Python)
    return (
        FunctionResult(
            "Order 8841 shipped Tuesday by UPS and is due Thursday. Tell the caller "
            "it shipped and when it arrives, and that a tracking link is on its way "
            "by text. Do not read the tracking number aloud."
        )
        .update_global_data({"last_order": "8841"})       # -> set_global_data action
        .send_sms(to_number="+15125550142",               # -> SWML send_sms action
                  from_number="+15125550100",
                  body="Track order 8841: https://acme.example/t/1Z999")
    )   # post_process defaults to False

    Read the response closely. It holds a fact (shipped Tuesday, due Thursday), an instruction (say it shipped, mention the text) and a restriction (do not read the number). None of it is phrased as the sentence the caller will hear.

  5. 5

    SignalWire runs the actions, and the agent writes its reply

    inside SignalWire

    With post_process: false, the actions run first. last_order is merged into global_data and the SMS goes out. The agent has no say in either.

    Then the agent takes its turn. It reads your response as the tool’s result, together with:

    • the persona and rules in your prompt
    • the instructions for the current step
    • everything the caller has said so far, including their name, language and mood

    From all of that it writes a reply. If your response points it at another tool, it can call that tool first, and hops 2 to 4 repeat before anything is spoken.

  6. 6

    The caller hears the agent’s words

    SignalWire → caller

    “Good news, Dana. Your order shipped on Tuesday and should be with you Thursday. I’ve just texted you a tracking link.”

    Compare this to the response in hop 4. They share facts but almost no words. The gap between them is the agent at work.

RememberThe agent asks. Your code answers the agent. The agent answers the caller.

Chapter 7 · Stage directions, not lines

Your response is a note to the actor, not a line to read out

In plain words

When the director passes a note back, it doesn’t say “read this out loud.” It says something like “It shipped Tuesday. Tell them, and keep it short.” The actor then says it in their own words, in the caller’s language, in the tone of the conversation. That is exactly how response works.

The common assumption

webhook returns response → SignalWire speaks it word for word

This would make your webhook the voice of the agent. It is not.

What actually happens

webhook returns response → the agent reads it as the tool’s result → the agent writes a reply in its own words → SignalWire speaks that reply

SignalWire’s docs say it directly: “The response is written to the AI, not to the caller. It can carry data, and it can carry instructions about what the agent should do next.”

Every reply has two slots

response

A note to the actor

“Order 8841 shipped Tuesday, due Thursday. Tell the caller, and mention the tracking text.”

Goes to the agent, which reads it and decides what to say.

action[ ]

What the theater does

Save a factChange the stepSend a textTransfer the callPut on holdSay exact wordsTake a paymentHang up

Carried out by SignalWire exactly as your code wrote them. The agent has no say.

Proof: one response, different lines

your webhook returns, every time JSON on the wire
{
  "response": "Order 8841 shipped Tuesday, due Thursday. Tell the caller, and mention the tracking text."
}
SignalWire Server SDK (Python)
return FunctionResult(
    "Order 8841 shipped Tuesday, due Thursday. "
    "Tell the caller, and mention the tracking text."
)
the agent says
Call language is es-MX

“Su pedido salió el martes y llega el jueves. Le acabo de enviar el enlace de seguimiento.”

Caller earlier: “I’m in a rush.”

“Shipped Tuesday, arriving Thursday. Tracking link’s in your texts.”

Caller asked two things at once

“It shipped Tuesday and arrives Thursday. Now, about changing the address…”

Illustrative examples. The facts stay fixed because your code supplied them. The wording, language and length come from the agent.

The details: writing a good response

A good response gives the agent facts it can rely on and tells it what to do with them. A plain string is passed to the agent as it is. The object form splits those two jobs. tool_result holds the factual status, and tool_prompt holds the instruction, written as “you” statements.

JSON on the wire
{ "response": {
    "tool_result": "Order 8841: shipped Tuesday via UPS, ETA Thursday. Tracking SMS sent.",
    "tool_prompt": "Tell the caller it shipped and when it arrives. Say a tracking link was texted. Do not read the tracking number."
} }
SignalWire Server SDK (Python)
return FunctionResult().set_tool_response(
    tool_result="Order 8841: shipped Tuesday via UPS, ETA Thursday. Tracking SMS sent.",
    tool_prompt="Tell the caller it shipped and when it arrives. "
                "Say a tracking link was texted. Do not read the tracking number.",
)

Leave tool_prompt out for a silent result that only reports status.

Reads like a script JSON on the wire
{
  "response": "Hello! Thank you so much for calling Acme! Your order has shipped!"
}
SignalWire Server SDK (Python)
return FunctionResult("Hello! Thank you so much for calling Acme! Your order has shipped!")

The agent rephrases it anyway, may greet the caller a second time, and has no arrival date to give if asked.

Reads like direction JSON on the wire
{
  "response": "Shipped Tuesday, ETA Thursday. Confirm both to the caller. They are already greeted."
}
SignalWire Server SDK (Python)
return FunctionResult(
    "Shipped Tuesday, ETA Thursday. Confirm both to the caller. "
    "They are already greeted."
)

Facts plus the next move. The persona in your prompt supplies the warmth.

Raw dump JSON on the wire
{
  "response": "{\"status\":\"S\",\"carrier\":\"UPS\",\"trk\":\"1Z999…\",\"eta\":1760054400,\"wh\":\"AUS-3\"}"
}
SignalWire Server SDK (Python)
order = orders_api.get("8841")
return FunctionResult(json.dumps(order))   # raw record passed straight through

The agent has to guess what the codes mean, may misread the Unix time, and might say the warehouse ID out loud.

Only what the agent should know JSON on the wire
{
  "response": "Shipped by UPS. Arrives Thursday, October 9."
}
SignalWire Server SDK (Python)
order = orders_api.get("8841")
eta = datetime.fromtimestamp(order["eta"]).strftime("%A, %B %-d")
return FunctionResult(f"Shipped by {order['carrier']}. Arrives {eta}.")

Translate codes in your own code. The agent can’t repeat anything it never received.

An error On the wire
no usable response: the agent gets a generic failure
SignalWire Server SDK (Python)
order = orders_api.get(order_id)   # raises: order system is down
return FunctionResult(f"Order {order_id} shipped...")   # never reached

The SignalWire Server SDK catches the exception and the agent gets a generic failure message, never the error text. It has to improvise an apology with no plan.

A recovery plan JSON on the wire
{
  "response": "No order 8841 found. Ask the caller to read the number again, one digit at a time."
}
SignalWire Server SDK (Python)
order = ORDERS.get(order_id)
if not order:
    return FunctionResult(
        f"No order {order_id} found. Ask the caller to read the number "
        "again, one digit at a time."
    )

Failures are directions too. Tell the agent what to do next.

RememberWrite response for the actor, not the audience: what is true, and what to do next.

Chapter 8 · Things the theater does

Actions make things happen, whatever the agent says

In plain words

Some things are too important to leave to the actor: sending a text, moving to another scene, transferring the call, taking a payment. Your code puts those in the action list, and SignalWire does them exactly as written. A clumsy word from the agent can never turn into a wrong action.

Remember things
Save or remove facts on the clipboard.update_global_data · remove_global_data · set_metadata
Change the scene
Jump to any step or context, even ones the agent couldn’t reach itself.swml_change_step · swml_change_context
Move the call
Transfer, hand off to another agent, hold, or hang up.connect · swml_transfer · sip_refer · hold · hangup
Sound and media
Speak exact words, play audio, record.say · play_background_file · record_call
Reach beyond the call
Send a text, update an app the caller is looking at, run any SWML.send_sms · swml_user_event · execute_swml
Take a payment
Collect card details with the agent never seeing them.pay
Tune the agent
Turn tools on or off, adjust timing and recognition hints mid-call.toggle_functions · set_end_of_speech_timeout · add_dynamic_hints

Names shown are SignalWire Server SDK FunctionResult methods. Each one adds an entry to the action list in the JSON reply.

The details: exact words

You can ask for specific wording in tool_prompt (“Read this exact amount: $38.60”), and the agent follows that well. It is still the agent choosing the words. When text must be spoken exactly as written, such as a legal disclosure, use the say action.

JSON on the wire
{ "response": "The recording disclosure was read to the caller. Ask if they agree before continuing.",
  "action": [ { "say": "This call may be recorded for quality and training purposes." } ] }
SignalWire Server SDK (Python)
return (
    FunctionResult("The recording disclosure was read to the caller. "
                   "Ask if they agree before continuing.")
    .say("This call may be recorded for quality and training purposes.")
)

Rule of thumbresponse tells the agent what happened and what to do. action makes things happen, including saying exact words.

The details: timing

By default, actions run as soon as your reply arrives, and the agent speaks afterward. With post_process: true, the agent speaks first and then the actions run. Use it when the caller should hear something before an action that changes the call, such as a transfer.

post_process: false
reply arrivesactions runagent speaks
post_process: true
reply arrivesagent speaksactions run
Example: a reply with a transfer action, post_process: true, and the response “Transferring to billing. Tell the caller and say goodbye.” The caller hears the agent say goodbye, and then the transfer starts. Without post_process, the transfer can start before the agent has said anything.
JSON on the wire
{
  "response": "Transferring to a billing specialist. Tell the caller and say goodbye.",
  "action": [
    {
      "SWML": {
        "version": "1.0.0",
        "sections": {
          "main": [
            {
              "connect": {
                "to": "+15125550199"
              }
            }
          ]
        }
      },
      "transfer": "true"
    }
  ],
  "post_process": true
}
SignalWire Server SDK (Python)
return (
    FunctionResult("Transferring to a billing specialist. Tell the caller "
                   "and say goodbye.", post_process=True)
    .connect("+15125550199", final=True)
)

Chapter 9 · The whole show

Dana’s call, start to finish

In plain words

Here is every piece working together on one phone call.

  1. Caller
    The phone ringsDana calls Acme’s support number.
  2. Your code
    The script is handed overSignalWire asks your code what to do. The SignalWire Server SDK returns Dana’s SWML (Chapter 2).
  3. SignalWire
    Curtain upSignalWire answers, starts recording, and starts the agent in the Verify step. The only tool that exists is verify_caller.
  4. Your code
    VerificationDana gives their order number and ZIP code. The agent calls verify_caller. Your code checks them, saves their name and “verified” on the clipboard, and moves the show to Help.
  5. Your code
    The lookupDana asks where their order is. The agent calls lookup_order. Your code replies with a direction (“shipped Tuesday, due Thursday, mention the text”) and two actions: save last_order and send the tracking text.
  6. SignalWire
    Words and action togetherThe text goes out. The agent reads your direction and says, in its own words, that the order shipped Tuesday and the link is on its way.
  7. Your code
    A decision the agent doesn’t makeDana asks for a refund. The agent calls transfer_to_human. Your code replies with post_process and a transfer, so the agent says goodbye first and then SignalWire transfers the call.
  8. Your code
    After the callSignalWire sends your code a summary of the conversation. In the SDK it arrives in on_summary.

Chapter 10 · Reference

Every request, every field, every term

Every HTTP exchange an AI call can make

WhenRequestYou return
Call startsSignalWire requests your SWML URL, sending call detailsThe SWML document: prompt, steps, SWAIG.functions, webhook URLs
Agent loadsSignature request to each SWAIG.includes URLTool definitions (name, description, parameters, web_hook_url)
Each tool callPOST text/swaig to the function’s web_hook_url (hop 3)response, action, post_process (hop 4)
DataMap toolsNo request to you. SignalWire calls the API itself and builds the reply from your templateNothing at call time. The template is in the SWML
Call endsPOST to post_prompt_urlNothing required. The body holds the summary, the conversation log and every tool call with timing

The tool webhook, field by field

FieldDirectionWhat it is
Request: what the agent asked for
functioninName of the function the agent called
argument.parsed[0]inThe agent’s arguments as an object. The SignalWire Server SDK passes this to your handler as args
argument.rawinThe arguments as the raw string the agent produced
argument_desc, descriptioninYour own schema and description, sent back to you
Request: which call and session
call_id, ai_session_idinStay the same for the whole call and AI session. On chat, call_id is the conversation ID
conversation_typeinvoice or chat. Call-only actions do nothing on chat
channel_active / offhook / readyinWhether the call is up, answered, and able to take actions
caller_id_name / num, project_id, space_id, app_nameinWho is calling, and which project and app this is
Request: state
global_datainCurrent session state. The agent never sees it unless your prompt text expands a value
meta_data, meta_data_tokeninState shared only among functions with the same token
call_log, raw_call_login, opt-inThe conversation so far, redacted and unredacted. Needs swaig_post_conversation
SWMLVars, SWMLCallin, opt-inSWML variables and call state. Needs swaig_post_swml_vars
Reply
responseout, to the agentThe tool result the agent reads before writing its reply. A string, or {tool_result, tool_prompt}. Never spoken directly
actionout, to SignalWireOne action or a list: set_global_data, say, SWML, hangup, hold, toggle_functions, step and context changes, and more
post_processoutRun the actions after the agent speaks instead of before. Default false

Six habits for writing webhooks

  • Write response for the agent. Give facts and the next move. Leave out greetings and filler.
  • Translate before you reply. Turn codes, timestamps and IDs into plain facts in your own code. Don’t leave that to the agent.
  • Return failures as directions. An error reaches the agent as a generic failure. A reply can tell it how to recover.
  • Use actions for anything that must happen. SMS, transfers, state changes and exact wording don’t depend on the agent.
  • Keep private values in global_data. Your webhook receives them on every POST. The agent never sees them unless you expand them.
  • Keep handlers fast. The agent still has to write and speak its reply after yours arrives. Fillers cover the wait.

The playbill

Real nameIn the playPlain English
SignalWireThe theaterRuns the call: phone lines, listening, speaking, turn-taking, and the agent itself.
AI agentThe actorTalks with the caller. Knows only what the script and your notes tell it.
Your codeThe directorWrites the script and answers notes. Usually a class built with the SignalWire Server SDK.
SWMLThe scriptSignalWire Markup Language. A JSON or YAML document that tells SignalWire how to run this call, one verb at a time.
Step / contextA sceneOne part of the conversation, with its own instructions, tools and exits.
SWAIG functionA prop the actor can ask forA tool with a name, description and parameters. Calling it sends a request to your code.
SWAIG requestA note passed backstageThe HTTP POST SignalWire sends your code when the agent calls a tool.
responseA stage directionWhat your code tells the agent: the facts and what to do next. The agent chooses the words.
actionStage machineryThings SignalWire does because your code said so: text, transfer, save, say exact words, hang up.
post_processCue timingWhether actions happen before or after the agent speaks.
global_dataThe director’s clipboardFacts kept for the call. Your code sees all of it. The agent sees only what you expand into its text.
FillersStalling for timeShort phrases like “One moment” played while a note travels.
SignalWire Server SDKThe director’s toolkitWrites the SWML for you, serves the endpoints, checks security, and hands each note to your handler.

In one line each

The agent handles the conversation. Your code handles the truth. SignalWire handles everything in between.

The agent talks and your code decides. The agent can still pick a clumsy word, but it can’t take an action, change a fact, or leave a scene unless your code allows it.

SWML and webhook fields follow the SignalWire SWML ai reference, the SWAIG.functions reference and the AI SWAIG tool webhook reference. SDK code follows the SignalWire Server SDK (Python) reference and source. Dana, Acme, order 8841 and all IDs, numbers and URLs are made-up examples.

Bottom Linear Gradient  Lines image

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.

The Communications Stack for What's Next

APIs built for speed. Infrastructure built for scale. AI built in from day one.