Best practices for creating a SignalWire AI agent

View as MarkdownOpen in Claude

Building an agent that holds up in production takes more than a good prompt. This guide collects the practices that matter across the whole design: how work divides between the prompt and your code, how to write for a real-time voice medium, how to help speech recognition, and how to test, observe, and stay compliant once callers arrive.

The examples build one agent, the dispatcher for a taxi company called Bayview Taxi, a practice at a time. The last section assembles it into a script you can run.

Split the work between the prompt and your code

The design decision that matters most is what you don’t put in the prompt.

A language model is good at conversation. It reads tone, keeps up with a caller who changes direction halfway through a sentence, and pulls a pickup address out of “yeah, I’m at Gough and Fell, the blue building on the corner.”

It is unreliable at anything with one correct answer. Arithmetic drifts, a fact written into the prompt goes stale the moment your data changes, and a rule stated in the prompt is a rule the model may or may not honor on turn nine of a difficult call.

So give each side the work it’s suited to. The prompt covers who the agent is, how it speaks, what it’s there to accomplish, and when to reach for its functions. Your code, reached through SWAIG functions (SignalWire AI Gateway, the platform’s tool calling), covers prices, inventory, calculations, policy, and anything else with a right answer.

What belongs in the prompt, what belongs in your code, and how the two meet. The prompt, fixed at call start, carries identity and tone, scope and duties, and the tools the agent can call. Your code, current at call time, carries knowledge and data, retrieval results, and business logic. Between them runs a round trip: the agent calls a tool and the request reaches your backend; your code runs, shapes the response, and the agent answers from it. The answer comes from your code, not the model, so it is the same answer on every call.

Keep business data out of the prompt

It’s tempting to paste the fare table, the service-area map, or the policy manual into the prompt and hope the model honors all of it: prompt and pray. Resist it. The prompt is fixed when the call starts, but your data keeps changing, and a function call reads the current value at the moment the caller asks.

Staleness isn’t the only problem. Data in the prompt is recalled by the model, approximately, while data from a function call is returned by your code, exactly. The model also has no way to tell your pasted policy apart from anything else it has read. Your backend is the system of record.

Move that data behind SWAIG functions: a lookup backed by your webhook or the Server SDK, or a serverless DataMap for straightforward API calls and pattern-matched responses.

Write the prompt for conversation, not logic

With business logic out of the way, the prompt’s job is focused: define the agent’s identity, its conversational duties, and where it defers to your code. Outline those necessities clearly, then stop. Overloading the AI with instructions muddles its behavior rather than tightening it, and since the prompt is part of every conversational turn, a brief prompt is also cheaper to run. See avoiding overprompting for techniques.

Structure the prompt with Markdown: headings and lists keep it organized and legible, narrowing how the AI interprets each section.

1from signalwire import AgentBase
2
3agent = AgentBase(name="bayview-taxi")
4
5agent.prompt_add_section(
6 "Role",
7 "Your name is Ada. You are the dispatcher for Bayview Taxi."
8)
9agent.prompt_add_section(
10 "Personality and duties",
11 "You are calm and efficient. Help callers get a fare quote and book "
12 "a pickup, using the functions available to you."
13)
14agent.prompt_add_section(
15 "Greeting rules",
16 "Greet the caller, introduce yourself as Ada, and ask where they "
17 "are and where they're going."
18)
19
20if __name__ == "__main__":
21 agent.run()

Notice what this prompt doesn’t contain: no fare table, no service-area boundary, no driver roster. That information lives in the dispatch system behind the agent’s functions, so the prompt stays short and the answers stay accurate.

The function it calls for a fare is a handler of your own. In outline:

get_quote, in outline
on get_quote(pickup, destination):
miles = distance_service.lookup(pickup, destination)
if miles is none:
return "that trip is outside the service area"
rate = rate_table.current() # today's rates, not last quarter's
fare = rate.base + miles * rate.per_mile
return "the fare is " + fare + " for " + miles + " miles"

Putting it together, at the end of this page, has that handler as running code.

Some context does belong in the prompt. Put in it what no system of record can answer: who the agent is, the scope of what it handles, the tone it takes, how it should treat a caller who is upset, and the point at which it hands off to a person. Leaving them out is what produces a bland, directionless agent.

Business hours look like a stable fact and aren’t: holidays move them, a weather closure moves them, and a short-staffed Saturday moves them. A prompt written last quarter will state the old hours with complete confidence. If a caller could be told the wrong thing because the world changed, it belongs behind a function.

Per-call context, such as the caller’s name or account tier, can be interpolated into the prompt at request time; place it at the bottom so the stable part stays identical from turn to turn and from call to call. Interpolation is for context the conversation starts from. A live answer, like a price or an order’s status, still comes from a function at the moment the caller asks.

For the full craft of prompt writing, including structure, examples, and iterative refinement, see the prompt engineering guide.

Adjust the model parameters when the defaults aren’t working

