Patter SDK Guide to Building a Restaurant Booking Phone Agent with Dynamic Variables, Guardrails, Latency Dashboards, and Eval Checks
This tutorial explores building a voice-agent workflow using Patter SDK for restaurant booking. It covers defining dynamic caller variables, registering callable tools for availability, bookings, hours, and human transfer, layering output guardrails, simulating speech-to-text and text-to-speech, running scripted call flows, tracking modeled latency and cost in a dashboard, and validating the agent with a deterministic eval harness. The same logic is then mapped to a real deployment using Twilio and OpenAI Realtime.
In this tutorial, we explore the Patter SDK by building a voice-agent workflow that simulates how an AI phone assistant behaves during real conversations. We work with a restaurant booking use case in which we define dynamic caller variables, register callable tools, apply output guardrails, simulate speech-to-text and text-to-speech behavior, and run a complete scripted call flow without requiring live telephony credentials. We also inspect the installed Patter API when available, create a deterministic agent brain, track modeled latency and cost metrics, and validate the system through regression-style evaluations. Finally, we understand how the Patter SDK integrates agent logic, tool use, safety checks, call simulation, and real-world deployment patterns into a single structured voice-agent pipeline.
Setting Up the Patter SDK, Tools, and Restaurant Backend
Copy CodeCopiedUse a different Browser
from future import annotations
import sys, subprocess, importlib, inspect, time, json, re, random, textwrap, os
from dataclasses import dataclass, field
from statistics import median
def _try_install(pkg: str) -> None:
try:
subprocess.run([sys.executable, "-m", "pip", "install", "-q", pkg],
check=False, timeout=600)
except Exception:
pass
def _load_patter():
"""Return the real getpatter module if importable, else None."""
for name in ("patter", "getpatter"):
try:
return importlib.import_module(name)
except Exception:
continue
return None
_PATTER = _load_patter()
if _PATTER is None:
_try_install("getpatter")
_PATTER = _load_patter()
def show_real_api():
"""Print the *actual* installed Patter API so the tutorial adapts to
whatever version Colab pulled (getpatter is young & moves weekly)."""
print("=" * 74)
print("PATTER SDK — installed API")
print("=" * 74)
if _PATTER is None:
print("getpatter not importable in this kernel (that's fine — the\n"
"Colab demo below is self-contained). On a fresh Colab it will\n"
"pip install getpatter and this block prints the live API.\n")
return
print(f"module : {_PATTER.name}")
print(f"version : {getattr(_PATTER, 'version', 'unknown')}")
exported = [n for n in dir(_PATTER) if not n.startswith('_')]
print("exports :", ", ".join(exported[:24]) + (" ..." if len(exported) > 24 else ""))
Patter = getattr(_PATTER, "Patter", None)
if Patter is not None:
for meth in ("init", "agent", "serve", "call", "test", "tool"):
fn = getattr(Patter, meth, None)
if callable(fn):
try:
print(f"Patter.{meth:")
print()
random.seed(7)
CALL_VARIABLES = {
"customer_name": "Priya",
"loyalty_tier": "Gold",
"restaurant": "Acme Bistro",
}
USE_REAL_LLM = False
TOOLS: dict[str, dict] = {}
def tool(description: str):
def deco(fn):
params = [p for p in inspect.signature(fn).parameters]
TOOLS[fn.name] = {"fn": fn, "description": description, "params": params}
return fn
return deco
import copy
_OPEN_TABLES_INIT = {
("today", "evening"): 6, ("today", "late"): 2,
("tomorrow", "lunch"): 8, ("tomorrow", "evening"): 4,
("friday", "evening"): 0, ("friday", "late"): 3,
}
_RES_DB_INIT = {"AC8842": "Table for 2, tomorrow 7:30pm, under Singh — confirmed."}
_HOURS = {"weekday": "11:00–22:00", "weekend": "10:00–23:00"}
_OPEN_TABLES = copy.deepcopy(_OPEN_TABLES_INIT)
_RES_DB = copy.deepcopy(_RES_DB_INIT)
def _reset_backend():
"""Each simulated call starts from a clean backend (a real call hits a
fresh DB connection). Keeps the eval suite deterministic across runs."""
_OPEN_TABLES.clear(); _OPEN_TABLES.update(copy.deepcopy(_OPEN_TABLES_INIT))
_RES_DB.clear(); _RES_DB.update(copy.deepcopy(_RES_DB_INIT))
@tool("Check whether tables are free for a date/time slot and party size.")
def check_availability(date: str, slot: str, party_size: int) -> str:
seats = _OPEN_TABLES.get((date, slot), 0)
if seats >= party_size:
return f"AVAILABLE: {seats} seats open for {date} {slot}."
return f"FULL: only {seats} seats for {date} {slot} (need {party_size})."
@tool("Book a table and return a confirmation code.")
def book_table(name: str, date: str, slot: str, party_size: int) -> str:
seats = _OPEN_TABLES.get((date, slot), 0)
if seats str:
return _HOURS.get(day_type, _HOURS["weekday"])
@tool("Look up an existing reservation by its confirmation code.")
def lookup_reservation(code: str) -> str:
return _RES_DB.get(code.upper(), "NOT_FOUND: no reservation with that code.")
@tool("Hand the call to a human host (Patter auto-injects transfer_call).")
def transfer_to_human(reason: str) -> str:
return f"TRANSFER: routing to a host — reason: {reason}."
We set up the tutorial environment by importing the required libraries, optionally installing the Patter SDK, and inspecting the installed API when it is available. We define the dynamic caller variables, create a small tool registry, and prepare an in-memory restaurant backend for availability, reservations, hours, and transfers. We also register the core tools that allow our simulated phone agent to check tables, book reservations, look up confirmation codes, and route callers to a human.
Adding Output Guardrails and Simulated Speech Layers
Copy CodeCopiedUse a different Browser
class GuardrailBlock(Exception): def init(self, safe_reply: str): self.safe_reply = safe_reply _PII_EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b") _PII_PHONE = re.compile(r"\b(?:\+?\d[\s-]?){9,13}\d\b") _INTERNAL = re.compile(r"\bCUST-\d{4,}\b") _BANNED = re.compile(r"\b(damn|hell|crap)\b", re.I) _OFFTOPIC = re.compile(r"\b(diagnos|prescri|lawsuit|legal advice|medication)\b", re.I) def gr_redact_pii(text: str, ctx: dict) -> str: text = _PII_EMAIL.sub("[email hidden]", text) text = _PII_PHONE.sub("[number hidden]", text) return text def gr_hide_internal_ids(text: str, ctx: dict) -> str: return _INTERNAL.sub("your account", text) def gr_profanity(text: str, ctx: dict) -> str: return _BANNED.sub("—", text) def gr_scope(text: str, ctx: dict) -> str: if _OFFTOPIC.search(text): raise GuardrailBlock("I'm just the booking line, so I can't help with " "that — but I can take a reservation if you like.") return text def gr_concise(text: str, ctx: dict) -> str: parts = re.split(r"(? 2 else text GUARDRAILS = [gr_scope, gr_hide_internal_ids, gr_redact_pii, gr_profanity, gr_concise] def apply_guardrails(text: str, ctx: dict) -> str: for g in GUARDRAILS: text = g(text, ctx) return text _WHISPER_FILLERS = {"you", "thank you", ".", "uh", "um"} def fake_stt(utterance: str) -> tuple[str, float]: """Return (transcript, latency_ms). Drops Whisper-style fillers like Patter's pipeline does.""" t0 = time.perf_counter() tokens = [w for w in utterance.split() if w.lower().strip(".,") not in _WHISPER_FILLERS] transcript = " ".join(tokens) if tokens else utterance lat = 60 + len(utterance) * 1.5 + random.uniform(0, 25) _spin(t0) return transcript, lat def fake_tts(text: str) -> float: """Return synthesis latency_ms (time-to-first-audio-ish).""" return 90 + len(text) * 0.8 + random.uniform(0, 30) def _spin(_t0): pass SYSTEM_PROMPT = ( "You are the friendly phone host for {restaurant}. Caller: {customer_name} " "({loyalty_tier} member). Help them book, check hours, look up a " "reservation, or reach a human. Keep replies to one or two short sentences." ) FIRST_MESSAGE = "Hi {customer_name}, thanks for calling {restaurant}! How can I help?" def _fill(t: str, v: dict) -> str: for k, val in v.items(): t = t.replace("{" + k + "}", str(val)) return t def parse_party(s: str): m = re.search(r"(?:for|party of|table for|of)\s+(\d+)", s) or re.search(r"\b(\d+)\s*(?:people|guests|of us|pax)", s) if m: return int(m.group(1)) for w, n in _NUM.items(): if re.search(rf"\b{w}\b(?:\s+(?:people|guests|of us))?", s): return n return None def parse_date(s: str): for d in ("today", "tonight", "tomorrow", "friday"): if d in s: return "today" if d == "tonight" else d return None def parse_slot(s: str): if re.search(r"\b(lunch|noon|midday)\b", s): return "lunch" if re.search(r"\b(late|11pm|11 pm|after 10)\b", s): return "late" if re.search(r"\b(dinner|evening|tonight|7|8|9|pm)\b", s): return "evening" return None def parse_name(s: str): m = re.search(r"(?:i'?m|this is|name is|under)\s+([A-Z][a-z]+)", s) return m.group(1) if m else None def parse_code(s: str): m = re.search(r"\b(AC\d{3,4})\b", s.upper()) return m.group(1) if m else None def maybe_real_llm(history, user_text, ctx): """Optional: defer freeform small-talk to a real LLM if USE_REAL_LLM + a key. Returns a string or None. Kept tiny and fully optional.""" if not USE_REAL_LLM: return None try: if os.environ.get("OPENAI_API_KEY"): from openai import OpenAI sys_p = _fill(SYSTEM_PROMPT, ctx["vars"]) msgs = [{"role": "system", "content": sys_p}] + history + \ [{"role": "user", "content": user_text}] r = OpenAI().chat.completions.create(model="gpt-4o-mini", messages=msgs, max_tokens=60) return r.choices[0].message.content except Exception: return None return None
We build the output guardrail layer that keeps the phone assistant safe, concise, and appropriate for the booking use case. We redact sensitive information, hide internal customer IDs, clean unwanted language, block off-topic requests, and keep responses short for a better phone experience. We then simulate speech-to-text and text-to-speech behavior, define the system prompt, and add lightweight parsing functions for party size, date, slot, name, and reservation code.
Building the Agent Brain and Call Simulator
Copy CodeCopiedUse a different Browser
def agent_brain(history: list, user_text: str, ctx: dict):
"""Core logic. ctx carries vars + slot state across turns."""
s = user_text.lower()
st = ctx["state"]
if re.search(r"\b(human|agent|representative|manager|person)\b", s):
return ("tool", "transfer_to_human", {"reason": "caller requested a human"})
if st.get("booked") and re.search(r"\b(no|nope|that'?s all|that'?s it|bye|thanks|thank you)\b", s):
return "Thanks for calling — see you soon!"
if re.search(r"\b(weather|stock|joke|medication|lawsuit)\b", s):
return "I'm just the booking line for tonight's tables — want me to grab you one?"
code = parse_code(s)
if code or "look up" in s or "my reservation" in s:
if code:
return ("tool", "lookup_reservation", {"code": code})
st["intent"] = "lookup"
return "Sure — what's your confirmation code? It looks like AC followed by four digits."
if re.search(r"\b(hours|open|close|closing)\b", s):
day_type = "weekend" if re.search(r"\b(sat|sun|weekend)\b", s) else "weekday"
return ("tool", "get_hours", {"day_type": day_type})
if re.search(r"\b(book|reserve|table|reservation)\b", s) or st.get("intent") == "book":
st["intent"] = "book"
if st.get("intent") == "book":
for key, val in (("party_size", parse_party(s)), ("date", parse_date(s)),
("slot", parse_slot(s)), ("name", parse_name(user_text) or st.get("name"))):
if val is not None:
st[key] = val
if st.get("party_size") is None:
return "Happy to book you in — how many people?"
if st.get("date") is None:
return f"Great, a table for {st['party_size']}. Which day — today, tomorrow, or Friday?"
if st.get("slot") is None:
return "And lunch, dinner, or late seating?"
if not st.get("checked"):
st["checked"] = True
return ("tool", "check_availability",
{"date": st["date"], "slot": st["slot"], "party_size": st["party_size"]})
if st.get("name") is None:
return "What name should I put it under?"
if not st.get("booked"):
st["booked"] = True
return ("tool", "book_table",
{"name": st["name"], "date": st["date"],
"slot": st["slot"], "party_size": st["party_size"]})
return "You're all set — anything else?"
if re.search(r"\b(no|nope|that's all|bye|thanks)\b", s):
return "Thanks for calling — see you soon!"
return maybe_real_llm(history, user_text, ctx) or \
"I can book a table, check hours, or look up a reservation — which would you like?"
def fold_tool_result(tool_name: str, raw: str, ctx: dict) -> str:
"""Turn a raw tool result into a natural spoken reply (what the LLM does
with a function-call result on a real Patter call)."""
if tool_name == "check_availability":
if raw.startsw
[truncated for AI cost control]