The platform’s defaults are set to work across most agents, so building one rarely means touching the parameters on the prompt object. Reach for them when something specific is wrong with the way your agent talks: it wanders off topic, it repeats a line, or it leaves a long pause after the caller stops speaking.

ParameterRangeWhat it does
temperature0.01.5How random the output is. Closer to 0 is less random.
top_p0.01.0Another way to set randomness, again less random closer to 0. It does the same job as temperature, so change one or the other, not both.
confidence0.01.0The threshold for firing a speech-detect event at the end of the caller’s utterance. Lowering it shortens the pause after the caller speaks, at the cost of false positives.
presence_penalty-2.02.0Aversion to staying on topic. Positive values make the model more likely to raise new topics.
frequency_penalty-2.02.0Aversion to repetition. Positive values make the model less likely to repeat the same line verbatim.
max_tokens04096A ceiling on how long a single generated reply can be.

The prompt reference lists the default each one starts at. Change one at a time and listen to a real call between changes.

Design for real-time voice

Voice adds a dimension that text chat doesn’t have: the caller hears every pause. Two controls shape how those pauses feel. Filler phrases give the AI something to say while a function call is running; configure them per-function and agent-wide. Background audio, such as typing or office ambience, gives the caller familiar feedback while processing happens; set it with params.background_file.

Underneath those sit two moments you can also configure. End-pointing (end-of-utterance detection) is how the AI decides the caller has finished speaking, adjustable through confidence in the prompt object and params.end_of_speech_timeout. Turnaround is the interval between that decision and the start of the reply.

Judge both on live calls with your own configuration, measuring the interval the caller experiences: from the moment they stop talking to the moment they hear the reply begin. Consistency matters as much as the average. Your function handlers are part of that interval, so move slow work out of band and respond promptly.

Help speech recognition with hints

Hints boost recognition accuracy for the words that matter in your domain, so “Gough Street” doesn’t arrive as “Goff Street.”

Street names, neighborhoods, and local landmarks are exactly the vocabulary a general speech model handles worst and a dispatcher hears most, which makes them the first thing to introduce:

1from signalwire import AgentBase
2
3agent = AgentBase(name="bayview-taxi")
4
5agent.prompt_add_section(
6 "Role",
7 "Your name is Ada. You are the dispatcher for Bayview Taxi. "
8 "Help callers get a fare quote and book a pickup."
9)
10agent.add_hints(["Gough Street", "Divisadero", "Presidio", "Embarcadero", "Bayview", "SFO"])
11
12if __name__ == "__main__":
13 agent.run()

See hints in the SWML reference, or speech recognition hints in the Server SDK.

Test, monitor, and iterate

Test with real users before deploying. Scripted test calls follow the script; customers won’t, and beta callers surface the gaps.

Once callers arrive, review real calls. Post-call reports capture each conversation and everything the agent did during it; setting them up and reading them is covered in the tool calling guide. Read the transcripts where the agent hesitated or chose the wrong function. That’s where the next fix comes from.

Usage metrics fill in the aggregate picture: token consumption, interaction times, and call outcomes reveal both cost and quality trends. Your business changes, so revisit the prompt and functions periodically, and check the documentation and release notes for new parameters, voices, and features your agent can use as SignalWire’s AI capabilities evolve.

Stay compliant with regulations

In February 2024 the FCC ruled that an AI-generated voice counts as an artificial voice under the Telephone Consumer Protection Act (TCPA). Your agent is subject to the same rules as any other automated call.

The person on the other end has to have consented to it, the call has to identify itself as AI, and a request to stop has to be honored. Which of those rules apply, and how strictly, depends on why you’re calling and where the called party lives.

Each rule has a yes-or-no answer, which makes it work for your code rather than the prompt. Put the AI disclosure in a static_greeting with static_greeting_no_barge, so it plays in full before the agent’s first turn.

Verify consent, check your do-not-call list, and confirm the local hour before you ask SignalWire to dial, because once the call is placed it has already happened. Then let the agent catch an opt-out mid-conversation with a SWAIG function whose handler writes to your do-not-call list before it answers the caller.

Both halves of that, together:

1from signalwire import AgentBase, FunctionResult
2
3class OutboundAgent(AgentBase):
4 def __init__(self):
5 super().__init__(name="outbound-agent")
6
7 self.set_params({
8 "static_greeting": (
9 "Hello, this is an automated assistant calling from Bayview "
10 "Taxi about your pickup. This call uses an artificial voice."
11 ),
12 "static_greeting_no_barge": True,
13 })
14
15 self.prompt_add_section(
16 "Role",
17 "You are calling to tell the caller their driver is on the way."
18 )
19 self.prompt_add_section(
20 "Opt-out",
21 "If the caller asks not to be contacted again, in any wording, "
22 "call opt_out immediately before saying anything else."
23 )
24
25 self.define_tool(
26 name="opt_out",
27 description="Record that the caller does not want to be contacted again",
28 parameters={"type": "object", "properties": {}},
29 handler=self.opt_out
30 )
31
32 def opt_out(self, args, raw_data):
33 number = raw_data.get("caller_id_num")
34 add_to_do_not_call_list(number) # your system of record
35 return FunctionResult(
36 "Confirm the request was recorded, apologize for the interruption, "
37 "and end the call."
38 ).hangup()
39
40if __name__ == "__main__":
41 agent = OutboundAgent()
42 agent.run()

The prompt asks the agent to call opt_out, and the handler is what makes the opt-out real. The number lands on your do-not-call list before the agent says a word about it, so the record exists even if the caller hangs up on the confirmation.

Not legal advice

This is technical guidance, not legal advice. Requirements vary by jurisdiction and by call type, and they change often. Consult qualified counsel before launching an outbound program.

Both compliance guides walk through these controls in detail:

Putting it together

Here is Ada as a complete script, with each practice in place: a static greeting that identifies the call as automated, a lean prompt, fares served by the dispatch system instead of pasted text, a filler phrase and background audio to cover the lookup, hints for local street names, and a post-call report for review.

Ada answers inbound calls, so the consent check and the do-not-call lookup from Stay compliant with regulations aren’t here: those run in your own code before an outbound call is placed, and the opt_out function belongs with them.

1from signalwire import AgentBase, FunctionResult
2
3# Stand-in for your dispatch system: a distance API, a rate table, a driver roster
4RATES = {"base": 4.50, "per_mile": 2.75}
5DISTANCES = {
6 ("123 gough street", "sfo"): 12.4,
7 ("123 gough street", "embarcadero"): 2.1,
8}
9
10class DispatchAgent(AgentBase):
11 def __init__(self):
12 super().__init__(name="bayview-taxi")
13 self.add_language("English", "en-US", "rime.spore:coda")
14
15 self.prompt_add_section(
16 "Role",
17 "Your name is Ada. You are the dispatcher for Bayview Taxi."
18 )
19 self.prompt_add_section(
20 "Personality and duties",
21 "You are calm and efficient. Help callers get a fare quote and book "
22 "a pickup. Price trips with get_quote, and only quote fares and "
23 "pickup times that get_quote returned."
24 )
25 self.prompt_add_section(
26 "Greeting rules",
27 "The greeting has already played, so don't introduce yourself again. "
28 "Open by asking where the caller is and where they're going."
29 )
30 self.set_params({
31 "static_greeting": (
32 "Thanks for calling Bayview Taxi. You're speaking with Ada, an "
33 "automated assistant using an artificial voice."
34 ),
35 "static_greeting_no_barge": True,
36 "background_file": "https://example.com/audio/office-ambience.mp3",
37 })
38
39 self.add_hints(["Gough Street", "Divisadero", "Presidio",
40 "Embarcadero", "Bayview", "SFO"])
41 self.set_post_prompt("Summarize the call and provide the summary in JSON format.")
42
43 self.define_tool(
44 name="get_quote",
45 description="Quote the fare between a pickup address and a destination",
46 parameters={
47 "type": "object",
48 "properties": {
49 "pickup": {
50 "type": "string",
51 "description": "The pickup address, as the caller said it"
52 },
53 "destination": {
54 "type": "string",
55 "description": "Where the caller is going"
56 }
57 },
58 "required": ["pickup", "destination"]
59 },
60 handler=self.get_quote,
61 fillers={"en-US": ["Let me work that out..."]}
62 )
63
64 def get_quote(self, args, raw_data):
65 pickup = args.get("pickup", "").lower().strip()
66 destination = args.get("destination", "").lower().strip()
67
68 miles = DISTANCES.get((pickup, destination))
69 if miles is None:
70 return FunctionResult(
71 "That trip isn't in the service area. Apologize and say we can't take it."
72 )
73
74 fare = RATES["base"] + miles * RATES["per_mile"]
75 return FunctionResult(
76 f"The fare from {pickup} to {destination} is ${fare:.2f} for {miles} miles. "
77 "Offer to send a driver now."
78 )
79
80if __name__ == "__main__":
81 agent = DispatchAgent()
82 agent.run()

When a caller asks what it costs to get to the airport, the platform sends an HTTP POST to your function’s endpoint (the web_hook_url in SWML, or the endpoint the Server SDK serves for you):

1{
2 "function": "get_quote",
3 "argument": {
4 "parsed": [{ "pickup": "123 Gough Street", "destination": "SFO" }]
5 }
6}

Your server prices the trip against the same rate table the meters use and replies:

1{
2 "response": "The fare from 123 Gough Street to SFO is $38.60 for 12.4 miles. Offer to send a driver now."
3}

Ada relays the answer in its own voice and offers to dispatch a car. Every number in that sentence came out of the dispatch system during the call. When the rate changes tomorrow, the agent is already right, and the post-call report shows you every lookup it made.

Next steps