superdialog.unpod.ai/playground. A guest trial lets you build a playbook before creating an account.
→
## Step 1 - Describe what you want
superdialog.unpod.ai/playground, describe an agent, and start talking to it in your browser. A guest trial lets you build before you sign up.
→
## One screen, three surfaces
}` branches and a capped `RetrySpec`
(`retry` ≤ 10). Tool failures are recorded as failed result events - data, never
a crash.
### Protocols: `CompletesLLM`, `StreamsLLM`, `HttpFn`, `PythonToolFn`
```python theme={null}
class CompletesLLM(Protocol): # Director seam
async def complete(self, messages: list[dict[str, str]], **kwargs) -> str: ...
class StreamsLLM(Protocol): # Talker seam
def stream(self, messages: list[dict[str, str]], **kwargs) -> AsyncIterator[str]: ...
HttpFn = Callable[..., Awaitable[tuple[int, Any]]] # (method=, url=, headers=,
# body=, timeout=) -> (status, json)
class PythonToolFn(Protocol): # registered via python_tools={id: fn}
async def __call__(self, args: dict[str, Any], state: ConversationState) -> Any: ...
```
`httpx_http` is the production `HttpFn` backed by httpx. Any
`superdialog.llm.LLMProvider` (the litellm-backed one behind model URIs) adapts
to the Talker/Director protocols in a few lines - see
[Embedding Guides](/superdialog/embedding-guides/overview).
### The expr language
Used by `judge: expr` rules, `ToolSpec.when`, and `Playbook.views`. A safe,
LLM-free, restricted Python expression over state:
```python theme={null}
slots.city == "Pune" # slot value; None when unset
results.hold.ok # tool result: ok / status / data / error
results.search.status == 404
env.BOOKING_API # env lane (not available in views)
pipeline.ok # pipeline-owned checkpoints only
len(results.search.data.slots) > 0
first(pluck(results.search.data.slots, "time"))
```
Helpers (the only callables): `len`, `first`, `last`, `pluck`, `unique`, `min`,
`max`, `any`, `all`. **Forbidden** (raises `ExprError`): arithmetic,
comprehensions, lambdas, dict literals, f-strings, any `_`-prefixed name,
non-whitelisted calls, expressions over 4096 chars. Missing values evaluate to
`None` (falsy), never an exception.
### Migrating flows
```python theme={null}
from superdialog import Flow
from superdialog.playbook import compile_flow, coverage_report
flow = Flow.load("golf_booking.json")
pb = compile_flow(flow) # single-journey "main" Playbook
report = coverage_report(flow, pb) # CoverageReport - the lossless proof
assert not report.unmapped_nodes
assert not report.unmapped_edges
assert not report.unmapped_actions
```
`compile_flow` is lossless by construction:
| Legacy construct | Becomes |
| ---------------------------------- | -------------------------------------------------- |
| Conversational nodes | Checkpoints in journey `"main"` |
| Tool-free computational nodes | Folded into their sources' advance rules |
| Tool-bearing computational chains | A `PipelineSpec` + synthetic checkpoint |
| Hub routers (≥4-exit) | `dispatch` entries merged into inbound checkpoints |
| Silence nodes | `policies.silence` |
| Token-expiry global edge + refresh | `middleware` |
| Other global edges | `interrupts` |
| Webhook/timer system nodes | `handlers` |
| `global_actions` | `tools`, 1:1 |
Deterministic edge conditions (`X.success == true`, `X.status == 404`) compile
to `judge: expr`; everything else stays `judge: llm` with the prose verbatim.
`coverage_report` lists anything that didn't map (any `unmapped_*` entry is a
compiler bug) - run it in CI.
### `EventLog` and `ConversationState`
The event log is the single source of truth; state is a pure fold over it.
```python theme={null}
from superdialog.playbook import ConversationState, EventLog
text = agent.event_log.to_jsonl() # persist (JSONL, one event/line)
agent.load_event_log(EventLog.from_jsonl(text)) # lossless restore
state = ConversationState.fold(agent.event_log, playbook)
```
`EventLog` is append-only with contiguous versions from 1. Events are frozen,
discriminated on `type`: `utterance`, `slot_write`, `advance`, `steering_note`,
`tool_call`, `tool_result`, `env_write`, `scratchpad`, `summary`, `external`,
`degraded`, `session_end`. `ConversationState.fold` derives `checkpoint_id`,
`slots` (value + provisional/confirmed status), `transcript`, `env`,
`tool_results`, `ended`, `outcome`, and helpers `slot_value`, `confirmed`,
`filled`.
### `replay` and the eval bridge
```python theme={null}
from superdialog.playbook import replay, run_session, run_eval, PersonaSpec
report = await replay(log, playbook, director_llm) # pure: never mutates log
report.stable # every decision matched?
metrics = await run_session(agent, persona, user_llm)
report = await run_eval(playbook_factory=make_agent, personas=personas,
user_llm=user_llm, n=1)
report.completion_rate, report.mean_slot_accuracy
```
`replay` re-runs the Director over recorded utterances under a (possibly edited)
playbook and diffs decisions - regression evidence for prompt or model changes.
The eval bridge scores persona self-play from the same logs.
***
## Sessions (v0.2)
Sessions add lifecycle and persistence on top of any `Agent`-protocol brain -
`PlaybookAgent` (default) and the legacy `DialogMachine` alike.
### Agent Protocol
```python Signature theme={null}
class Agent(Protocol):
async def turn(text: str, *, stream: bool = False) -> TurnResult | AsyncIterator[StreamChunk]
def assist(text: str) -> None
@property
def chat_ctx(self) -> ChatContext
def load_chat_ctx(ctx: ChatContext) -> None
```
### `SessionWorker` / `SessionHandle`
```python theme={null}
from superdialog import DialogMachine, SessionWorker, InMemorySessionStore
worker = SessionWorker(
agent_factory=lambda: DialogMachine("booking.yaml", llm="openai/gpt-4.1-mini"),
store=InMemorySessionStore(),
lock_backend=None, # default: AsyncioLockBackend
max_sessions=1000,
)
async with worker.acquire("user-42") as h:
result = await h.turn("hello")
h.assist("Customer sounds upset; be empathetic.")
```
`agent_factory` is called once per new session; `acquire(session_id)` loads or
creates the session, locks it for the block, and persists on exit. Different ids
run in parallel; same id serialises. `SessionHandle` exposes `turn`, `assist`,
`state`.
### Session stores and lock backends
| Store | Ships | Use case |
| ------------------------------------------------------------- | ---------- | ------------------------------------------------ |
| `InMemorySessionStore` | ✅ v0.2 | Single-process; state lives for process lifetime |
| `NullSessionStore` | ✅ v0.2 | Voice calls; no persistence wanted |
| `RedisSessionStore`, `FileSessionStore`, `SQLiteSessionStore` | 🔜 planned | Distributed / durable |
| Backend | Ships | Use case |
| -------------------- | ---------- | --------------------------- |
| `AsyncioLockBackend` | ✅ v0.2 | Single-process |
| `RedisLockBackend` | 🔜 planned | Multi-process / distributed |
`SessionWorker`'s `SessionRecord` persists `chat_ctx` / `flow_state` only. For
durable Playbook-engine resume, persist `agent.event_log.to_jsonl()` and restore
with `load_event_log` - see [Sessions](/superdialog/sessions).
### Other agent brains
```python theme={null}
from superdialog import LLMAgent, LangChainAgent, SessionWorker, InMemorySessionStore
worker = SessionWorker(
agent_factory=lambda: LLMAgent(llm="openai/gpt-4.1-mini", system_prompt="Be helpful."),
store=InMemorySessionStore(),
)
# LangChainAgent(runnable=...) requires: pip install superdialog[langchain]
```
***
## Tools
`PythonTool`, `HttpTool`, and `MCPTool` implement the `Tool` ABC and are passed
through `DialogMachine(tools=[...])` on either engine. On the Playbook engine,
prefer declaring tools in the playbook's process layer (see
[Tools](/superdialog/tools)).
```python theme={null}
from superdialog import PythonTool, HttpTool, MCPTool, Tool
import os
PythonTool.of(lookup_customer) # infer id/name/schema
PythonTool(id="lookup", name="lookup", description="...", fn=lookup_customer)
HttpTool(id="lookup", name="lookup", description="Look up a customer",
url="https://api.company.io/lookup", method="POST",
auth={"type": "bearer", "token": os.environ["KEY"]})
MCPTool(id="search", name="search", description="Search the KB",
server="https://mcp.company.io")
Tool.from_dict({"type": "http", "id": "lookup", "name": "lookup",
"description": "...", "url": "https://api.company.io/lookup"})
```
HTTP `auth` accepts `{"type": "bearer", "token": "..."}` in v0.2 (`basic`,
`api_key`, callable planned).
***
## LLM provider registration
```python theme={null}
import os
from superdialog import DialogMachine, register_llm_provider
register_llm_provider(
name="internal",
base_url="https://llm.company.io/v1",
api_key=os.environ["INTERNAL_KEY"],
api_style="openai",
)
agent = DialogMachine("booking.yaml", llm="custom/internal/llama-3-70b-tuned")
```
Process-global. Once registered, `custom//` works in
`DialogMachine(llm=...)`, `set_llm()`, and `create_dialog_flow(llm=...)`.
***
## Adapters
| Import | Purpose |
| ------------------------------------------------ | --------------------------------------------------- |
| `superdialog.adapters.livekit.DialogMachineLLM` | LiveKit `Agent(llm=...)` plugin (accepts any Agent) |
| `superdialog.adapters.pipecat.make_processor` | Factory for PipeCat `FrameProcessor` |
| `superdialog.adapters.fastapi.FastAPIRouter` | Mountable router: `/turn`, `/stream`, `/reset` |
| `superdialog.adapters.websocket.WebSocketRunner` | Standalone WSS server for Unpod Voice Infra |
See [Embedding Guides](/superdialog/embedding-guides/overview) for complete
integration examples per host.
# Architecture
Source: https://docs.unpod.ai/superdialog/architecture
How SuperDialog works internally - two engines behind one Agent protocol, the Talker/Director runtime, the event-sourced log, tools, sessions, and adapters.
## Two engines, one contract
One Python package. No services, no daemons. Everything in-process.
SuperDialog ships **two conversation engines** behind the same `Agent` protocol
(`turn` / `assist` / `chat_ctx` / `load_chat_ctx`). Hosts, sessions, and
adapters do not know which engine they are driving.
* **Engine B - Playbook (default).** Checkpoint-compound runtime: a Talker and a
Director over an event-sourced log. Internals below.
* **Engine A - DialogMachine (legacy).** Graph-railed state machine, fully
supported; flow JSON runs compiled onto Engine B by default.
`DialogMachine(source, llm, *, engine=...)` is the recommended way in and drives
either engine - the Playbook engine by default, the legacy graph runtime with
`engine="flow"`. What each engine is for:
[What is SuperDialog?](/superdialog/introduction).
## Library shape
```
superdialog/
├─ flow/ # Flow graph: nodes, edges, serialization
├─ machine/ # DialogStateMachine engine (Engine A internals)
├─ dialog_machine.py # Public DialogMachine facade (unified entry point)
├─ playbook/ # Playbook engine (Engine B): models, events,
│ # runtime, talker, director, compiler, replay
├─ agent.py # Agent Protocol + TurnResult
├─ agents/ # LLMAgent, LangChainAgent (non-DM brains)
├─ session/ # Session, SessionHandle, SessionWorker, stores, locks
├─ chat_context.py # ChatContext, ChatMessage (LiveKit-aligned)
├─ llm/ # Model URI resolver and provider adapters
├─ tools/ # Python / HTTP / MCP tool wrappers
├─ cli/ # superdialog generate / chat / optimize / playbook / flow / eval
└─ adapters/ # LiveKit, PipeCat, FastAPI, WebSocket
```
## Engine B - the Playbook runtime
The default engine runs declarative **checkpoint** journeys. Two LLM roles share
one append-only event log:
* A fast **Talker** streams every spoken turn with one LLM call.
* An async **Director** makes one structured call per user utterance to extract
typed slots, judge advance rules, run tools, and write a steering note.
### One turn, in order
1. **User text arrives.** The agent snapshots state (version *N*) for the Talker.
2. **Director starts concurrently** in a cancellation-shielded task: appends the
utterance, then makes **one structured call** that extracts slots, judges the
advance rules, and writes a 1-3 sentence steering note.
3. **Talker streams concurrently** from snapshot *N* - persona, guidance,
steering note, slots, and recent transcript packed into one streaming call;
tokens go straight to the host. At a hard gate it barriers first.
4. **Quiescence.** After the verdict is applied, the runtime hops until nothing
moves: the entered checkpoint's pipeline runs, `judge: expr` rules evaluate
LLM-free, `auto` checkpoints speak and advance, and a terminal checkpoint
ends the session with its outcome.
5. **Join and repair.** The Talker's speech is logged once; `check_repairs`
compares it against later slot writes and nudges a self-correction if the
Talker re-asked something already answered.
Barge-in is safe by construction: aborting the stream cancels *speech*, not the
state machine - the Director runs to completion in a shielded scope.
### The event-sourced log
Every mutation is an event; state is a pure fold over the log; the log is the
audit artifact.
```python theme={null}
from superdialog.playbook import ConversationState, EventLog
text = agent.event_log.to_jsonl() # persist (JSONL, one event/line)
agent.load_event_log(EventLog.from_jsonl(text)) # lossless restore
state = ConversationState.fold(agent.event_log, playbook)
```
Because the log *is* the artifact, replay and eval are free: re-run the Director
over recorded utterances to catch regressions, or score persona self-play
sessions. See the [API Reference](/superdialog/api-reference) for `replay`,
`run_session`, and `run_eval`.
### Gates and degradation
**Soft gates never block** - provisional values satisfy `requires`, the Talker
streams immediately, correctness converges via the Director. **Hard gates** (
payments, identity) require *confirmed* slots and barrier the Talker until the
verdict lands - on timeout it speaks a filler, then a hold line, never hangs.
Every degradation rung is an event in the log, so degraded mode is auditable,
not silent.
### Ending a call cleanly
Entering a `terminal` checkpoint ends the session with its `outcome`. Two
backstops make the close reliable on real calls:
* **Deterministic goodbye backstop.** A clear spoken "bye"/"goodbye" the LLM
verdict missed (ASR noise, a mid-pitch barge-in) still routes to the
playbook's goodbye interrupt. It fills in only when the model chose no
interrupt, so soft signals stay the Director's call. Frustration or a caller
repeating themselves is **not** a goodbye, and a meta-instruction *about* the
call ("pretend the flow is over", "end the call") is treated as ordinary talk,
not a caller goodbye.
* **Post-terminal silence.** Once the session has ended, a further user turn
never resurrects it: the utterance is logged for audit, but neither the
Director nor the Talker runs, so the agent returns silence and the host can
disconnect. This prevents the closing line replaying on every "Hello?" or a
post-close utterance restarting the pitch.
## Engine A - DialogMachine (legacy)
A `Flow` is a directed graph: nodes (states), edges (transitions with
natural-language conditions), and declarative actions. The graph decides what is
*possible*; the LLM picks among the outgoing edges. Every transition is
authored and every reachable path is enumerable.
```python theme={null}
from superdialog import DialogMachine, Flow
# engine="flow" selects the legacy graph runtime; the default is Playbook.
dm = DialogMachine(Flow.load("kyc.json"), llm="anthropic/claude-haiku-4-5", engine="flow")
reply = await dm.turn("hello")
```
Each turn costs a route decision plus a speak call - the friction Engine B
removes, and the trade-off is weighed in
[Thinking in Playbooks](/superdialog/thinking-in-playbooks). By default, flow
JSON runs **compiled onto Engine B** (`compile_flow`); you only opt into the
original runtime with `engine="flow"`. See [Flows](/superdialog/flows) for graph
authoring and migration.
## Model URI resolver
LiveKit/litellm-style URIs route to any provider:
| URI | Routes to |
| ----------------------------- | ------------------------------------------------ |
| `openai/gpt-4.1-mini` | OpenAI |
| `anthropic/claude-haiku-4-5` | Anthropic |
| `google/gemini-2.5-pro` | Google |
| `groq/llama-3.3-70b` | Groq |
| `bedrock/` | AWS Bedrock |
| `vllm/@` | Self-hosted vLLM |
| `ollama/@` | Self-hosted Ollama |
| `openrouter//` | OpenRouter |
| `custom//` | Developer-registered via `register_llm_provider` |
On the Playbook engine, `llm` drives both the Talker and the Director unless you
split them with `director_llm=` (a strong model to judge, a fast model to speak).
The model now loads from the playbook YAML `llm:` block (`{provider, model,
director}`) - see [Playbooks](/superdialog/playbooks#the-full-format); the
persona-level `llm` setting is deprecated and warns.
## Adapter pattern
Adapters live in `superdialog.adapters` and are thin shims. The same agent -
`PlaybookAgent` or legacy `DialogMachine` - passes through all of them.
| Adapter | Use case |
| ---------------------------- | -------------------------------------------------- |
| `DialogMachineLLM` (LiveKit) | Plug into `Agent(llm=...)` (accepts any Agent) |
| `make_processor` (PipeCat) | Factory for `FrameProcessor` in a pipeline |
| `FastAPIRouter` | Mountable router with `/turn`, `/stream`, `/reset` |
| `WebSocketRunner` | Standalone WSS server for Unpod Voice Infra |
## What lives outside this library
SuperDialog ends at text in, text out - on both engines. The following are out
of scope:
* Audio processing
* STT, TTS
* Telephony, SIP, RTP
* Media servers and WebRTC Rooms
* Phone numbers, voice profiles
* Billing
# CLI Reference
Source: https://docs.unpod.ai/superdialog/cli
All superdialog command-line commands - the playbook-default workflow first, the legacy flow-graph commands second.
## Install
The CLI is included when you install SuperDialog:
```bash theme={null}
pip install superdialog
superdialog --help
```
The default commands operate on **playbooks** and run on the Playbook engine.
The `flow` sub-tree (and `--mode flow`) is the legacy graph path.
***
## `superdialog generate`
The default creation path. Bootstrap a validated simple-format **playbook** from
a plain-language prompt.
```bash theme={null}
superdialog generate "Confirm KYC. Ask for Aadhaar last 4. Confirm DOB." \
--output kyc.yaml
```
| Flag | Default | Description |
| ---------- | --------------------- | --------------------------------------------- |
| `--output` | `playbook.yaml` | Output file path |
| `--llm` | `openai/gpt-4.1-mini` | Model URI used to generate |
| `--from` | - | Read the prompt from a file instead of inline |
The output is parsed and compiled before it's written, so anything `generate`
produces is loadable. **When to use:** start every new agent here, then refine
the YAML by hand.
***
## `superdialog chat`
Interactive terminal chat. No infrastructure, no Unpod account, no phone number.
Runs on the **Playbook engine**; defaults to `./playbook.yaml`, then
`./flow.json` - any format is auto-detected.
```bash theme={null}
superdialog chat kyc.yaml
```
```
> Hello, I need to verify my KYC.
Agent: Sure! Could you please provide the last 4 digits of your Aadhaar?
> 1234
Agent: Thank you. Could you also confirm your date of birth?
[checkpoint=collect_dob ended=False]
```
The per-turn status line names the live checkpoint, so you can watch outcomes
advance.
| Flag | Default | Description |
| ----------------- | ----------------------------- | ----------------------------------------------------------------------------------- |
| `--flow` | `playbook.yaml` → `flow.json` | Path to the artifact (any format) |
| `--llm` | `openai/gpt-4.1-mini` | Model URI for the runtime |
| `--mode` | `playbook` | `playbook` (default) or `flow` (legacy graph engine) |
| `--adapter` | `toolcall` | Applies **only in `--mode flow`**: `toolcall` (1 call/turn) or `llm` (2 calls/turn) |
| `--traversal-dir` | - | Save session JSON on completion (graph engine) |
**When to use:** during playbook (or legacy flow) design, prompt tuning, and
eval-dataset collection - before any voice infrastructure is involved.
The CLI reads `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` from your environment.
### The build loop
`superdialog generate` a playbook from a plain-language prompt.
Full end-to-end dialog in the terminal - same logic, same LLM calls, same
tool execution, nothing but Python.
Refine the prose, slots, and advance rules. Repeat.
Only once it behaves correctly, wire it into
[LiveKit](/superdialog/embedding-guides/livekit),
[PipeCat](/superdialog/embedding-guides/pipecat), a
[FastAPI endpoint](/superdialog/embedding-guides/fastapi), or
[Unpod Voice Infra](/superdialog/embedding-guides/unpod-voice).
### Which engine am I on?
The status line tells you:
| Status line | Engine |
| -------------------------------- | ------------------------------------ |
| `[checkpoint= ended=]` | Playbook engine (the default) |
| `[ms]` | Legacy DialogMachine (`--mode flow`) |
A bare `--flow x.json` shows the checkpoint form - flow JSON is compiled onto
the Playbook engine.
### REPL loop in Python
For more control - custom tools, a split Talker/Director, or to inspect the
event log:
```python theme={null}
import asyncio
from superdialog import DialogMachine
agent = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5") # any format
async def main():
while True:
user = input("> ")
if user.strip() in ("quit", "exit"):
break
reply = await agent.turn(user)
print(reply.text)
asyncio.run(main())
```
**Inspect the event log.** Drop to `PlaybookAgent` and
`agent.event_log.to_jsonl()` is the audit artifact - every utterance, slot
write, advance, and tool call, replayable offline:
```python theme={null}
from superdialog.playbook import Playbook, PlaybookAgent, httpx_http
agent = PlaybookAgent(
playbook=Playbook.load("kyc.yaml"),
talker_llm=talker, director_llm=director, http=httpx_http,
)
```
**Legacy graph engine in code.** Construct
`DialogMachine(Flow.load("kyc.json"), llm=..., engine="flow",
traversal_dir="./traversal_history")` and drive the same loop - see
[Traversal history](#traversal-history-graph-engine).
***
## `superdialog optimize`
Reflective prose optimizer: paired persona evals score targeted, prose-only
edits and emit improved YAML **in your source format**.
```bash theme={null}
superdialog optimize --playbook kyc.yaml
```
It generates persona suites, runs paired evals (before/after), makes prose-only
edits to `guidance` / `say`, and writes back improved YAML. **When to use:** to
close the run → eval → improve loop without hand-tuning prompts.
***
## `superdialog playbook`
Migration and direct playbook operations.
```bash theme={null}
superdialog playbook compile kyc.json # compile legacy flow JSON → playbook YAML
superdialog playbook chat --playbook kyc.yaml # REPL against an existing playbook
superdialog playbook run kyc.json # compile a flow and immediately chat
```
**When to use:** migrating an existing flow graph to a playbook, or running a
playbook explicitly.
***
## `superdialog eval`
A subcommand group: the **playbook-vs-vanilla A/B harness** plus the legacy
single-session audit. Full guide: [A/B Evals](/superdialog/evals).
```bash theme={null}
superdialog eval gen-dataset --playbook spa.yaml --n-probes 8 # build the dataset
superdialog eval run --playbook spa.yaml --dataset spa.evalcases.yaml --out ./eval-out
superdialog eval bench --playbook spa.yaml --models openai/gpt-4o-mini --max-turns 20 # one shot
superdialog eval serve --playbook spa.yaml --port 8000 # OpenAI-compatible endpoint
superdialog eval suite --config suites.yaml --tier smoke # CI-able behavioral gate
superdialog eval flow --flow kyc.json --traversal session.json # legacy session audit
```
| Subcommand | Purpose |
| ------------- | ------------------------------------------------------------------------------------------------------------- |
| `gen-dataset` | Build `.evalcases.yaml` (personas + probes) offline |
| `run` | A/B both modes over a dataset, write `report.json` + `report.md` |
| `bench` | One shot: gen dataset (if missing) + A/B every `--models` entry, one report dir each |
| `serve` | Serve the playbook as an OpenAI-compatible endpoint for any benchmark |
| `suite` | Run a suite registry as a CI gate; assert behavioral expectations (`--tier smoke\|full`, `--only`, `--force`) |
| `flow` | Legacy: audit a recorded session (`--traversal`) or run a synthetic eval |
`eval run` takes `--modes`, `--agent-model`, `--director-model`,
`--talker-model`, `--judge-model`, `--user-model`, `--metrics`, and `--repeats` - see the [A/B Evals](/superdialog/evals) guide. (For playbook persona evals
from Python, see `run_eval` in the
[API Reference](/superdialog/api-reference#replay-and-the-eval-bridge).)
***
## `superdialog benchmark`
A separate RAGAS + deterministic harness: replays a dataset's user turns at one
or more models and scores **raw LLM vs with-SuperDialog** against ground truth
in one big table.
```bash theme={null}
superdialog benchmark --data universal --flow kyc.yaml --prompt raw_system.txt
```
| Flag | Default | Description |
| -------------------------- | --------------------------------------- | ---------------------------------------------------------- |
| `--data` | `universal` | Dataset short name or path to a `.jsonl` |
| `--flow` | dataset's `playbook` | Playbook YAML to run |
| `--prompt` | - | Raw-LLM system-prompt `.txt` (needed for the raw baseline) |
| `--models` | `gpt-4o-mini,gpt-4.1-mini,claude-haiku` | Models to score |
| `--sd-only` / `--raw-only` | - | Restrict to one side |
| `--no-ragas` | - | Deterministic metrics only (no judge; fast/free) |
| `--out` | - | Write the report table to this path |
`benchmark` uses the RAGAS 0.2.x line (the `benchmark` extra), while the A/B
`eval` harness uses RAGAS 0.4.3 (the `ragas` extra). They cannot co-install - see [A/B Evals → RAGAS](/superdialog/evals#ragas-is-optional-and-version-pinned).
***
## Legacy: flow graphs
The `flow` sub-tree authors and inspects **flow graphs**. These still work;
`superdialog generate` writes a playbook instead.
```bash theme={null}
# Validate graph structure (unreachable nodes, missing edges, undefined slots)
superdialog flow lint kyc.json
# Render a Mermaid diagram of the graph
superdialog flow draw kyc.json
# Bootstrap a flow.json from a prompt (legacy; equivalent to create_dialog_flow)
superdialog flow generate "Confirm KYC. Ask for Aadhaar last 4." \
--llm openai/gpt-5.1 --output kyc.json
# Run the original graph runtime in the REPL
superdialog chat kyc.json --mode flow
```
By default a flow JSON runs **compiled onto the Playbook engine** -
`--mode flow` opts into the original graph runtime. See
[Flows (legacy)](/superdialog/flows).
***
## Traversal history (graph engine)
Any command running the legacy graph engine supports `--traversal-dir`. When
set, a timestamped JSON file is written per completed session capturing every
node visited, every turn, and all collected slot values:
```bash theme={null}
superdialog chat kyc.json --mode flow --traversal-dir ./traversal_history
```
Use these files to build eval corpora, debug flow paths, and audit
conversations. On the Playbook engine, the equivalent artifact is the
event log (`agent.event_log.to_jsonl()`).
# FastAPI
Source: https://docs.unpod.ai/superdialog/embedding-guides/fastapi
Expose a SuperDialog dialog machine as a REST endpoint for text chatbots, web widgets, and async messaging.
## When to use this
* Text-only chatbot (no voice)
* Support widget (Intercom, Zendesk, custom)
* WhatsApp or SMS webhook
* Any HTTP-based channel
## Single-user (stateless)
For simple cases where one machine handles one conversation at a time:
```python theme={null}
from fastapi import FastAPI
from superdialog import DialogMachine
app = FastAPI()
agent = DialogMachine("kyc.yaml", llm="openai/gpt-4.1-mini") # any format
@app.post("/turn")
async def turn(payload: dict):
reply = await agent.turn(payload["text"])
return {"reply": reply.text}
```
## Multi-user (SessionWorker)
For multi-user or multi-worker deployments, route each conversation through a `SessionWorker`:
```python theme={null}
from contextlib import asynccontextmanager
from fastapi import FastAPI
from superdialog import DialogMachine, SessionWorker, InMemorySessionStore
worker: SessionWorker
@asynccontextmanager
async def lifespan(app: FastAPI):
global worker
worker = SessionWorker(
agent_factory=lambda: DialogMachine("booking.yaml", llm="openai/gpt-4.1-mini"),
store=InMemorySessionStore(), # swap for a distributed SessionStore in production
)
yield
app = FastAPI(lifespan=lifespan)
@app.post("/turn")
async def turn(payload: dict):
async with worker.acquire(payload["session_id"]) as h:
result = await h.turn(payload["text"])
return {"reply": result.text}
```
The `SessionWorker`:
* Creates one agent per active session
* Shares the immutable playbook by reference
* Serialises concurrent requests for the same `session_id`
* Runs concurrent requests for different session IDs fully in parallel
`result.metadata` carries `checkpoint`, `version`, `ended`, and (on terminal
checkpoints) `outcome`.
## Streaming endpoint
On the Playbook engine the stream is **live provider tokens** from the Talker -
not post-hoc chunking:
```python theme={null}
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from superdialog import DialogMachine
app = FastAPI()
agent = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5")
@app.post("/stream")
async def stream(payload: dict):
async def generate():
stream = await agent.turn(payload["text"], stream=True)
async for chunk in stream:
yield chunk.text
return StreamingResponse(generate(), media_type="text/plain")
```
## Using FastAPIRouter
The built-in adapter mounts `/turn`, `/stream`, and `/reset` in one line:
```python theme={null}
from fastapi import FastAPI
from superdialog import DialogMachine
from superdialog.adapters.fastapi import FastAPIRouter
agent = DialogMachine("kyc.yaml", llm="openai/gpt-4.1-mini")
app = FastAPI()
app.include_router(FastAPIRouter(agent), prefix="/dialog")
# Exposes: POST /dialog/turn, POST /dialog/stream, POST /dialog/reset
```
## Request / response shape
```json theme={null}
// POST /turn
{ "text": "Hello, I need to verify my KYC.", "session_id": "user-42" }
// Response
{ "reply": "Sure! Could you please provide the last 4 digits of your Aadhaar?" }
```
## Deploying to production
For production multi-worker FastAPI:
1. Replace `InMemorySessionStore` with a distributed `SessionStore`
(`RedisSessionStore` is planned; implement the `SessionStore` protocol today)
so state survives across workers
2. Set `max_sessions` on `SessionWorker` to cap memory usage
3. Use `NullSessionStore` if your sessions are fully stateless (e.g. webhook-per-message pattern)
The in-process `SessionWorker` works as-is because agents stay cache-resident,
but durable or multi-worker resume on the Playbook engine requires persisting
`agent.event_log.to_jsonl()` yourself and restoring via `load_event_log` -
`SessionWorker`'s `SessionRecord` persists `chat_ctx` / `flow_state` only, which
loses playbook state fidelity. External events (webhooks, timers, silence) go to
`agent.runtime.on_external(...)` from your own endpoints.
# LiveKit
Source: https://docs.unpod.ai/superdialog/embedding-guides/livekit
Plug SuperDialog into a LiveKit agent as the LLM brain.
## How it works
SuperDialog ships a `DialogMachineLLM` plugin (named for the legacy engine, but
it accepts **any** superdialog `Agent`) that wires an agent into a LiveKit
`Agent` via the `llm=` parameter - the same pattern LiveKit's own
`livekit-plugins-langchain` uses.
LiveKit's `AgentSession` drives the conversation (STT → LLM → TTS).
`DialogMachineLLM` sits in the LLM slot and translates between LiveKit's
`ChatContext` and SuperDialog's `turn()` API. On the Playbook engine (the
default), **streaming is real**: the Talker's tokens reach TTS as they are
generated, and a barge-in (the host aborting the stream mid-utterance) interrupts
speech, never the state machine - the Director's decision still lands.
## Install
```bash theme={null}
pip install superdialog livekit-agents
```
## Minimal example
```python theme={null}
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from superdialog import DialogMachine
from superdialog.adapters.livekit import DialogMachineLLM
dm = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5") # any format
async def entrypoint(ctx: JobContext):
agent = Agent(llm=DialogMachineLLM(dm))
await AgentSession().start(agent=agent, room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
```
## With STT and TTS
```python theme={null}
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from livekit.plugins import deepgram, cartesia
from superdialog import DialogMachine
from superdialog.adapters.livekit import DialogMachineLLM
dm = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5")
async def entrypoint(ctx: JobContext):
await ctx.connect()
agent = Agent(
llm=DialogMachineLLM(dm),
stt=deepgram.STT(),
tts=cartesia.TTS(),
)
await AgentSession().start(agent=agent, room=ctx.room)
if __name__ == "__main__":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
```
## Per-call dialog machine
For production, create a fresh agent per call so conversation state is isolated:
```python theme={null}
from superdialog import DialogMachine, PythonTool
async def entrypoint(ctx: JobContext):
await ctx.connect()
# Fresh agent per call
dm = DialogMachine(
"kyc.yaml",
llm="anthropic/claude-haiku-4-5",
tools=[PythonTool.of(lookup_customer)],
)
agent = Agent(llm=DialogMachineLLM(dm))
await AgentSession().start(agent=agent, room=ctx.room)
```
**Advanced / legacy.** Pass a `PlaybookAgent` for explicit Talker/Director LLMs,
or `DialogMachine(Flow.load("kyc.json"), llm="anthropic/claude-opus-4-7",
engine="flow")` for the legacy graph engine - same adapter, same wiring.
Voice-event plumbing (feeding silence timeouts into `agent.runtime.on_external`)
is roadmap; today the adapter covers the text path.
## Mid-call context injection
Push system instructions during a call with `assist`:
```python theme={null}
# After detecting customer sentiment, inject context
dm.assist("The customer sounds frustrated. Prioritise empathy and resolution speed.")
```
## When to use this adapter
* You're already using LiveKit for media routing (rooms, WebRTC, recording)
* You want SuperDialog to manage turn-by-turn dialog logic
* You need a clean separation between media transport (LiveKit) and conversation logic (SuperDialog)
# Embedding Overview
Source: https://docs.unpod.ai/superdialog/embedding-guides/overview
How to plug SuperDialog into any host environment - CLI, LiveKit, PipeCat, FastAPI, Unpod Voice, and more.
## The same pattern everywhere
In every host, three things stay the same:
1. **Construct an entry point** - `DialogMachine(source, llm=...)`. It runs the
Playbook engine by default; `source` accepts full playbooks, simple-format
playbooks, *and* legacy flow JSON (auto-compiled), so you don't pick a format,
you just point it at your artifact.
2. **Route inbound text** to `agent.turn(text)`.
3. **Send the reply text** back to the host's output channel.
Both engines implement the same `superdialog.agent.Agent` protocol
(`turn` / `assist` / `chat_ctx` / `load_chat_ctx`), so every adapter accepts
either one. The host varies; the SuperDialog code is identical.
```python theme={null}
# This object works in every host below
from superdialog import DialogMachine
agent = DialogMachine("booking.yaml", llm="anthropic/claude-haiku-4-5") # any format
```
**Advanced:** drop to `PlaybookAgent` when you need to supply the two LLM seams
directly - a `StreamsLLM` Talker and a `CompletesLLM` Director - or a custom HTTP
executor. Any `superdialog.llm.LLMProvider` (the litellm-backed one behind model
URIs) adapts in a few lines:
```python theme={null}
from superdialog.llm import resolve_llm
class TextLLM:
def __init__(self, provider): self._p = provider
async def complete(self, messages, **kw):
return (await self._p.complete(messages, **kw)).text
async def stream(self, messages, **kw):
async for chunk in self._p.stream(messages, **kw):
if chunk.text:
yield chunk.text
talker = TextLLM(resolve_llm("anthropic/claude-haiku-4-5")) # fast: speaks
director = TextLLM(resolve_llm("anthropic/claude-opus-4-7")) # strong: judges
```
Why two models, and how the Director steers the Talker without stalling it:
[Architecture](/superdialog/architecture).
## Choose your host
Zero infrastructure. Best for testing and prompt tuning.
Voice agent via `Agent(llm=DialogMachineLLM(...))` plugin.
Drop-in `FrameProcessor` for PipeCat pipelines.
REST endpoint for text chatbots and web widgets.
Plug your `DialogMachine` into an Unpod `AgentRunner` session - no extra server needed.
Because SuperDialog is text-only, every dialog is unit-testable.
## Lines of code comparison
| Host | Adapter | Extra LoC |
| ----------------------------- | ------------------------------------------------------- | --------- |
| CLI | None - direct `input()`/`print()` or `superdialog chat` | \~5 |
| LiveKit | `DialogMachineLLM` | \~8 |
| PipeCat | `make_processor` | \~12 |
| FastAPI | `FastAPIRouter` or direct route | \~6 |
| Unpod Voice (SDK) | `unpod.AgentRunner` + `session.dialog_machine` | \~6 |
| Unit test | None - direct calls | \~3 |
| Custom (Slack, Discord, etc.) | None - direct callback | \~3 |
# PipeCat
Source: https://docs.unpod.ai/superdialog/embedding-guides/pipecat
Use SuperDialog as the LLM processor in a PipeCat voice pipeline.
## How it works
SuperDialog ships a `make_processor` factory that builds a PipeCat
`FrameProcessor` wrapping **any** superdialog `Agent`. Because PipeCat's
`FrameProcessor` base class shifts between releases, SuperDialog synthesises the
right subclass against whichever PipeCat version is installed.
## Install
```bash theme={null}
pip install superdialog pipecat-ai
```
## Minimal example
```python theme={null}
from superdialog import DialogMachine
from superdialog.adapters.pipecat import make_processor
agent = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5") # any format
processor = make_processor(agent)
```
**Legacy / advanced:**
`make_processor(DialogMachine(Flow.load("kyc.json"), llm=..., engine="flow"))`
or a hand-built `PlaybookAgent` - same factory, same pipeline position.
## Full pipeline
```python theme={null}
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask
from pipecat.services.deepgram import DeepgramSTTService
from pipecat.services.cartesia import CartesiaTTSService
from superdialog import DialogMachine
from superdialog.adapters.pipecat import make_processor
async def main():
agent = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5")
pipeline = Pipeline([
DeepgramSTTService(api_key="..."), # STT
make_processor(agent), # SuperDialog as the LLM
CartesiaTTSService(api_key="..."), # TTS
])
runner = PipelineRunner()
task = PipelineTask(pipeline)
await runner.run(task)
```
## Per-call processor
For production, create a fresh agent and processor per call:
```python theme={null}
async def handle_call():
agent = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5")
processor = make_processor(agent)
pipeline = Pipeline([stt, processor, tts])
await PipelineRunner().run(PipelineTask(pipeline))
```
## When to use this adapter
* You have an existing PipeCat-based voice stack
* You want SuperDialog to replace hand-written LLM logic between STT and TTS
* Your STT and TTS are already configured in PipeCat
# Testing
Source: https://docs.unpod.ai/superdialog/embedding-guides/testing
Test your conversations as pure functions - no audio, no infrastructure. Scripted LLMs for offline tests, replay and persona evals for regression.
## Why SuperDialog is easy to test
SuperDialog is text in, text out. There is no audio to mock, no WebRTC room to
spin up, no telephony to stub. Every dialog is a Python function that takes a
string and returns a string. This is the killer feature vs. voice-coupled
frameworks where tests need audio fixtures.
## Setup
```bash theme={null}
pip install pytest pytest-asyncio
```
```toml theme={null}
[tool.pytest.ini_options]
asyncio_mode = "auto"
```
Or use `anyio` as specified in the project guidelines.
## Offline tests with scripted LLMs
The Playbook engine separates the **Talker** (`StreamsLLM`) and **Director**
(`CompletesLLM`) seams, so you can run a conversation with **no network** by
constructing `PlaybookAgent` with stub LLMs and asserting on
`agent.runtime.state` - slots, checkpoint, ended/outcome.
```python theme={null}
import pytest
from superdialog.playbook import Playbook, PlaybookAgent, httpx_http
@pytest.mark.asyncio
async def test_kyc_collects_aadhaar():
agent = PlaybookAgent(
playbook=Playbook.load("kyc.yaml"),
talker_llm=stub_talker, # scripted StreamsLLM
director_llm=stub_director, # scripted CompletesLLM
http=httpx_http,
)
reply = await agent.turn("My Aadhaar starts with 1234.")
assert reply.text
assert agent.runtime.state.slot_value("aadhaar_last_4") == "1234"
```
`agent.runtime.state` is the folded `ConversationState`: `slot_value(key)`,
`confirmed(keys)`, `checkpoint_id`, `ended`, `outcome`.
## Live smoke test through the entry point
For an end-to-end check against a real model, use the public entry point with a
cheap, fast model:
```python theme={null}
import pytest
from superdialog import DialogMachine
@pytest.mark.asyncio
async def test_greets_customer():
agent = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5")
reply = await agent.turn("Hello")
assert reply.text # non-empty response
@pytest.mark.asyncio
async def test_kyc_collects_aadhaar_live():
agent = DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5")
await agent.turn("I need to verify my KYC.")
reply = await agent.turn("My Aadhaar ends in 1234.")
state = agent.state # {"checkpoint": ..., "slots": ..., "ended": ...}
assert "1234" in reply.text or state["slots"].get("aadhaar_last_4") == "1234"
```
## Replay - regression without re-running models
Because the event log is the source of truth, you can re-run the Director over a
recorded session under a (possibly edited) playbook and diff its decisions -
LLM-free regression evidence for prompt or model changes:
```python theme={null}
from superdialog.playbook import EventLog, Playbook, replay
log = EventLog.from_jsonl(open("session.jsonl").read())
report = await replay(log, Playbook.load("kyc.yaml"), director_llm)
assert report.stable # every replayed decision matched the recording
```
## Persona evals
Drive scripted personas through a fresh agent per run and score completion, slot
accuracy, and turns-per-checkpoint:
```python theme={null}
from superdialog.playbook import PersonaSpec, run_eval
personas = [PersonaSpec(
name="impatient", traits="gives all details at once",
goal="verify KYC", max_turns=10, opening="Hi",
ground_truth_slots={"aadhaar_last_4": "1234"},
)]
report = await run_eval(
playbook_factory=lambda: make_agent(),
personas=personas, user_llm=user_llm, n=1,
)
assert report.completion_rate == 1.0
assert report.mean_slot_accuracy > 0.9
```
The CLI wraps the same loop: `superdialog optimize --playbook kyc.yaml` runs
paired evals and proposes prose-only improvements.
## Testing tools
```python theme={null}
from superdialog.playbook import Playbook, PlaybookAgent, httpx_http
@pytest.mark.asyncio
async def test_tool_is_called():
calls = []
async def lookup_customer(args, state) -> dict:
"""Look up customer by ID."""
calls.append(args["customer_id"])
return {"name": "Ravi Kumar", "verified": True}
agent = PlaybookAgent(
playbook=Playbook.load("kyc.yaml"),
talker_llm=stub_talker, director_llm=stub_director, http=httpx_http,
python_tools={"lookup_customer": lookup_customer},
)
await agent.turn("My customer ID is CUST-999.")
assert "CUST-999" in calls
```
## Legacy graph engine
Same pattern with `engine="flow"`, asserting on the machine's `state` property
(which returns `{"node_id": ..., "slots": ...}` on the graph engine):
```python theme={null}
import pytest
from superdialog import DialogMachine, Flow, FlowSet
@pytest.mark.asyncio
async def test_kyc_collects_aadhaar_flow():
dm = DialogMachine(Flow.load("kyc.json"), llm="anthropic/claude-haiku-4-5", engine="flow")
await dm.turn("My Aadhaar ends in 1234.")
assert dm.state["slots"].get("aadhaar_last_4") == "1234"
@pytest.mark.asyncio
async def test_switch_to_escalation():
flowset = FlowSet({"main": main_flow, "escalation": escalation_flow})
dm = DialogMachine(flowset, llm="anthropic/claude-haiku-4-5", engine="flow")
dm.switch_flow("escalation")
reply = await dm.turn("I want to speak to a manager.")
assert "escalat" in reply.text.lower() or "manager" in reply.text.lower()
```
Set `traversal_dir` on a graph-engine machine to capture each completed session
as JSON for an eval corpus.
## Tips
* Use a cheap model (`claude-haiku-4-5`) in live tests to keep costs and latency low
* Use scripted Talker/Director LLMs for deterministic, offline assertions
* Keep playbook YAML / flow JSON in version control so tests are reproducible
* Build a corpus from recorded event logs, then use `replay` / `run_eval` / `superdialog eval` for regression
# Unpod (hosted voice)
Source: https://docs.unpod.ai/superdialog/embedding-guides/unpod-voice
Connect a SuperDialog dialog machine to Unpod voice calls via the unpod SDK.
## How it works
The `unpod` SDK connects your `AgentRunner` to Unpod's voice platform over WebSocket. Unpod handles STT, TTS, telephony, numbers, and recording - your code handles dialog logic using a SuperDialog `DialogMachine` or `LLMAgent`.
Your dialog machine runs inside your process, in the same call as the rest of your agent logic. No separate WebSocket server needed.
***
## Step 1 - Build your dialog machine
```python theme={null}
from superdialog import DialogMachine, PythonTool
def lookup_customer(phone: str) -> dict:
"""Look up customer by phone number."""
return crm.get_by_phone(phone)
dialog_machine = DialogMachine(
"kyc.yaml", # any format; Playbook engine by default
llm="anthropic/claude-haiku-4-5",
tools=[PythonTool.of(lookup_customer)],
)
```
See [SuperDialog Quickstart](/superdialog/quickstart) to generate and save a playbook.
***
## Step 2 - Plug the machine into your session
Assign `dialog_machine` to `ctx.session.dialog_machine` inside your `AgentRunner` entrypoint. The SDK auto-wraps it - no adapter import needed.
```python theme={null}
from unpod import AgentRunner, CallContext
from superdialog import DialogMachine, Flow, PythonTool
def lookup_customer(phone: str) -> dict:
"""Look up customer by phone number."""
return crm.get_by_phone(phone)
async def handle_call(ctx: CallContext) -> None:
# Build (or re-use) the agent per call
machine = DialogMachine(
"kyc.yaml",
llm="anthropic/claude-haiku-4-5",
tools=[PythonTool.of(lookup_customer)],
)
ctx.session.dialog_machine = machine # auto-wrapped by SuperDialogAdapter
await ctx.session.run() # blocks until the call ends
AgentRunner(
entrypoint=handle_call,
agent_id="kyc-bot",
).start()
```
***
## Step 3 - Register a Speech Pipe (once)
A Speech Pipe is the voice front-end calls arrive on. Create one, attach a
number, and give it the **same `agent_id`** your `AgentRunner` uses - a mismatch
is the most common first-run failure.
```python theme={null}
pipe = await client.pipes.create(
name="KYC Bot",
voice_profile=profiles[0].profile_id,
agent_id="kyc-bot", # must match AgentRunner(agent_id=...)
recording=True,
)
await client.numbers.attach(number_id=numbers[0].number_id, pipe_id=pipe.pipe_id)
```
Full script, env vars, and voice-profile lookup:
[Provisioning checklist](/speech-stack/setup-checklist).
***
## Complete example
The long-lived runner process. Pipe and number are provisioned once beforehand
* see [Provisioning checklist](/speech-stack/setup-checklist).
```python theme={null}
from superdialog import DialogMachine, PythonTool
from unpod import AgentRunner, CallContext
# --- Tools (playbook built with `superdialog generate`) ---
def lookup_aadhaar(partial: str) -> dict:
"""Look up customer by partial Aadhaar."""
return crm.lookup_by_partial_aadhaar(partial)
# --- Runner (long-lived process) ---
async def handle_call(ctx: CallContext) -> None:
machine = DialogMachine(
"kyc.yaml",
llm="anthropic/claude-haiku-4-5",
tools=[PythonTool.of(lookup_aadhaar)],
)
ctx.session.dialog_machine = machine
await ctx.session.run()
AgentRunner(
entrypoint=handle_call,
agent_id="kyc-bot",
).start()
```
***
## Using pre-call data in the flow
Data passed when triggering an outbound call (or injected by the platform) is available on `ctx.data`:
```python theme={null}
async def handle_call(ctx: CallContext) -> None:
machine = DialogMachine("onboarding.yaml", llm="anthropic/claude-haiku-4-5")
# Inject caller context before the first turn
if customer_name := ctx.data.get("customer_name"):
machine.assist(f"The customer's name is {customer_name}. Address them by name.")
ctx.session.dialog_machine = machine
await ctx.session.run()
```
***
## Mid-call context injection
Inject system instructions at any point during an active call from your own business logic:
```python theme={null}
async def handle_call(ctx: CallContext) -> None:
machine = DialogMachine("support.yaml", llm="openai/gpt-4.1-mini")
@ctx.session.on("user_turn")
async def _(text: str) -> None:
sentiment = await analyze_sentiment(text)
if sentiment == "frustrated":
machine.assist("The customer seems frustrated. Be empathetic and offer escalation.")
ctx.session.dialog_machine = machine
await ctx.session.run()
```
***
## Switching flows mid-call (graph engine)
`switch_flow` is a **graph-engine** feature - construct the machine with a
`FlowSet` and `engine="flow"`:
```python theme={null}
from superdialog import DialogMachine, Flow, FlowSet
async def handle_call(ctx: CallContext) -> None:
flows = FlowSet({"triage": Flow.load("triage.json"), "billing": Flow.load("billing.json")})
machine = DialogMachine(flows, llm="openai/gpt-4.1-mini", engine="flow")
@ctx.session.on("user_turn")
async def _(text: str) -> None:
if "billing" in text.lower():
machine.switch_flow("billing", preserve_memory=True)
ctx.session.dialog_machine = machine
await ctx.session.run()
```
On the **Playbook engine** (the default), model the same behaviour with multiple
`journeys` and `interrupts` inside a single playbook instead of swapping flows.
See [Playbooks](/superdialog/playbooks).
***
## vs. LiveKit / PipeCat adapters
| | Unpod Voice (SDK) | LiveKit adapter | PipeCat adapter |
| ------------------------- | -------------------------------- | ------------------------- | -------------------------- |
| **Who handles STT/TTS** | Unpod | You (via LiveKit plugins) | You (via PipeCat services) |
| **Who handles telephony** | Unpod | You / LiveKit SIP | You / Twilio / etc. |
| **Dialog runs in** | Your AgentRunner process | Your LiveKit agent | Your PipeCat pipeline |
| **Best for** | Fastest path to production voice | Full media layer control | Existing PipeCat pipelines |
***
## Next Steps
Constructor, credentials, lifecycle, and live-call controls -
say(), transfer(), recording.
Author the simple and full playbook formats.
Add HTTP, Python, and MCP tools to your agent.
# A/B Evals
Source: https://docs.unpod.ai/superdialog/evals
A/B-evaluate a playbook on the SuperDialog engine against a vanilla LLM handed the same playbook - scored from the transcript alone, so both are judged by an identical rubric.
## What it answers
`superdialog eval` answers one question: **does running your playbook on the
SuperDialog engine beat handing the same playbook to a raw LLM as a flat system
prompt?**
It drives two modes over one dataset and scores both from the conversation
**transcript only** - never from engine internals - so the playbook and the
vanilla baseline face an identical rubric. Use it to justify adopting the
engine, to catch regressions, or to expose your playbook to any external
benchmark.
This is different from `superdialog optimize` and the persona-eval loop in the
[API Reference](/superdialog/api-reference#replay-and-the-eval-bridge). That
loop asks "is this playbook good enough, and how do I improve its prose?" - the
A/B harness here asks "is the playbook **machinery** earning its keep over a
plain prompt?"
## The two modes
| Mode | What runs |
| ---------- | ------------------------------------------------------------------- |
| `playbook` | The full Director + Talker checkpoint runtime loading your playbook |
| `vanilla` | One raw LLM handed the playbook file as a single flat system prompt |
The runner only sees `str` in / `str` out, so a mode is just a factory that
returns a conversation endpoint. Endpoints ship for in-process playbook /
vanilla, a remote HTTP SuperDialog server, and any OpenAI-compatible model.
## Headline metrics
| Metric | Kind | Reads |
| --------------- | --------------------- | ------------------------------------------------------------------------------ |
| `task_success` | LLM judge (0–1) | full transcript vs the case goal |
| `slot_accuracy` | LLM judge (0–1) | transcript vs `ground_truth_slots` |
| `guardrail` | LLM judge (hard gate) | each guardrail-probe reply |
| `efficiency` | pure code | user turns + assistant latency p50/p95 |
| `token_cost` | pure code | input tokens per assistant turn, with a director/talker split in playbook mode |
`guardrail` is a **hard gate**. If either mode complies with a probe attack,
that case's composite score is zeroed and it counts toward the
`guardrail_violation_rate` - no matter how well it did on the other metrics.
`efficiency` and `token_cost` are pure code - no judge tokens, no added
latency - so every run includes them regardless of `--metrics`. The report
gains a **Latency & tokens** table (p50/p95, input tok/turn, director/talker
split, LLM calls/turn) and a **framework** score: zero unless quality is
perfect (`task_success=1`, `slot_accuracy=1`, guardrail clean), then higher
for lower latency and fewer tokens - the framework's goal as one number.
## Run it
Two phases: build the dataset once (offline, commit it), then A/B-run it.
```bash theme={null}
# 1. Build .evalcases.yaml - personas auto-generated, probes injected
superdialog eval gen-dataset --playbook spa.yaml --n-probes 8
# 2. A/B both modes → report.json (full) + report.md (headline table + drilldown)
superdialog eval run \
--playbook spa.yaml --dataset spa.evalcases.yaml \
--modes vanilla,playbook \
--agent-model openai/gpt-4.1-mini \
--judge-model openai/gpt-4.1-mini \
--metrics task_success,slot_accuracy,guardrail,efficiency \
--out ./eval-out
```
Useful `eval run` flags:
| Flag | Default | Description |
| ------------------------------------- | --------------------- | --------------------------------------- |
| `--modes` | `vanilla,playbook` | Which modes to compare |
| `--agent-model` | `openai/gpt-4.1-mini` | Model both modes answer with |
| `--director-model` / `--talker-model` | agent model | Per-role LLMs for playbook mode |
| `--judge-model` | `openai/gpt-4.1-mini` | LLM that scores the transcripts |
| `--user-model` | agent model | LLM that simulates the persona / caller |
| `--metrics` | all four | Comma-separated headline metrics |
| `--repeats` | `1` | Runs per case (average out variance) |
The dataset format mirrors the RAGAS single-/multi-turn shape: each case carries
a persona, `ground_truth_slots`, and a list of probes. A persona may also define
`afterlife_probes` - utterances sent *after* the session ends, asserted on by the
suite runner's `silent_afterlife` check (above).
Or run both phases in one shot with `eval bench` - it builds the dataset if
missing (`--regen` rebuilds, `--personas` seeds), A/Bs every `--models` entry
into its own report directory, and adds `--max-turns` to override each
persona's turn budget:
```bash theme={null}
superdialog eval bench --playbook spa.yaml \
--models openai/gpt-4o-mini --max-turns 20 --out ./eval-out
```
## Gate a suite in CI
`eval run` and `eval bench` produce scores you eyeball. `superdialog eval suite`
turns a set of benches into a one-command, CI-able **behavioral regression
gate**: each suite pins a playbook, dataset, and models to the *expectations*
that made the run worth doing (this case must fire the goodbye interrupt, that
control must **not**; this case must not answer after it ended).
```bash theme={null}
superdialog eval suite --config suites.yaml --tier smoke # cheap pre-merge signal
superdialog eval suite --config suites.yaml --tier full # run everything
superdialog eval suite --config suites.yaml --only realestate-disconnect --force
```
The registry is YAML - one entry per suite:
```yaml theme={null}
suites:
- name: realestate-disconnect
playbook: examples/playbooks/realestate_site_visit.simple.yaml
dataset: examples/datasets/realestate_disconnect.evalcases.yaml
models: [livekit/google/gemma-4-31b-it]
judge: openai/gpt-4.1-mini
user_model: openai/gpt-4.1-mini
fallback_judge: livekit/openai/gpt-4o-mini # retried on provider quota (429)
fallback_user: livekit/openai/gpt-4o-mini
max_turns: 14
smoke_cases: [explicit-disconnect-mid, objecting-but-staying-pooja]
min_composite: 0.6
expect:
explicit-disconnect-mid: {goodbye: fired, min_task_success: 0.7}
objecting-but-staying-pooja: {goodbye: absent, min_turns: 4}
```
Each `expect` entry asserts one case's behavior from the run's report and log:
| Expectation | Asserts |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `goodbye` | `fired` / `absent` - did the goodbye interrupt fire (matched via `goodbye_signal`, default `interrupt:global_goodbye`) |
| `min_turns` | Conversation length floor - encodes "not ended prematurely" for a control persona who legitimately closes once finished |
| `no_reentry` | Route integrity - no checkpoint is re-entered after the flow moved past it (a restart/regression). Skip on playbooks with `resume: true` interrupts |
| `silent_afterlife` | Post-end **afterlife probes** (utterances sent *after* the session ended) draw **no** reply - an ended session that answers is a zombie. Requires the persona to define `afterlife_probes` |
| `no_repeat_replies` | No assistant reply exactly repeats the previous one (closing/parrot loops) |
| `min_task_success` | Per-case `task_success` floor |
| `min_composite` | Suite-level composite-mean floor |
**Tiers, skip-if-unchanged, and quota fallback.** `--tier smoke` runs only each
suite's `smoke_cases`; `--tier full` runs everything. A content hash of
playbook + dataset + params is stamped in the out dir, so unchanged suites are
skipped unless `--force`. A run that dies on provider quota
(`insufficient_quota` / `429`) is retried once with the suite's
`fallback_judge` / `fallback_user`; **behavioral** checks (goodbye, route,
afterlife, repeats) always gate, but score floors (`task_success`,
`composite`) downgrade to **advisory** under the fallback judge since they were
calibrated against the primary one. The command exits non-zero if any suite
fails or errors.
## Serve it to an external benchmark
Expose the playbook as an OpenAI-compatible endpoint and grade it like any other
model:
```bash theme={null}
superdialog eval serve --playbook spa.yaml --port 8000
# POST /v1/chat/completions → the playbook answers as the "model"
```
## RAGAS is optional (and version-pinned)
The custom LLM judges produce every headline metric with **no RAGAS installed**.
RAGAS metrics are opt-in via the `ragas` extra:
```bash theme={null}
pip install superdialog[ragas]
```
SuperDialog ships **two** RAGAS-based harnesses on incompatible RAGAS lines:
the A/B `ragas` extra (RAGAS 0.4.3) and the separate `benchmark` extra
(RAGAS 0.2.x, used by `superdialog benchmark`). They **cannot co-install** - pick one extra per environment. With `uv`, they are declared conflicting so the
project still resolves; with `pip`, install only one.
## Legacy session audit
The older single-session audit lives under the same command group:
```bash theme={null}
superdialog eval flow --flow kyc.json --traversal session.json
```
See the [CLI Reference](/superdialog/cli#superdialog-eval) for every `eval`
subcommand.
# Flows (legacy)
Source: https://docs.unpod.ai/superdialog/flows
Author flow graphs - the legacy / compliance path. Flows run compiled on the Playbook engine by default; opt into the original graph runtime with engine="flow".
**Flows are the legacy authoring surface.** The default engine is the
[Playbook](/superdialog/playbooks) runtime, and a flow graph **runs compiled
onto it by default** - `Playbook.load` detects flow JSON and converts it
(`compile_flow`). Reach for a hand-authored graph when you need an enumerable,
lintable spec for **compliance** or **strict determinism**. New conversational
agents should start with [Thinking in Playbooks](/superdialog/thinking-in-playbooks).
## What is a Flow?
A `Flow` is a directed graph: nodes (states) connected by edges (transitions), with metadata at each node (prompts, tool references, slot definitions). It's the static definition of what your dialog can do.
By default, `DialogMachine` runs a flow **compiled onto the Playbook engine** -
you pass the flow and get checkpoint semantics for free. To run the **original
graph runtime** (one `turn()` at a time, every transition authored), pass
`engine="flow"`:
```python theme={null}
from superdialog import DialogMachine, Flow
# Default: flow JSON is compiled and run on the Playbook engine
agent = DialogMachine(Flow.load("kyc.json"), llm="anthropic/claude-haiku-4-5")
# Legacy graph runtime (opt-in)
dm = DialogMachine(Flow.load("kyc.json"), llm="anthropic/claude-haiku-4-5", engine="flow")
```
## Building from a prompt
The fastest way to get started. `create_dialog_flow` makes one LLM call and generates the graph for you.
```python theme={null}
import asyncio
from superdialog import create_dialog_flow
flow = asyncio.run(create_dialog_flow(
prompt="Confirm KYC. Ask the customer for the last 4 digits of their Aadhaar. Confirm their date of birth. Thank them on completion.",
llm="openai/gpt-5.1",
))
flow.save("kyc.json")
```
Tips for prompts:
* Describe the goal and each step in plain language
* Mention what data you need to collect (slots)
* Describe any branching logic ("if they decline, escalate to an agent")
## Building by hand
For precise control over graph structure, construct nodes and edges directly:
```python theme={null}
from superdialog.flow import Flow, Node, Edge
flow = Flow(
nodes=[
Node(id="greet", prompt="Greet the customer and ask how you can help."),
Node(id="collect_name", prompt="Ask for the customer's full name."),
Node(id="done", prompt="Thank the customer and confirm the details.", terminal=True),
],
edges=[
Edge(from_node="greet", to_node="collect_name", condition="customer_responded"),
Edge(from_node="collect_name", to_node="done", condition="name_collected"),
],
)
flow.save("manual.json")
```
## Saving and loading
Flows support JSON and YAML formats - commit them to source control alongside your code.
```python theme={null}
from superdialog import Flow
# Save as JSON
flow.save("flows/kyc.json")
# Load - auto-detects format from extension
flow = Flow.load("flows/kyc.json")
flow = Flow.load("flows/kyc.yaml")
# Explicit format loaders
flow = Flow.from_json_file("flows/kyc.json")
flow = Flow.from_yaml_file("flows/kyc.yaml")
# Load from string
flow = Flow.from_json_string(json_str)
flow = Flow.from_yaml_string(yaml_str)
# Load from dict
flow = Flow.from_config(config_dict)
```
### React Flow editor support
Flows exported from the React Flow visual editor (camelCase JSON) are automatically detected and normalized:
```python theme={null}
# Works directly - no manual conversion needed
flow = Flow.load("flows/kyc-react-flow-export.json")
```
## Multiple flows with FlowSet
A `FlowSet` holds several named flows. Use it when a conversation may branch across distinct sub-flows (e.g. main flow → escalation, billing, or FAQ).
`FlowSet` and `switch_flow` are **graph-engine features** - construct the machine
with `engine="flow"`. On the Playbook engine, use multiple `journeys` and advance
rules instead (see [Playbooks](/superdialog/playbooks)).
```python theme={null}
from superdialog import FlowSet, DialogMachine
flowset = FlowSet({
"main": main_flow,
"escalation": escalation_flow,
"billing": billing_flow,
})
dm = DialogMachine(flowset, llm="openai/gpt-4.1-mini", engine="flow")
```
Switch flows at runtime:
```python theme={null}
# Reset state on switch (default)
dm.switch_flow("escalation")
# Keep conversation history
dm.switch_flow("billing", preserve_memory=True)
```
## Validating and inspecting flows
Use the legacy `flow` CLI sub-tree to lint and visualise a flow graph:
```bash theme={null}
# Check graph structure for errors
superdialog flow lint kyc.json
# Render a Mermaid diagram
superdialog flow draw kyc.json
# Re-generate a flow graph from a prompt (legacy; `superdialog generate`
# writes a playbook instead)
superdialog flow generate "Confirm KYC." --llm openai/gpt-5.1 --output kyc.json
# Run the original graph runtime in the REPL
superdialog chat kyc.json --mode flow
```
## Migrating a flow to a playbook
A flow already runs on the Playbook engine by default. To make the conversion
explicit - and to keep authoring in the playbook format going forward - compile
it:
```bash theme={null}
# Compile flow JSON to a playbook YAML you can edit
superdialog playbook compile kyc.json
# Or compile-and-run in one step
superdialog playbook run kyc.json
```
```python theme={null}
from superdialog import Flow
from superdialog.playbook import compile_flow, coverage_report
flow = Flow.load("kyc.json")
pb = compile_flow(flow) # single-journey "main" playbook
report = coverage_report(flow, pb) # lossless proof
assert not report.unmapped_nodes
assert not report.unmapped_edges
assert not report.unmapped_actions
```
`compile_flow` is lossless by construction; `coverage_report` lists anything
that didn't map (any entry is a compiler bug). Run it in CI next to the compiled
artifact. See the [API Reference](/superdialog/api-reference#migrating-flows)
for the full mapping table.
## Flow versioning
Because flows are plain JSON files, they work naturally with git:
```bash theme={null}
git diff flows/kyc.json # see what changed
git log flows/kyc.json # see history
git blame flows/kyc.json # see who changed what
```
Pin a specific flow version by checking out a commit hash - useful for A/B testing different flow designs against the same eval corpus.
# What is SuperDialog?
Source: https://docs.unpod.ai/superdialog/introduction
A conversation framework with two engines behind one Agent protocol - the Playbook engine (default) for fluid conversations, and the legacy DialogMachine graph runtime. Pure text in, pure text out.
## Overview
**`pip install superdialog` - no account, no API key.** SuperDialog is an
open-source Python framework that runs in your own process. Unpod's hosted
voice is one place to run it, alongside LiveKit, Pipecat, and FastAPI.
It is the **brain** layer for conversational systems: it takes a prompt or an
authored artifact and turns it into a running conversation runtime - managing
turn-by-turn logic, tool calls, outcome tracking, and conversation memory.
It ships **two engines behind one `Agent` protocol**, and the **Playbook
engine is the default everywhere**:
* **Playbook engine (default)** - checkpoints gate *outcomes*, not utterances.
A fast Talker streams every spoken turn while an async Director extracts
data, judges progress, and runs tools over an event-sourced log. This is
where new investment goes.
* **DialogMachine (supported legacy)** - the graph-railed state machine: nodes,
edges, and criteria, where every transition is authored. Still fully
supported, opt-in via `engine="flow"`. Existing flow graphs run **compiled
on the Playbook engine by default**, so nothing breaks.
Turn ordering, the event-sourced log, gates, and degradation are covered in
[Architecture](/superdialog/architecture).
It is intentionally narrow in scope. Audio, STT, TTS, telephony, and media
servers are all out of scope - those belong to voice infrastructure like
LiveKit, PipeCat, or the Unpod Voice Platform. SuperDialog ends at text in,
text out - on both engines.
Read the mental-model guide before diving into the quickstart.
Browse the source, issues, and releases at `unpod-ai/superdialog`.
**Coming from the Speech Stack?** Assign your SuperDialog agent to
`ctx.session.dialog_machine` and the SDK wraps it for you - see
[Run a SuperDialog agent](/speech-stack/level-up-superdialog).
## Why SuperDialog exists
### The brain has natural reuse beyond voice
A conversation brain that runs a customer-onboarding journey works the same
whether the user is on a phone, a WhatsApp thread, an Intercom widget, or a CLI
test harness. Coupling it to telephony forecloses every non-voice use case.
### The dependency direction matters
Voice infrastructure should depend on SuperDialog (as one brain option), not the
other way around. A modular architecture keeps the framework portable and the
platform composable.
## Who it's for
| Audience | Why they care |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Voice developer using LiveKit / PipeCat** | Drop SuperDialog in as the brain; `PlaybookAgent` gives real token streaming through the same adapters |
| **Chatbot developer (text-only)** | `superdialog generate` a playbook, chat against it from the CLI, embed it with FastAPI through the `Agent` protocol |
| **Developer with compliance / scripted flows** | Author the flow graph as the spec - every path enumerable and lintable - and run it compiled on the Playbook engine, or on DialogMachine via `--mode flow` |
| **Enterprise dev with a custom LLM** | Plug any LLM URI and get the full framework for free |
| **Unpod Voice Platform customer** | SuperDialog is the default brain Unpod offers - same code runs locally and in Unpod cloud |
## How it compares
SuperDialog is to **conversation flow** what n8n is to **integration workflow** -
a simple, composable, eval-able runtime for orchestrating turn-by-turn logic.
Where LangChain and LangGraph expose general agent primitives, SuperDialog
focuses narrowly on the conversational core: who speaks next, what to say while
tools run, which checkpoint or flow the conversation is in, when to call a tool,
when to escalate, and which outcome the session ended with.
The pitch: *"if your problem is conversation state, this is the right size."*
## Two engines, one entry point
`DialogMachine` is the recommended way in. It runs the Playbook engine by
default; pass `engine="flow"` for the legacy graph runtime. Both engines sit
behind the same `Agent` protocol, so sessions and host adapters run either one
unchanged.
```python theme={null}
from superdialog import DialogMachine
agent = DialogMachine("booking.yaml", llm="openai/gpt-4.1-mini")
result = await agent.turn("hello")
```
Playbook is the default because users don't follow graphs: the graph-railed
model gated every utterance and still cost two serial LLM calls per turn.
Checkpoints gate outcomes instead - the model owns the phrasing, the framework
owns "done". **Existing flows are migrated, not replaced**: `Playbook.load`
detects flow JSON and compiles it (`compile_flow`), with `coverage_report`
proving every node, edge, and action mapped.
Side-by-side comparison, and when a graph still fits:
[Thinking in Playbooks](/superdialog/thinking-in-playbooks).
## What it explicitly is not
* **Not a UI flow designer** - that belongs to a downstream tool
* **Not a voice framework** - audio, STT, TTS are out of scope (the Talker
streams text tokens; the host turns them into speech)
* **Not multi-modal** - text only at the interface (vision/audio via tools if needed)
* **Not a hosted service** - SuperDialog is a library; the Unpod Voice Platform provides hosting for those who want it
# Playbooks
Source: https://docs.unpod.ai/superdialog/playbooks
Author playbooks - the simple format to start, the full format when you need typed slots, gates, pipelines, and multiple journeys.
## What is a playbook?
A **playbook** is the authored, git-diffable artifact the Playbook engine runs.
It has two layers:
* **Conversation layer** - `journeys` of **checkpoints** (a goal, typed slots,
guidance prose, and ordered advance rules) plus a `persona`.
* **Process layer** - everything that isn't conversation: `tools`, `pipelines`,
`handlers`, `interrupts`, and `policies`.
There are **two authoring formats and one engine**. Start in the simple format;
graduate to the full format when you need precision. Both compile to the same
validated artifact and run identically.
`Playbook.load(path)` auto-detects all three, so callers never branch on format.
## The simple format
Prose steps, a structured persona, and reference data as real YAML. This is what
`superdialog generate` writes.
```yaml theme={null}
goal: "Book a haircut and confirm it."
persona:
name: Mira
language: ["en", "hi"]
voice_style: "Warm and brief. One question at a time."
identity: "You are Mira, a booking assistant for Glow Studio."
opening: "Greet the caller warmly."
closing: "Thank them and say goodbye."
playbook:
- id: greet
purpose: "Open the call."
say: "Greet the caller and ask how you can help."
done_when: "Caller is ready to book."
- id: collect
purpose: "Get the booking details."
say: "Ask for their name and preferred service."
collect: [name, service]
done_when: "Name and service are captured."
- id: confirm
purpose: "Confirm and close."
say: "Read back the booking and confirm."
done_when: "Caller has confirmed."
facts:
canonical_pricing: {haircut: "₹400"}
boundaries: ["NEVER invent prices."]
interrupts:
- {when: "Caller says goodbye or asks to end the call.", to: main.confirm}
```
### Section reference
| Key | Meaning |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `goal` | The call's mission statement - what makes this call a win. |
| `persona` | `identity` (who the agent is), `name`, `language` (first is default), `voice_style` (tone, pacing). Compiles into the persona the Talker sees every turn. |
| `opening` / `closing` | Optional greeting / sign-off prose. `opening` seeds the first step's guidance when it has no `say`. |
| `playbook` | Ordered steps. Each becomes a checkpoint in a single journey `main`, **chained linearly by default**: step N's `done_when` advances to step N+1. Override with `then:` (explicit target) or `branches:` (conditional routing); reordering the list re-wires the default chain. |
| `facts` | Grounding data (pricing, policies) the agent may recite - never invent beyond it. |
| `objections` | `{trigger, handle}` steering, handled *within* a step. |
| `boundaries` | Compliance "NEVER…" rules (prose-enforced). |
| `interrupts` | Global jumps judged from any step (`{when, to}`). |
| `fallback_actions` | Ordered fallback steering when the caller stalls or goes off-script. |
Also valid at the top level: `name`, `channel`, `tone`, `call_type`, `timezone`,
`memory_enabled`, `followup_enabled`, and the multi-entity toggles `multi_entity` /
`supervisor`. **Any key not in this set raises at load** (see [Strict validation](#strict-validation)).
Per step:
| Field | Meaning |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Checkpoint id; addressable as `main.` in logs, metrics, replay. |
| `purpose` | The goal (Director-facing). |
| `say` | Guidance the Talker speaks from. |
| `collect` | Slot keys to capture. |
| `done_when` | Observable condition that advances (one `judge: llm` rule). |
| `require` | Explicit subset of `collect` that gates the advance. |
| `turn_budget` | User turns before a "wrap this step up" nudge (default 4). |
| `kb` | Whether this step's prompt carries the knowledge base; set `false` on steps that only mention it. |
| `then` | Step id to advance to when `done_when` holds, instead of the next list element. Unknown targets and self-targets are rejected at load. |
| `terminal` + `outcome` | Mark the step as a call ending (default `outcome: closed`), recorded on `SessionEnd`. Any step can be terminal now - not only the last. `outcome` on a non-terminal step is rejected. |
| `branches` | Multi-way routing, judged in author order **ahead of** `done_when`. Each entry is `{when, to, requires?}` - note the target key is `to`, not `then`. A branch may not target the default next step or itself; terminal steps cannot have branches. |
| `then_say` | A line delivered *while advancing out of* this step (post-capture pitch), rendered Jinja over slots. Guidance written after the capture in `say` is unreachable, so use `then_say`. Never spoken on interrupt/policy advances; rejected on terminal steps. |
| `entity` / `gate` | Advanced: turn owner (`caller`/`agent`) and per-step `soft`/`hard` gate. |
**Routing example** - a branch off the default chain, and two terminal outcomes:
```yaml theme={null}
playbook:
- id: pitch_visit
say: "Offer the site visit; handle the objection on hesitation."
done_when: "Customer accepts or is open to the visit."
then_say: "Great - let me lock that in." # spoken while advancing out
branches:
- when: "Customer firmly declines after the objection was handled."
to: advisor_callback # `to`, not `then`; never the default next step
- id: book_visit # pitch_visit's default next step
say: "Confirm the slot, read it back, and say goodbye."
terminal: true # a terminal step need not be the last one
outcome: visit_booked
- id: advisor_callback
say: "Capture a callback time."
collect: [callback_time]
done_when: "Callback time captured."
- id: close # advisor_callback's default next step
say: "Thank them and say goodbye."
terminal: true
outcome: callback_scheduled
```
**Which `collect` keys gate advancement depends on the step's shape.** A
focused capture step (≤2 slots) requires all of them filled before the
Director may advance; a branchy step collecting more than 2 per-path
alternatives requires **none** - demanding every slot of a 14-slot category
qualifier would deadlock the step. Use `require:` to override the heuristic
(e.g. one mandatory key on an otherwise-branchy step).
**Always add a goodbye interrupt.** In testing, linear playbooks with no early
exit never completed a single call (a satisfied caller loops until the turn
cap); the same playbook with goodbye/busy interrupts completed every call.
### Strict validation
Keys the format does not recognize - a typo'd `done_wehn`, an invented top-level
`language_lock:` - **raise at load** with the dotted path of every offender,
instead of being silently dropped (config theater: you think it's set, the
runtime never sees it). For live loaders that must not kill a call over a stale
authored file, downgrade to a warning:
```python theme={null}
from superdialog.playbook.simple import simple_to_playbook, load_simple
pb = simple_to_playbook(doc, strict=False) # warn instead of raise
pb = load_simple(path, strict=False)
```
### What the simple format cannot express
Multiple terminals/outcomes, per-step `gate` and `then`/`branches` routing are
now all expressible in the simple format (above). When you need any of these,
move to the full format:
* Pipelines and tools (transactional steps - holds, payments)
* `judge: expr` rules (machine-evaluated transitions - zero LLM cost)
* Typed/required slots, `never_say`, `say_verbatim`, silence policy, multiple journeys
The escape hatch is one-way: compile your simple file and continue authoring the
result. There is no decompiler back.
## The full format
Everything the engine can do, stated explicitly. The conversation layer is
`journeys` of checkpoints; the process layer is `tools`, `pipelines`,
`handlers`, `interrupts`, `policies`.
```yaml theme={null}
persona: "You are Asha, a friendly golf-course booking assistant."
llm: # the model loads from here (top-level persona `llm` is deprecated)
provider: anthropic
model: claude-haiku-4-5
director: anthropic/claude-haiku-4-5 # optional: separate Director model
views: # computed, LLM-free exprs; shown as reference data
hold_valid_until: "results.hold.data.valid_until"
journeys:
booking:
checkpoints:
- id: collect # addressed as booking.collect
goal: "Have city and date"
slots: # typed, flow-scoped declarations
city:
type: str # str|int|float|bool|date|enum|array|object
required: true
invalidates: [hold] # a city change clears the stale hold result
date: {type: date, required: true}
players: {type: int}
guidance: | # Jinja over {slots, views, results}
Collect naturally; the caller may give everything in one breath.
never_say: ["our systems are slow"]
turn_budget: 6 # steer to wrap up after 6 user turns here
on_failure: booking.handoff
advance_when: # ordered; first matching rule wins
- when: "caller gave the booking details"
judge: llm # the Director judges intent
to: booking.confirm
requires: [city, date] # rule fires only when these are met
- id: confirm
gate: hard # outcomes barrier on the Director here
pipeline: confirm_and_hold # process layer runs on entry
advance_when:
- {when: "pipeline.ok", judge: expr, to: booking.close}
- {when: "pipeline.failed", judge: expr, to: booking.collect}
- id: close
terminal: true # session ends on entry
outcome: confirmed # label for metrics and host hangup
- id: handoff
terminal: true # the `on_failure` target declared above
outcome: escalated
tools:
- id: hold_slot
type: http
method: POST
url: "{{ env.API_BASE_URL }}/slots/hold"
headers: {Authorization: "Bearer {{ env.ACCESS_TOKEN }}"}
body: {city: "{{ slots.city }}", date: "{{ slots.date }}"}
store_response_as: hold # readable as results.hold.* afterwards
pipelines:
- id: confirm_and_hold
steps:
- tool: hold_slot
on:
ok: continue
http_409: booking.collect # typed HTTP-status branch
failed: {retry: 1, on_exhaust: booking.collect}
interrupts:
- {id: goodbye, when: "caller says goodbye", judge: llm, to: booking.close}
policies:
silence:
max_prompts: 2
prompts: ["Can you hear me?", "Are you there?"]
then: booking.close
hold_timeout: 4.0 # max wait before the hold line is spoken (default 4.0s)
filler: "Ek second…" # author barrier line while the Director settles
hold_line: "Still working on it, bear with me." # spoken after hold_timeout
```
### The building blocks
| Block | Key fields |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Checkpoint** | `goal`, `slots`, `guidance`, `advance_when`, `gate` (`soft`/`hard`), `say_verbatim`, `never_say`, `exit_say` (post-capture pitch; `then_say` in the simple format), `auto`, `pipeline`, `on_failure`, `terminal` + `outcome`, `turn_budget` |
| **SlotSpec** | `type`, `required`, `values` (enum), `authoritative` (tool-written only), `invalidates`, `description` |
| **AdvanceRule** | `when` (prose or expr), `judge` (`llm`/`expr`), `to` (checkpoint ref), `requires` (slots that must be filled/confirmed), `set` (slot writes on advance) |
| **ToolSpec** | `type` (`http`/`python`), `method`/`url`/`headers`/`body` (sandboxed Jinja), `store_response_as`, `env_updates`, `run_once`, `when`, `timeout` |
| **PipelineSpec** | ordered `steps`, each with typed `on: {ok, failed, http_}` branches and capped `retry` |
| **LLMConfig** (`llm:`) | `provider`, `model`, optional `director` - the model-loading path (persona-level `llm` is deprecated and warns) |
| **Policies** (`policies:`) | `silence`, `hold_timeout` (default 4.0), `filler` + `hold_line` (author-facing barrier lines spoken while the Director settles) |
Validation runs on load and raises on unknown checkpoint/pipeline/tool refs,
duplicate ids, undeclared `requires` keys, and the reserved `pipeline` result
key - typos fail fast, not mid-call.
**`judge: expr` rules are evaluated LLM-free** at every quiescence hop - this is
what makes compiled router chains instant. The expr language is a sandboxed,
AST-whitelisted subset of Python over `slots`, `results`, `env`, and `pipeline`.
See the [API Reference](/superdialog/api-reference#the-expr-language).
## Generate, then refine
```bash theme={null}
# Generate a simple-format playbook from a prompt
superdialog generate "Book a tee time. Collect city, date, party size. \
Confirm before holding the slot." --output booking.yaml
# Chat against it - watch checkpoints advance
superdialog chat booking.yaml
# Close the loop: paired persona evals score prose-only edits, output stays
# in your source format
superdialog optimize --playbook booking.yaml
```
## How it runs
This page is about what you *write*. For what *happens* - the Talker/Director
compound runtime, gating semantics, and the event log - see
[Architecture](/superdialog/architecture). The mental model is in
[Thinking in Playbooks](/superdialog/thinking-in-playbooks).
The runtime that executes a playbook
The process layer in depth
Every field and the expr language
Graph authoring and migration
# Quickstart
Source: https://docs.unpod.ai/superdialog/quickstart
Install SuperDialog, generate a playbook from a prompt, and run your first conversation in under 5 minutes.
## Install
```bash theme={null}
pip install superdialog
```
Source on GitHub: [unpod-ai/superdialog](https://github.com/unpod-ai/superdialog).
Install only the extras you need:
```bash theme={null}
pip install superdialog[livekit] # LiveKit adapter
pip install superdialog[pipecat] # PipeCat adapter
pip install superdialog[fastapi] # FastAPI adapter + uvicorn
pip install superdialog[ws] # WebSocket runner
pip install superdialog[mcp] # MCP tool support
pip install superdialog[langchain] # LangChainAgent
```
## Step 1 - Generate a playbook from a prompt
`superdialog generate` is the default creation path. It writes a validated
**simple-format playbook** - prose steps plus a persona - that runs on the
Playbook engine.
```bash theme={null}
superdialog generate "Confirm a customer appointment. Ask if Friday 4pm works; \
offer 5pm as an alternative if not. Confirm before saving." \
--output appointment.yaml
```
The result is a human-readable, git-diffable YAML file you can edit by hand:
```yaml theme={null}
goal: "Confirm the appointment and lock in a time."
persona:
name: Ava
voice_style: "Warm and brief. One question at a time."
identity: "You are Ava, a scheduling assistant."
playbook:
- id: greet
purpose: "Open the call."
say: "Greet the customer and confirm you're calling about their appointment."
done_when: "Customer is ready to confirm a time."
- id: confirm_time
purpose: "Lock in the slot."
say: "Ask if Friday 4pm works; offer 5pm if not."
collect: [chosen_time]
done_when: "Customer has agreed to a time."
```
Prefer Python? `from superdialog.playbook import generate_simple_playbook`
returns the same validated YAML from an async call.
## Step 2 - Build the runtime agent
`DialogMachine` is the one entry point. Point it at your artifact and pick a
model URI - it runs the Playbook engine by default.
```python theme={null}
from superdialog import DialogMachine
agent = DialogMachine(
"appointment.yaml", # any format: playbook, simple, or legacy flow JSON
llm="anthropic/claude-haiku-4-5", # fast model for the speaking turn
)
```
## Step 3 - Run a conversation
```python theme={null}
import asyncio
async def chat():
reply = await agent.turn("Hello, I'm calling about my appointment.")
print(reply.text)
reply = await agent.turn("Friday 4pm works for me.")
print(reply.text)
asyncio.run(chat())
```
Or use the bundled CLI - no Python code needed:
```bash theme={null}
superdialog chat appointment.yaml
```
The REPL runs on the Playbook engine and prints a per-turn status line
(`[checkpoint= ended=]`) so you can watch outcomes advance.
## Step 4 - Add a tool
On the Playbook engine, tools live in the playbook's **process layer** - HTTP
or registered Python callables the Director runs off the speech path. For a
quick local function, register it by id:
```python theme={null}
from superdialog import DialogMachine
async def lookup_customer(args, state) -> dict:
"""Look up customer record by phone number."""
return await crm.get_by_phone(args["phone_number"])
agent = DialogMachine(
"appointment.yaml",
llm="anthropic/claude-haiku-4-5",
tools=[...], # see the Tools guide for HTTP / Python / MCP shapes
)
```
See [Tools](/superdialog/tools) for declaring tools in the playbook, pipelines,
and the legacy `DialogMachine(tools=[...])` bridge.
## Step 5 - Deploy anywhere
The same `agent` object drops into every host - the `Agent` protocol is the only
contract:
Plug in as an `Agent(llm=...)` plugin - real token streaming
Drop in as a `FrameProcessor` in your pipeline
Mount a `/turn` endpoint for text chatbots
Connect via `WebSocketRunner` to Unpod infrastructure
**Prefer a graph?** The legacy path still works:
`superdialog flow generate "..." --output appointment.json` writes a flow graph,
and `DialogMachine(Flow.load("appointment.json"), llm=..., engine="flow")` runs
the original graph engine. See [Flows](/superdialog/flows). By default a flow
JSON runs compiled on the Playbook engine - no `engine="flow"` needed.
## What's next
The checkpoint mental model
Author the simple and full formats
Two engines, the Talker/Director runtime, and data flow
All `superdialog` commands
# Sessions
Source: https://docs.unpod.ai/superdialog/sessions
Manage multiple concurrent conversations, persist state across process restarts, and use non-DM agent brains.
## When do you need sessions?
Sessions add a lifecycle and persistence layer on top of **any `Agent`-protocol
brain** - the default `PlaybookAgent` and the legacy `DialogMachine` alike. A
bare agent holds its conversation state in memory for the lifetime of the
instance. This works perfectly for:
* Voice calls (one machine per call, ephemeral)
* CLI testing (single conversation, short-lived)
* Simple single-user demos
You need `SessionWorker` when:
* **Multi-user:** multiple concurrent conversations hit the same process
* **Multi-worker:** requests are load-balanced across FastAPI workers or pods
* **Long-lived chat:** conversations span hours or days and must survive restarts
## The Agent Protocol
`SessionWorker` works with any brain that implements this Protocol:
```python Signature theme={null}
class Agent(Protocol):
async def turn(text: str, *, stream: bool = False) -> TurnResult | AsyncIterator[StreamChunk]
def assist(text: str) -> None
@property
def chat_ctx(self) -> ChatContext
def load_chat_ctx(ctx: ChatContext) -> None
```
The Playbook engine's `PlaybookAgent`, the legacy `DialogMachine`, `LLMAgent`,
and `LangChainAgent` all implement this.
## SessionWorker
The `agent_factory` returns a fresh agent per session. Point `DialogMachine` at
a playbook and you get the default engine; the factory is identical whatever the
artifact:
```python theme={null}
from superdialog import DialogMachine, SessionWorker, InMemorySessionStore
worker = SessionWorker(
agent_factory=lambda: DialogMachine("kyc.yaml", llm="openai/gpt-4.1-mini"),
store=InMemorySessionStore(),
max_sessions=1000, # optional cap
)
```
Use it with an async context manager:
```python theme={null}
async with worker.acquire("user-42") as h:
result = await h.turn("Hello")
h.assist("The customer is a VIP - be especially warm.")
print(result.text)
```
What `acquire` does:
1. Loads or creates the session for `session_id`
2. Acquires a per-session lock (serialises concurrent requests for the same id)
3. Yields a `SessionHandle`
4. On exit: persists state to the store and releases the lock
Requests for **different** `session_id`s run fully in parallel.
## FastAPI multi-user example
```python theme={null}
from contextlib import asynccontextmanager
from fastapi import FastAPI
from superdialog import DialogMachine, SessionWorker, InMemorySessionStore
worker: SessionWorker
@asynccontextmanager
async def lifespan(app: FastAPI):
global worker
worker = SessionWorker(
agent_factory=lambda: DialogMachine("kyc.yaml", llm="openai/gpt-4.1-mini"),
store=InMemorySessionStore(),
)
yield
app = FastAPI(lifespan=lifespan)
@app.post("/turn")
async def turn(payload: dict):
async with worker.acquire(payload["session_id"]) as h:
result = await h.turn(payload["text"])
return {"reply": result.text}
```
## Session stores
| Store | Status | Use case |
| ---------------------- | ------- | --------------------------------- |
| `InMemorySessionStore` | ✅ v0.2 | Development, single-process |
| `NullSessionStore` | ✅ v0.2 | Voice (ephemeral, no persistence) |
| `RedisSessionStore` | 🔜 v0.3 | Multi-process, distributed |
| `FileSessionStore` | 🔜 v0.3 | Lightweight persistence |
| `SQLiteSessionStore` | 🔜 v0.3 | Local single-server |
Switch stores by changing one line - the rest of the code stays the same:
```python theme={null}
# Development
store = InMemorySessionStore()
# Production (v0.3)
# store = RedisSessionStore(url="redis://localhost:6379")
```
## Lock backends
| Backend | Status | Use case |
| -------------------- | ------- | --------------------------- |
| `AsyncioLockBackend` | ✅ v0.2 | Single-process (default) |
| `RedisLockBackend` | 🔜 v0.3 | Multi-process / distributed |
## Other agent brains
When you want sessions and persistence but no checkpoint or flow opinion at all -
a raw chat brain:
```python theme={null}
from superdialog import LLMAgent, SessionWorker, InMemorySessionStore
# Raw chat brain - no flow, no slots
worker = SessionWorker(
agent_factory=lambda: LLMAgent(
llm="openai/gpt-5.1",
system_prompt="You are a helpful customer support assistant.",
),
store=InMemorySessionStore(),
)
```
Or with LangChain (requires `pip install superdialog[langchain]`):
```python theme={null}
from superdialog import LangChainAgent, SessionWorker, InMemorySessionStore
worker = SessionWorker(
agent_factory=lambda: LangChainAgent(runnable=my_langchain_chain),
store=InMemorySessionStore(),
)
```
## Conversation state
Internally, each session stores a `ChatContext` - LiveKit-aligned message
history:
```python theme={null}
@dataclass
class ChatMessage:
role: Literal["system", "user", "assistant", "tool"]
content: str
@dataclass
class ChatContext:
items: list[ChatMessage]
```
Beyond the transcript, each engine carries its own runtime state:
* **Playbook engine** - the source of truth is the **event-sourced log**
(`agent.event_log`); `ConversationState.fold(log, playbook)` derives the
checkpoint, slots, and outcome. Persist `event_log.to_jsonl()` and restore via
`load_event_log` for full fidelity.
* **Legacy DialogMachine** - `FlowState` (current node, slot values). Present
only when the brain is a `DialogMachine`; `None` otherwise.
`SessionWorker`'s built-in `SessionRecord` persists `chat_ctx` / `flow_state`
only. For durable or multi-worker resume on the Playbook engine, persist
`agent.event_log.to_jsonl()` yourself and restore with `load_event_log` -
otherwise playbook state fidelity (provisional vs confirmed slots, tool results)
is lost.
## Voice (ephemeral) pattern
For voice calls where each call is a fresh conversation and no persistence is needed:
```python theme={null}
worker = SessionWorker(
agent_factory=lambda: DialogMachine("kyc.yaml", llm="anthropic/claude-haiku-4-5"),
store=NullSessionStore(), # writes are dropped; reads always return empty
)
```
`NullSessionStore` keeps the single-call lifecycle of a bare agent while still giving you the multiplexing and locking of `SessionWorker`.
# Thinking in Playbooks
Source: https://docs.unpod.ai/superdialog/thinking-in-playbooks
The mental model shift from writing prompts to authoring checkpoints that gate outcomes - and why it makes complex conversations both fluid and reliable.
# Thinking in Playbooks
If you have built LLM chatbots before, SuperDialog's default engine asks for one
shift in thinking:
| Traditional bot | A rigid graph | SuperDialog playbook |
| ---------------------- | ----------------------------------- | ------------------------------------------------------------- |
| Write one long prompt | Wire every transition by hand | Author **checkpoints** that gate outcomes |
| LLM decides everything | LLM can only traverse defined edges | LLM owns the phrasing; the framework owns the outcomes |
| No structure | Users don't follow your graph | Conversation is free *inside* a checkpoint; progress is gated |
The key move: **checkpoints gate outcomes, not utterances.** You don't script
what the agent says next - you declare what "done" means for each step, and the
model speaks freely to get there.
## The core model
A **playbook** is one or more **journeys**, each a list of **checkpoints**. A
checkpoint is a call-center-script unit with four parts:
* **`goal`** - what "done" means for this step ("Have the city, date, and party size")
* **`slots`** - typed data to extract while here (`city: str`, `date: date`, `players: int`)
* **`guidance`** - prose the agent speaks from (it owns the wording)
* **`advance_when`** - an ordered list of rules that move the conversation forward
Those are the **full format** names. The **simple format** spells the same four
`purpose` / `collect` / `say` / `done_when`; both compile to one checkpoint - see
[Playbooks](/superdialog/playbooks).
Inside a checkpoint the conversation is free - the caller can answer in any
order, give everything in one breath, or change their mind. The framework's job
is only to decide **when the goal is actually met** and where to go next.
## Start with the topology, then write the steps
Before writing any prose, map your conversation:
1. What are the distinct **steps** (checkpoints) in this conversation?
2. What **data** (slots) must each step capture?
3. What **outcomes** move the conversation forward (advance rules)?
4. What can **go wrong** at each step? (caller refuses, asks a side question)
Then write it in the **simple format** - prose steps and a persona, the same
thing `superdialog generate` produces:
```yaml theme={null}
goal: "Book a haircut and confirm it."
persona:
name: Mira
voice_style: "Warm and brief. One question at a time."
identity: "You are Mira, a booking assistant for Glow Studio."
playbook:
- id: greet
purpose: "Open the call."
say: "Greet the caller and ask how you can help."
done_when: "Caller is ready to book."
- id: collect
purpose: "Get the booking details."
say: "Ask for their name and preferred service."
collect: [name, service]
done_when: "Name and service are captured."
- id: confirm
purpose: "Confirm and close."
say: "Read back the booking and confirm."
done_when: "Caller has confirmed."
```
See [Playbooks](/superdialog/playbooks) for the full section reference and when
to graduate to the full format (typed slots, gates, pipelines, multiple
journeys).
## Test it - no infrastructure needed
```bash theme={null}
superdialog generate "Book a haircut and confirm it." --output salon.yaml
superdialog chat salon.yaml
```
Full interactive REPL against your playbook. No Unpod account, no phone number,
no voice setup required.
```
> I'd like to book a haircut
Hi! I'd be happy to help. May I have your name?
> Mira, and I'd like a colour
[checkpoint=collect ended=False]
```
The status line names the live checkpoint, so you can watch the conversation
advance as outcomes are met. Iterate on `salon.yaml`, re-run `chat` - the loop
takes seconds.
## Soft gates vs hard gates
Every checkpoint has a `gate`. This is where fluidity meets reliability:
* **Soft gate (default)** - provisional values are enough; the agent never
blocks. The model keeps the conversation moving and the extracted data settles
in the background. Use it for everything that isn't irreversible.
* **Hard gate** - for payments, identity, anything you can't undo. Required
slots must be **confirmed** (not just provisionally extracted), and the agent
briefly waits for that confirmation before it speaks the gated line. A single
model guess can never push past a hard gate on its own.
```yaml theme={null}
playbook:
- id: take_payment
purpose: "Charge the deposit."
say: "Confirm the amount, then take the card."
collect: [card_token, amount]
require: [card_token, amount] # must be confirmed, not just extracted
gate: hard # waits for that before the gated line
done_when: "Deposit charged."
```
## Why one streaming call, not two
A rigid graph has to make two LLM calls per turn: one to decide which edge
fires, then one to speak. For voice, that adds latency before the caller hears
anything.
A playbook splits the turn instead: a fast **Talker** streams the spoken reply
in one LLM call, while an async **Director** extracts slots and judges advance
rules **off the speech path**.
The caller hears the agent immediately; correctness converges a beat behind.
Full runtime - ordering, the event log, barge-in safety - in
[Architecture](/superdialog/architecture).
## When a graph still fits
The checkpoint model is the default and the right choice for most
conversations. A hand-authored **flow graph** still earns its place when:
* **Compliance / auditability** - you need every reachable path enumerable and
lintable as a spec.
* **Strict determinism** - the conversation truly is a fixed decision tree with
no room for the model to improvise.
You don't lose anything by authoring a graph: by default it runs **compiled onto
the Playbook engine** (`Playbook.load` detects flow JSON and converts it), and
you can still run the original graph runtime with `engine="flow"` /
`superdialog chat --mode flow`. See [Flows](/superdialog/flows) for graph
authoring and the migration path.
## Next steps
The simple and full authoring formats
Generate and run your first playbook
The Talker/Director runtime and event log
Graph authoring and the migration path
# Tools
Source: https://docs.unpod.ai/superdialog/tools
Give your agent the ability to call HTTP endpoints, Python functions, and MCP servers - declared in the playbook process layer and run by the Director off the speech path.
## Overview
Tools let your agent take actions during a conversation - look up a record, hold
a slot, charge a deposit, query a database. On the **Playbook engine** (the
default), tools live in the playbook's **process layer** and are run by the
**Director**, off the speech path, so a slow API call never stalls what the
caller hears.
Three tool shapes, one model:
| Type | Execution | Best for |
| -------- | ------------------------ | -------------------------------------------- |
| `http` | HTTP request (templated) | External REST APIs, microservices |
| `python` | In-process callable | Local functions, direct DB calls, any Python |
| MCP | MCP protocol | Model Context Protocol servers |
***
## Declaring tools in a playbook
A tool is a `ToolSpec` in the playbook's `tools:` list. HTTP tools template
their `url`/`headers`/`body` with sandboxed Jinja over `{slots, env, results}`;
the response is stored under `store_response_as` and is then readable as
`results.` in guidance, advance rules, and other tools.
```yaml theme={null}
tools:
- id: hold_slot
type: http
method: POST
url: "{{ env.API_BASE_URL }}/slots/hold"
headers: {Authorization: "Bearer {{ env.ACCESS_TOKEN }}"}
body: {city: "{{ slots.city }}", date: "{{ slots.date }}"}
store_response_as: hold # readable as results.hold.* afterwards
env_updates: {hold_id: hold_id} # env key <- dotted path into the response
run_once: false # true: at most one call per session
when: "slots.city" # expr over state; skip the call when falsy
timeout: 10
```
A checkpoint runs tools via its `pipeline` (on entry) or `on_enter` list. Tool
failures are **data, not exceptions** - a failed HTTP status or template error is
recorded as a failed result and routed declaratively, never a crash mid-call.
## Pipelines
Chain tools with typed result branches. Each step routes on `ok`, `failed`, or
an exact `http_`; failures can retry (capped) and route on exhaustion.
```yaml theme={null}
pipelines:
- id: confirm_and_hold
steps:
- tool: hold_slot
on:
ok: continue # next step, or pipeline success
http_409: booking.offer_other # typed status branch
failed: {retry: 2, on_exhaust: booking.collect}
```
A pipeline-owned checkpoint routes on `pipeline.ok` / `pipeline.failed`:
```yaml theme={null}
- id: confirm
gate: hard
pipeline: confirm_and_hold
advance_when:
- {when: "pipeline.ok", judge: expr, to: booking.close}
- {when: "pipeline.failed", judge: expr, to: booking.collect}
```
For auth that expires mid-call, one `middleware` entry refreshes a token and
replays the step:
```yaml theme={null}
middleware: {on_status: 401, refresh_with: refresh_auth, then: replay}
```
## Python tools
Declare a `python` tool in the playbook by id, then bind the implementation. A
Python tool is an async callable that receives the call args and the current
state:
```yaml theme={null}
tools:
- id: lookup_customer
type: python
args: {phone_number: {type: str, required: true}}
store_response_as: customer
```
```python theme={null}
from superdialog.playbook import Playbook, PlaybookAgent, httpx_http
async def lookup_customer(args, state) -> dict:
"""Look up a customer by phone number."""
return await crm.get_by_phone(args["phone_number"])
agent = PlaybookAgent(
playbook=Playbook.load("booking.yaml"),
talker_llm=talker,
director_llm=director,
http=httpx_http,
python_tools={"lookup_customer": lookup_customer}, # bind by id
)
```
Through the unified `DialogMachine` entry point, pass any `Tool` and it is
bridged automatically:
```python theme={null}
from superdialog import DialogMachine, PythonTool
agent = DialogMachine(
"booking.yaml",
llm="anthropic/claude-haiku-4-5",
tools=[PythonTool.of(lookup_customer)], # bridged through the engine
)
```
***
## MCP tools
For servers that implement the [Model Context Protocol](https://modelcontextprotocol.io).
Requires `pip install superdialog[mcp]`.
```python theme={null}
from superdialog import MCPTool
tool = MCPTool(
id="search",
name="search",
description="Search the internal knowledge base",
server="https://mcp.company.io",
)
```
`MCPTool` connects lazily on first use and forwards `execute(args)` to the
configured server. Auto-discovery of all tools an MCP server publishes is
planned for a follow-up release.
***
## Security model
The playbook artifact is data and the transcript is untrusted user speech.
Tools are defended accordingly:
* **Sandboxed Jinja** for all `url`/`headers`/`body` rendering - attribute-walking
injection payloads are blocked, not executed.
* **Secret redaction** in the event log - token/api-key/password/bearer-shaped
keys and URL userinfo are masked before the `ToolCallEvent` lands; the real
request still goes to the wire untouched.
* **The `env` lane is never rendered to the Talker** - `ACCESS_TOKEN`-class
values cannot leak into speech or the packed prompt.
***
## Legacy: tools on the graph engine
On the legacy DialogMachine graph engine (`engine="flow"`), tools are registered
as a list and the LLM calls them via tool-calling. This still works for graph
flows.
### `@tool` decorator and plain functions
```python theme={null}
from superdialog.tools import tool
@tool
async def lookup_customer(customer_id: str) -> dict:
"""Look up customer record by ID."""
return await crm.get(customer_id)
dm = DialogMachine(flow, llm="...", engine="flow", tools=[lookup_customer])
```
Plain functions work too - SuperDialog wraps them using the function name as the
id and the docstring as the description.
### `PythonTool` / `HttpTool`
```python theme={null}
from superdialog import PythonTool, HttpTool
import os
tool = PythonTool.of(lookup_customer) # infer id/name/schema
http = HttpTool(
id="lookup", name="lookup",
description="Look up a customer by partial Aadhaar",
url="https://api.company.io/customer/lookup",
auth={"type": "bearer", "token": os.environ["COMPANY_KEY"]},
)
```
### Function references on the flow model
Attach functions to `ConversationFlow.tools` (flow-level) or `FlowNode.tools`
(node-scoped) when building graphs in Python:
```python theme={null}
from superdialog.flow.models import ConversationFlow, FlowNode, Edge
from superdialog.machine.machine import DialogStateMachine
from superdialog.tools import tool
@tool
async def search_kb(query: str) -> dict:
"""Search the knowledge base."""
return {"results": await kb.search(query)}
async def check_availability(date: str) -> dict:
"""Check available appointment slots for a date."""
return {"slots": await calendar.get_slots(date)}
flow = ConversationFlow(
system_prompt="You are an appointment booking assistant.",
initial_node="collect_info",
tools=[search_kb], # available on every node
nodes=[
FlowNode(
id="collect_info",
name="Collect Info",
instruction="Collect patient name and preferred date.",
tools=[check_availability], # node-scoped
edges=[Edge(id="e_confirm", condition="All info collected", target_node_id="confirm")],
),
],
)
machine = await DialogStateMachine.from_flow(flow, adapter)
```
### Tool results and flow transitions
On the graph engine, a tool can trigger a transition by returning a `ToolResult`
with `transition_edge_id`:
```python theme={null}
from superdialog.machine.models import ToolResult
async def book_appointment(slot_id: str) -> ToolResult:
"""Book the appointment for the given slot."""
if await calendar.book(slot_id):
return ToolResult(data={"booked": True}, transition_edge_id="booking_confirmed")
return ToolResult(data={"booked": False})
```
On the Playbook engine, outcome routing is done by **advance rules** and
**pipeline branches** instead - a tool result is stored under
`store_response_as` and read by `judge: expr` rules. There is no
`transition_edge_id`.
# Call Detail Records (CDR)
Source: https://docs.unpod.ai/telephony/calls/call-logs
GET /api/v2/platform/cdr/
Fetch telephony call detail records with filtering and pagination
Fetch telephony call detail records (CDR) for your organization - inbound and
outbound SIP calls with status, timing, duration, and end reason.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
### Query parameters
| Name | Type | Description |
| ------------ | ------- | ---------------------------------------- |
| page | integer | Page number (default `1`) |
| page\_size | integer | Rows per page (default `20`) |
| call\_type | string | `inbound` or `outbound` |
| call\_status | string | `completed`, `notConnected`, or `failed` |
```json 200 theme={null}
{
"count": 643,
"status_code": 200,
"message": "Call logs fetched successfully",
"data": [
{
"id": 33364,
"call_status": "completed",
"end_reason": "call.in-progress.sip-completed-call",
"call_type": "outbound",
"bridge": { "id": 12, "name": "Acme Primary Bridge" },
"creation_time": "2025-11-08T05:32:29Z",
"start_time": "2025-11-08T05:29:43Z",
"end_time": "2025-11-08T05:32:28.686639Z",
"call_duration": 165.686639,
"source_number": "+15551234567",
"destination_number": "+15559876543",
"failure_source": null,
"sip_cause": null
}
]
}
```
```bash cURL theme={null}
curl -s "https://unpod.ai/api/v2/platform/cdr/?page_size=5" \
-H "Authorization: Token " \
-H "Org-Handle: "
```
# Telephony Overview
Source: https://docs.unpod.ai/telephony/calls/overview
GET /api/v2/platform/telephony/overview/
Per-number lifecycle: connection state, termination, agent link, sync state
A per-number lifecycle overview for your organization: one row per `ASSIGNED` number on
your bridges, exposing `connection_state`, termination kind, agent link, and the
projection `sync_state`. This endpoint is DB-only - it never live-probes the projection
planes, so it stays fast and never errors on a missing projection. Secrets are masked.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
| Product-Id | string | No | Optional product filter |
### Row fields
| Field | Type | Description |
| ----------------- | ------- | -------------------------------------- |
| number\_id | integer | Number id |
| number | string | E.164 number |
| bridge\_slug | string | Bridge the number is on |
| connection\_state | string | e.g. `LINKED`, `NOT_LINKED` |
| termination\_kind | string | `sip` (carrier) or `agent` |
| agent\_id | string | Linked agent handle (null for carrier) |
| sync\_state | string | Projection sync state |
| sync\_detail | string | Extra sync detail (nullable) |
```json 200 theme={null}
{
"status_code": 200,
"message": "Telephony overview fetched successfully.",
"data": [
{
"number_id": 501,
"number": "+15551234567",
"bridge_slug": "support-bridge",
"connection_state": "LINKED",
"termination_kind": "sip",
"agent_id": null,
"sync_state": "synced",
"sync_detail": null
}
]
}
```
```json 206 theme={null}
{ "message": "Please provide Org-Handle in headers" }
```
```bash cURL theme={null}
curl -s "https://unpod.ai/api/v2/platform/telephony/overview/" \
-H "Authorization: Token " \
-H "Org-Handle: "
```
# Daily (API)
Source: https://docs.unpod.ai/telephony/integrations/daily/api
Set up the Unpod side of a Daily integration over the REST API - create a SIP trunk to your Daily SIP dial-in room and attach your number.
The **programmatic** path for the **Unpod side**: create a SIP trunk pointed at your
**Daily SIP dial-in** address and attach your number. The trunk's **origin endpoint** is
what Daily uses to reach Unpod - see the [Dashboard guide](/telephony/integrations/daily/dashboard)
for the Daily-side setup.
**Base URL:** `https://unpod.ai/api/v2/platform/`
Every request needs `Authorization: Token ` and an `Org-Handle` header.
See [Authentication](/api/get-started/authentication).
## Prerequisites
* An Unpod **API Token** and **Org-Handle**.
* A number in your org (we'll fetch its `id` below).
* A Daily room with **SIP dial-in enabled** - note its `sip_uri`
(`sip:@.sip.daily.co`).
## 1. Find your number
```bash cURL theme={null}
curl https://unpod.ai/api/v2/platform/telephony/numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE"
```
```json Response (200) theme={null}
{
"status_code": 200,
"message": "Telephony numbers fetched successfully.",
"data": [
{ "id": 501, "number": "+15551234567", "state": "NOT_ASSIGNED", "active": true }
]
}
```
Full request/response schema.
## 2. Create the SIP trunk to Daily
Point the trunk's `sip_url` at your Daily room's `sip_uri`. One trunk carries both inbound and outbound.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{
"name": "Daily trunk",
"sip_url": "sip:endpoint@your-subdomain.sip.daily.co",
"transport": "udp",
"port": "5060"
}'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Trunk created successfully.",
"data": { "id": 21, "name": "Daily trunk", "sip_url": "sip:endpoint@your-subdomain.sip.daily.co", "transport": "udp", "port": "5060", "active": true }
}
```
All request fields (`sip_url`, `auth_username`, `auth_password`, `transport`, `port`, `source_ips`).
## 3. Attach your number to the trunk
Map the number from step 1 (its `id`) to the trunk from step 2 (its `id`). The response
returns the **origin endpoint** - the address + creds Daily uses to reach Unpod.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/21/attach-numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{ "number_ids": [501] }'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Numbers mapped to trunk.",
"data": {
"trunk_id": 21,
"origin_endpoint": {
"ingress": "sip:sip-lb1.unpod.tel",
"dids": ["+15551234567"],
"accepted_source_ips": [],
"region": "ap-south"
}
}
}
```
Path params, request body, and the full origin-endpoint response.
## Configure the Daily side
Enable **SIP dial-in** on your Daily room. This is **Daily's own API** (`api.daily.co`,
`Authorization: Bearer` with your Daily key). Run this **first** - the `sip_uri` it returns
is exactly what you put in the trunk's `sip_url` in step 2.
### 4. Enable SIP dial-in on the room
```bash cURL theme={null}
curl -X POST https://api.daily.co/v1/rooms/your-room-name \
-H "Authorization: Bearer $DAILY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"sip": {
"sip_mode": "dial-in",
"display_name": "SIP Participant",
"num_endpoints": 1,
"codecs": { "audio": ["OPUS"] }
}
}
}'
```
```json Response theme={null}
{
"config": {
"sip_uri": { "endpoint": "sip:123456780@example.sip.daily.co" },
"sip": { "sip_mode": "dial-in", "display_name": "SIP Participant" }
}
}
```
`config.sip_uri.endpoint` is the address you point the Unpod trunk at (the `sip_url` in
step 2). Route that room to your agent so calls from Unpod connect to it.
Full room `sip` properties + `sip_uri` response (this is Daily's API).
## Troubleshooting
| Status | Meaning | Fix |
| ------------------- | ----------------- | --------------------------------------- |
| `400` on `/trunks/` | Missing `sip_url` | Send your Daily room `sip_uri` |
| `400` on attach | Number not in org | Use a valid `id` from **GET /numbers/** |
| `401` | Bad Unpod token | Verify `Authorization: Token …` |
| `403` | Wrong org | Verify the `Org-Handle` header |
The same flow in the Studio UI.
Back to the integrations overview.
# Daily (Dashboard)
Source: https://docs.unpod.ai/telephony/integrations/daily/dashboard
Connect Daily to Unpod with no code - create a SIP trunk on your Unpod number and point it at your Daily SIP dial-in room.
The **no-code** path. Create a SIP trunk on your Unpod number, point it at your **Daily
SIP dial-in** address, then copy the trunk's origin-endpoint credentials so Daily can reach
Unpod. One trunk carries inbound and outbound. Prefer code? See the
[API guide](/telephony/integrations/daily/api).
**You need:** an Unpod number on a Bridge, and a Daily account with **SIP dial-in enabled**
on a room (this gives you a `sip:...@.sip.daily.co` address).
We only show the **Unpod-side** screens here. Treat your SIP trunk username/password as
secrets - never paste them into shared docs or screenshots.
## Part 1 - Create the trunk in Unpod
In **Telephony**, select your number and click **Configure**. On the **New trunk** tab,
set a **Name** you'll recognise and the **trunk origin endpoint**:
* **SIP URI / address** - your **Daily SIP dial-in** address
(e.g. `sip:endpoint@your-subdomain.sip.daily.co`).
* **Port** `5060` and **Transport** (`TCP` / `UDP` / `TLS`).
* **Allowed IPs / CIDR** - optional source allow-list.
Click **Test** to validate, then **Create Trunk**.
The number now shows **Linked · Daily**. Under **Origin Endpoint Details** copy the
values - Daily uses them to reach Unpod:
* **Address** - e.g. `sip-lb1.unpod.tel`
* **Port** / **Protocol** - `5060` / `UDP`
* **Username** and **Password** (under **Authentication**)
## Part 2 - Enable SIP dial-in on Daily
Daily has **no fixed SIP host** - each room exposes its own SIP address. Enable SIP dial-in
so Daily gives you the `sip_uri` you entered in Part 1.
Enable SIP dial-in on the Daily room (set the room's `sip` property with
`sip_mode: "dial-in"`). Daily returns a read-only **`sip_uri`** in the form
`sip:@.sip.daily.co`. This is **API-only** - Daily has no
dashboard screen for it.
Point that room / SIP dial-in at the agent that should answer, so incoming calls from
the Unpod trunk connect to it.
Daily's SIP dial-in setup is **API-driven** (room `sip` property / `pinless_dialin`). See
[Daily's SIP dial-in docs](https://docs.daily.co/guides/products/dial-in-dial-out/sip).
## Part 3 - Publish
Back in the Unpod Studio, click **Publish** to activate the configuration. The number is
then ready for inbound and outbound calls.
## Troubleshooting
| Symptom | Likely cause | Fix |
| ----------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
| Trunk **Test** fails in Unpod | Wrong Daily `sip_uri` or transport | Re-copy the room's `sip_uri`; match transport |
| Daily can't reach the trunk | Wrong origin-endpoint address or auth | Re-copy **Address** + **Username/Password** from the Unpod origin endpoint |
| Call connects, agent silent | No agent bound to the room | Route the SIP dial-in room to an agent |
| Inbound not arriving | SIP dial-in not enabled | Set `sip_mode: "dial-in"` on the Daily room |
| Number not reachable | Config not published | Click **Publish** in the Unpod Studio |
Same flow over the REST API.
Back to the integrations overview.
# ElevenLabs (API)
Source: https://docs.unpod.ai/telephony/integrations/elevenlabs/api
Set up the Unpod side of an ElevenLabs integration over the REST API - create a SIP trunk to ElevenLabs and attach your number.
The **programmatic** path for the **Unpod side**: create a SIP trunk pointed at ElevenLabs
and attach your number. The trunk's **origin endpoint** is what you then register in
ElevenLabs as a BYO SIP trunk - see the [Dashboard guide](/telephony/integrations/elevenlabs/dashboard)
for the ElevenLabs-side screens.
**Base URL:** `https://unpod.ai/api/v2/platform/`
Every request needs `Authorization: Token ` and an `Org-Handle` header.
See [Authentication](/api/get-started/authentication).
## Prerequisites
* An Unpod **API Token** and **Org-Handle**.
* A number in your org (we'll fetch its `id` below).
## 1. Find your number
```bash cURL theme={null}
curl https://unpod.ai/api/v2/platform/telephony/numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE"
```
```json Response (200) theme={null}
{
"status_code": 200,
"message": "Telephony numbers fetched successfully.",
"data": [
{ "id": 501, "number": "+15551234567", "state": "NOT_ASSIGNED", "active": true }
]
}
```
Full request/response schema.
## 2. Create the SIP trunk to ElevenLabs
Point the trunk's `sip_url` at ElevenLabs' SIP host. One trunk carries both inbound and outbound.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{
"name": "ElevenLabs trunk",
"sip_url": "sip:sip.rtc.elevenlabs.io:5060;transport=tcp",
"transport": "tcp",
"port": "5060"
}'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Trunk created successfully.",
"data": { "id": 21, "name": "ElevenLabs trunk", "sip_url": "sip:sip.rtc.elevenlabs.io:5060;transport=tcp", "transport": "tcp", "port": "5060", "active": true }
}
```
All request fields (`sip_url`, `auth_username`, `auth_password`, `transport`, `port`, `source_ips`).
## 3. Attach your number to the trunk
Map the number from step 1 (its `id`) to the trunk from step 2 (its `id`). The response
returns the **origin endpoint** - the address + creds you register in ElevenLabs.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/21/attach-numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{ "number_ids": [501] }'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Numbers mapped to trunk.",
"data": {
"trunk_id": 21,
"origin_endpoint": {
"ingress": "sip:sip-lb1.unpod.tel",
"dids": ["+15551234567"],
"accepted_source_ips": [],
"region": "ap-south"
}
}
}
```
The `origin_endpoint.ingress` (plus the trunk's `auth_username` / `auth_password`) is what
you enter in ElevenLabs' BYO SIP trunk.
Path params, request body, and the full origin-endpoint response.
## Configure the ElevenLabs side
The Unpod side is done. Import the number into ElevenLabs over SIP. This is **ElevenLabs'
own API** (`api.elevenlabs.io`, `xi-api-key` header). Use the origin endpoint from step 3.
### 4. Import the number from SIP trunk
```bash cURL theme={null}
curl -X POST https://api.elevenlabs.io/v1/convai/phone-numbers \
-H "xi-api-key: $ELEVENLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "sip_trunk",
"phone_number": "+15551234567",
"label": "Unpod trunk",
"outbound_trunk_config": {
"address": "sip-lb1.unpod.tel",
"transport": "tcp",
"media_encryption": "allowed",
"credentials": {
"username": "",
"password": ""
}
},
"inbound_trunk_config": {
"allowed_addresses": ["0.0.0.0/0"]
}
}'
```
`outbound_trunk_config.address` = your `origin_endpoint.ingress` host (no `sip:`). The
response returns a `phone_number_id` - attach your Conversational AI agent to it.
Full phone-number import + agent assignment (this is ElevenLabs' API).
## Troubleshooting
| Status | Meaning | Fix |
| ------------------- | ----------------- | --------------------------------------------------- |
| `400` on `/trunks/` | Missing `sip_url` | Send `sip:sip.rtc.elevenlabs.io:5060;transport=tcp` |
| `400` on attach | Number not in org | Use a valid `id` from **GET /numbers/** |
| `401` | Bad Unpod token | Verify `Authorization: Token …` |
| `403` | Wrong org | Verify the `Org-Handle` header |
# ElevenLabs (Dashboard)
Source: https://docs.unpod.ai/telephony/integrations/elevenlabs/dashboard
Connect ElevenLabs to Unpod with no code - connect a number in the Unpod Studio, then import it into ElevenLabs Conversational AI over SIP.
The **no-code** path. Connect your number in the Unpod Studio to get an **Unpod SIP
trunk** (one endpoint, inbound + outbound), then import that number into **ElevenLabs
Conversational AI** over SIP and attach an agent. Prefer code? See the
[API guide](/telephony/integrations/elevenlabs/api).
**You need:** an Unpod number on a Bridge, and an ElevenLabs account with a
**Conversational AI agent** (the agent needs a **First Message** and a selected voice).
We only show the **Unpod-side** screens here. Treat your SIP trunk username/password as
secrets - never paste them into shared docs or screenshots.
## Part 1 - Connect the number in Unpod
In **Telephony**, select your number (shown as **Not Linked**) and click **Connect**
to set up its SIP trunk.
In the trunk panel, set the **Trunk Origin Endpoint Details** to ElevenLabs' SIP host:
* **SIP URI / address** - `sip:sip.rtc.elevenlabs.io:5060;transport=tcp`
(or port `5061` with `transport=tls` for TLS).
* **Port** `5060` (TCP) / `5061` (TLS).
* **Allowed IPs / CIDR** - optional source allow-list.
Click **Save**. The number then shows **Linked · Unpod SIP Trunk**.
Calls Unpod sends to ElevenLabs use the form
`sip:@sip.rtc.elevenlabs.io:5060` (identifier + domain).
Under **Origin Endpoint Details** copy these - you'll paste them into ElevenLabs next:
* **Address** - e.g. `sip-lb1.unpod.tel`
* **Port** / **Protocol** - `5060` / `TCP`
* **Username** and **Password** (under **Authentication**)
## Part 2 - Import the number into ElevenLabs
In the **ElevenLabs Agents** dashboard, open the **Phone Numbers** section.
Click **Import number**, then choose **Import a phone number from SIP trunk**.
In the **Import SIP Trunk** panel set:
* **Label** - a descriptive name.
* **Phone Number** - your Unpod number in E.164 (e.g. `+918071539111`), or a SIP
extension / identifier.
Under **Inbound Configuration** (forwards calls to the ElevenLabs SIP server):
* **Media Encryption** - `Allowed` (use `Required` with TLS for production).
* **Allowed Numbers** *(optional)* - leave empty to allow all.
* **Allowed Source IP Addresses** *(optional)* - `0.0.0.0/0` to allow all (TCP/TLS only;
restrict in production).
So ElevenLabs can send calls to Unpod, enter the Unpod origin-endpoint values from Part 1:
* **Address** - your Unpod address, **hostname only, no `sip:` prefix** (e.g. `sip-lb1.unpod.tel`).
* **Transport Type** - `TCP` (or `TLS`).
* **Media Encryption** - `Allowed` (use `Required` with TLS for production).
* **SIP Trunk Username** / **SIP Trunk Password** - the Unpod **Username** / **Password**.
Click **Import**.
**Production:** ElevenLabs recommends **TLS transport + Required media encryption** (TLS 1.2+).
Your system must support **G711 or G722** codecs (8 kHz / 16 kHz) or resample.
## Part 3 - Attach your agent
Open the imported number and **attach** your Conversational AI **agent** from the
dropdown. Incoming calls now route through ElevenLabs to that agent.
From **Phone Numbers**, select the imported number → **Make Outbound Call** → choose the
agent → enter the destination in E.164. Check the agent's **Call History** /
**Conversations** for transcripts and recordings.
## Troubleshooting
| Symptom | Likely cause | Fix |
| --------------------- | ------------------------------------ | --------------------------------------------------------------------------------- |
| SIP `408` timeout | Wrong trunk address or transport | Confirm the Unpod address and **TCP** transport on both sides |
| Authentication failed | Wrong / mis-cased credentials | Re-copy the **Username/Password** from the Unpod origin endpoint (case-sensitive) |
| Call connects, silent | Agent missing First Message or voice | Set a **First Message** and a valid voice on the agent |
| Inbound not arriving | Unpod trunk SIP URI wrong | Set it to `sip:sip.rtc.elevenlabs.io:5060` (TCP) |
| Number not reachable | Config not published | Click **Publish** in the Unpod Studio |
Same flow over the REST API.
Back to the integrations overview.
# LiveKit (API)
Source: https://docs.unpod.ai/telephony/integrations/livekit/api
Set up the Unpod side of a LiveKit integration over the REST API - create a SIP trunk to LiveKit and attach your number.
The **programmatic** path for the **Unpod side**: create a SIP trunk pointed at your
LiveKit SIP URI and attach your number. The trunk's **origin endpoint** is what you then
use to create the matching trunks in the LiveKit console - see the
[Dashboard guide](/telephony/integrations/livekit/dashboard) for the LiveKit-side screens.
**Base URL:** `https://unpod.ai/api/v2/platform/`
Every request needs `Authorization: Token ` and an `Org-Handle` header.
See [Authentication](/api/get-started/authentication).
## Prerequisites
* An Unpod **API Token** and **Org-Handle**.
* A number in your org (we'll fetch its `id` below).
* Your **LiveKit SIP URI** from the LiveKit Cloud dashboard (**Settings → Project**),
e.g. `sip:.sip.livekit.cloud`.
## 1. Find your number
```bash cURL theme={null}
curl https://unpod.ai/api/v2/platform/telephony/numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE"
```
```json Response (200) theme={null}
{
"status_code": 200,
"message": "Telephony numbers fetched successfully.",
"data": [
{ "id": 501, "number": "+15551234567", "state": "NOT_ASSIGNED", "active": true }
]
}
```
Full request/response schema.
## 2. Create the SIP trunk to LiveKit
Point the trunk's `sip_url` at your LiveKit SIP URI. One trunk carries both inbound and outbound.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{
"name": "LiveKit trunk",
"sip_url": "sip:.sip.livekit.cloud",
"transport": "tcp",
"port": "5060"
}'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Trunk created successfully.",
"data": { "id": 21, "name": "LiveKit trunk", "sip_url": "sip:.sip.livekit.cloud", "transport": "tcp", "port": "5060", "active": true }
}
```
All request fields (`sip_url`, `auth_username`, `auth_password`, `transport`, `port`, `source_ips`).
## 3. Attach your number to the trunk
Map the number from step 1 (its `id`) to the trunk from step 2 (its `id`). The response
returns the **origin endpoint** - the address + creds you use in LiveKit.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/21/attach-numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{ "number_ids": [501] }'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Numbers mapped to trunk.",
"data": {
"trunk_id": 21,
"origin_endpoint": {
"ingress": "sip:sip-lb1.unpod.tel",
"dids": ["+15551234567"],
"accepted_source_ips": [],
"region": "ap-south"
}
}
}
```
The `origin_endpoint.ingress` (plus the trunk's `auth_username` / `auth_password`) is what
you enter when creating the LiveKit outbound trunk.
Path params, request body, and the full origin-endpoint response.
## Configure the LiveKit side
The Unpod side is done. LiveKit's SIP API is served over **Twirp HTTP** at
`{LIVEKIT_URL}/twirp/livekit.SIP/`. Authenticate with a **LiveKit access token**
(JWT with a SIP admin grant) - mint one with `lk token create --sip-admin` or a server SDK.
```bash Env theme={null}
export LIVEKIT_URL="https://.livekit.cloud"
export LIVEKIT_TOKEN=""
```
### 4. Create the inbound trunk
```bash cURL theme={null}
curl -X POST "$LIVEKIT_URL/twirp/livekit.SIP/CreateSIPInboundTrunk" \
-H "Authorization: Bearer $LIVEKIT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"trunk": {
"name": "Unpod inbound",
"numbers": ["+15551234567"]
}
}'
```
```json Response theme={null}
{ "sip_trunk_id": "ST_inbound123", "name": "Unpod inbound", "numbers": ["+15551234567"] }
```
### 5. Create the outbound trunk
```bash cURL theme={null}
curl -X POST "$LIVEKIT_URL/twirp/livekit.SIP/CreateSIPOutboundTrunk" \
-H "Authorization: Bearer $LIVEKIT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"trunk": {
"name": "Unpod outbound",
"address": "sip-lb1.unpod.tel",
"destination_country": "US",
"transport": "SIP_TRANSPORT_TCP",
"numbers": ["+15551234567"],
"auth_username": "",
"auth_password": ""
}
}'
```
```json Response theme={null}
{ "sip_trunk_id": "ST_outbound456", "name": "Unpod outbound", "address": "sip-lb1.unpod.tel" }
```
`trunk.address` = your `origin_endpoint.ingress` host (no `sip:` prefix).
### 6. Create the dispatch rule
```bash cURL theme={null}
curl -X POST "$LIVEKIT_URL/twirp/livekit.SIP/CreateSIPDispatchRule" \
-H "Authorization: Bearer $LIVEKIT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dispatch_rule": {
"rule": { "dispatchRuleIndividual": { "roomPrefix": "call-" } }
},
"trunk_ids": ["ST_inbound123"]
}'
```
```json Response theme={null}
{ "sip_dispatch_rule_id": "SDR_abc789", "trunk_ids": ["ST_inbound123"] }
```
The same calls are available via the **`lk` CLI** (`lk sip inbound create `) and the
server SDKs (Go `SIPClient`, JS `SipClient`, Python `sip_service`). Field shapes follow
LiveKit's SIP proto - confirm in their API reference.
Full trunk + dispatch-rule API and SDK usage (this is LiveKit's API).
## Troubleshooting
| Status | Meaning | Fix |
| ------------------- | ----------------- | ---------------------------------------------- |
| `400` on `/trunks/` | Missing `sip_url` | Send your `sip:.sip.livekit.cloud` |
| `400` on attach | Number not in org | Use a valid `id` from **GET /numbers/** |
| `401` | Bad Unpod token | Verify `Authorization: Token …` |
| `403` | Wrong org | Verify the `Org-Handle` header |
# LiveKit (Dashboard)
Source: https://docs.unpod.ai/telephony/integrations/livekit/dashboard
Connect LiveKit to Unpod with no code - connect a number in the Unpod Studio, then point a LiveKit SIP trunk at it for low-latency streaming.
The **no-code** path. Connect your number in the Unpod Studio to get an **Unpod SIP
trunk** (one endpoint, inbound + outbound), then create matching trunks in the
**LiveKit Cloud** console using those credentials. Prefer code? See the
[API guide](/telephony/integrations/livekit/api).
**You need:** an Unpod number on a Bridge, a **LiveKit Cloud** project, and a
**LiveKit agent** to answer inbound calls. The Unpod trunk carries both inbound and
outbound - no separate provider setup.
Keep your LiveKit API Secret and SIP trunk credentials private - never paste them into
shared docs or screenshots.
## Part 1 - Get the LiveKit SIP URI
In the **LiveKit Cloud** dashboard, open **Settings → Project** (General) and copy the
**SIP URI** - it looks like `sip:.sip.livekit.cloud`. You paste this into the
Unpod trunk next so outbound calls reach LiveKit.
## Part 2 - Connect the number in Unpod
In **Telephony**, select your number (shown as **Not Linked**) and click **Connect**
to set up its SIP trunk.
In the trunk panel, set the **Trunk Origin Endpoint Details**:
* **SIP URI / address** - paste the **LiveKit SIP URI** from Part 1
(e.g. `sip:.sip.livekit.cloud`).
* **Port** `5060`, **Transport** `TCP` (or `UDP` / `TLS` to match LiveKit).
* **Allowed IPs / CIDR** - optional source allow-list.
Click **Save**. The number then shows **Linked · Unpod SIP Trunk**.
Under **Origin Endpoint Details** copy these - you'll use them in LiveKit next:
* **Address** - e.g. `sip-lb1.unpod.tel`
* **Port** / **Protocol** - `5060` / `UDP`
* **Username** and **Password** (under **Authentication**)
## Part 3 - Create the trunks in LiveKit
In the LiveKit console, go to **Telephony → SIP trunks** and click **Create new trunk**.
Fill the **Trunk details** form (or use the **JSON editor**). Create two trunks using the
Unpod values from Part 2.
Set **Trunk direction** to **Inbound**, then:
* **Trunk name** - a label.
* **Numbers** - your Unpod phone number(s) in E.164 (comma-separated). Leave empty to accept any.
* **Allowed addresses** - restrict to trusted IPs, or `0.0.0.0/0` to allow all (while testing).
Set **Trunk direction** to **Outbound**, then map the Unpod values:
* **Trunk name** - a label.
* **Address** - Unpod **Address** (e.g. `sip-lb1.unpod.tel`).
* **Transport** - `TCP` (match your trunk).
* **Numbers** - your Unpod phone number(s).
Under **Optional settings**, add the **Auth username / password** from the Unpod origin endpoint.
## Part 4 - Route inbound to your agent
In **Telephony → Dispatch rules**, click **Create new dispatch rule** so inbound calls
land in a room your agent joins:
* **Rule name** - a label.
* **Rule type** - **Individual**.
* **Room prefix** - e.g. `call-`.
* **Agent dispatch** - click **Add agent** and enter your deployed agent name (for explicit dispatch).
* **Inbound routing** - match by **Phone numbers** or **Trunks** (leave unset to apply to all).
A single Individual dispatch rule is usually the only routing config you need.
## Publish
Back in the Unpod Studio, click **Publish** to activate the configuration. The number
is then ready for inbound and outbound calls.
## Troubleshooting
| Symptom | Likely cause | Fix |
| --------------------------- | ----------------------------------- | -------------------------------------------------------------------------- |
| LiveKit can't place calls | Wrong outbound address or auth | Re-copy **Address** + **Username/Password** from the Unpod origin endpoint |
| Inbound not arriving | Number not on the inbound trunk | Confirm the Unpod number is in the inbound trunk's `numbers` (E.164) |
| Call connects, agent silent | Dispatch rule / agent name mismatch | Make **Agent Name** match the deployed agent exactly |
| Number not reachable | Config not published | Click **Publish** in the Unpod Studio |
Same flow over the REST API.
Back to the integrations overview.
# Integrations & SDKs
Source: https://docs.unpod.ai/telephony/integrations/overview
Connect Unpod to every major AI voice platform - Vapi, LiveKit, Twilio, Daily and more - over global SIP trunking and a single REST API.
Unpod is built ground-up for **AI-first interconnectivity**. Route telephone audio
straight into any major voice platform using **SIP trunking** or **WebSocket
streaming** - no custom bridges required. Every provider plugs into the same
trunk → number → agent model, configurable from the **Console** or the **REST API**.
Two ways to connect any platform: the **Dashboard** (no-code, click-through in the
Unpod Studio) or the **API** (programmatic, language-agnostic REST). Pick either -
they drive the same underlying trunk.
## Supported Platforms
Voice platforms
Live
Vapi
Production AI voice agents
Live
LiveKit
Low-latency SIP streaming
Live
ElevenLabs
Realistic TTS voices
Live
Ultravox
Multilingual speech model
Soon
WebSockets
Raw audio streaming
Live
Daily
Multi-party voice & video
## Official SDKs
Build in your language
Live
Python SDK
pip install unpod
Live
REST API
Language-agnostic HTTP
Soon
Node.js SDK
npm package
Soon
Ruby SDK
gem install
Soon
Go SDK
go get
Soon
C# / .NET SDK
NuGet package
Python is the only first-party SDK today. Other languages integrate through the
[REST API](/api/overview) or an [HTTP brain](/speech-stack/adapters#bundled-adapters)
until native SDKs ship.
## Prerequisites
Sign up and grab an **API Token** + **Org-Handle**. See [Authentication](/api/get-started/authentication).
Bring a number into your org so calls have a DID. See the [Quickstart](/telephony/quickstart).
A trunk holds your carrier credentials and exposes the origin endpoint. See [Create a trunk](/telephony/trunks/create-trunk).
Pick a platform above and run its **Dashboard** or **API** integration guide.
## Getting Started
| If you want to… | Recommended platform |
| ------------------------------------------- | -------------------- |
| Ship production AI voice agents fast | **Vapi** |
| Run the lowest-latency phone pipeline | **LiveKit** |
| Build multi-party voice + video | **Daily** |
| Drive calls programmatically from any stack | **REST API** |
The fastest path - Dashboard or API, your choice.
How trunks, numbers, and agents fit together end to end.
# Ultravox (API)
Source: https://docs.unpod.ai/telephony/integrations/ultravox/api
Place outbound Ultravox AI calls through your Unpod number over SIP - create the Unpod trunk, attach your number, then trigger the call from Ultravox.
The **programmatic** path: on the **Unpod side** create a SIP trunk and attach your number
to get a SIP endpoint + credentials; then call **Ultravox's Create Agent Call** endpoint
with those SIP details. Ultravox places the outbound call through your Unpod trunk. Prefer
clicks? See the [Dashboard guide](/telephony/integrations/ultravox/dashboard).
**Unpod Base URL:** `https://unpod.ai/api/v2/platform/`
Every Unpod request needs `Authorization: Token ` and an `Org-Handle` header.
See [Authentication](/api/get-started/authentication).
## Prerequisites
* An Unpod **API Token** and **Org-Handle**, plus a number on a Bridge.
* An Ultravox **API key** and an **agent** (note its **Agent ID**).
## 1. Find your Unpod number
```bash cURL theme={null}
curl https://unpod.ai/api/v2/platform/telephony/numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE"
```
```json Response (200) theme={null}
{
"status_code": 200,
"message": "Telephony numbers fetched successfully.",
"data": [
{ "id": 501, "number": "+918071539111", "state": "ASSIGNED", "active": true }
]
}
```
Full request/response schema.
## 2. Create the SIP trunk
Set `auth_username` / `auth_password` - these become the SIP credentials Ultravox
authenticates with when it dials into Unpod.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{
"name": "Ultravox trunk",
"sip_url": "sip:sip-lb1.unpod.tel",
"auth_username": "unpod_user_501",
"auth_password": "",
"transport": "udp",
"port": "5060"
}'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Trunk created successfully.",
"data": { "id": 21, "name": "Ultravox trunk", "auth_username": "unpod_user_501", "active": true }
}
```
All request fields (`sip_url`, `auth_username`, `auth_password`, `transport`, `port`, `source_ips`).
## 3. Attach your number to the trunk
Map the number from step 1 (its `id`) to the trunk from step 2 (its `id`). The response
returns the **origin endpoint** - the SIP domain Ultravox dials into.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/21/attach-numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{ "number_ids": [501] }'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Numbers mapped to trunk.",
"data": {
"trunk_id": 21,
"origin_endpoint": { "ingress": "sip:sip-lb1.unpod.tel", "dids": ["+918071539111"] }
}
}
```
You now have everything Ultravox needs:
* **SIP domain** - host from `origin_endpoint.ingress` (e.g. `sip-lb1.unpod.tel`, drop the `sip:`).
* **`auth_username`** / **`auth_password`** - the creds you set in step 2.
* Your **number** (`+918071539111`) for caller ID.
Path params, request body, and the full origin-endpoint response.
## 4. Place the call from Ultravox
This is **Ultravox's own API** (`api.ultravox.ai`, `X-API-Key` header). Point `to` at your
Unpod SIP domain and pass the Unpod credentials.
```bash cURL theme={null}
curl -X POST https://api.ultravox.ai/api/agents/YOUR_AGENT_ID/calls \
-H "X-API-Key: YOUR_ULTRAVOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"medium": {
"sip": {
"outgoing": {
"to": "sip:+919999999999@sip-lb1.unpod.tel",
"from": "+918071539111",
"username": "unpod_user_501",
"password": ""
}
}
},
"firstSpeakerSettings": { "user": {} }
}'
```
| Field | Value |
| ----------------------- | ----------------------------------------------------- |
| `to` | Destination as `sip:@` |
| `from` | Your Unpod number (caller ID) |
| `username` / `password` | Unpod `auth_username` / `auth_password` from step 2 |
| `firstSpeakerSettings` | `{ "user": {} }` - callee speaks first (outbound) |
Ultravox returns a `callId` and sends the SIP INVITE through Unpod. The agent joins when
the callee answers.
Full request body + response (this is Ultravox's API).
## 5. Debug with SIP logs
If a call is rejected, pull Ultravox's SIP logs for that `callId`:
```bash cURL theme={null}
curl https://api.ultravox.ai/api/calls/CALL_ID/sip/logs \
-H "X-API-Key: YOUR_ULTRAVOX_API_KEY"
```
SIP log fields for debugging rejected calls (this is Ultravox's API).
## Troubleshooting
| Status | Meaning | Fix |
| ------------- | ----------------------- | ---------------------------------------------------------------- |
| `403` | Auth rejected by Unpod | Re-check `username` / `password` (Unpod trunk creds from step 2) |
| `407` | Proxy auth required | Ensure `username` / `password` are in `sip.outgoing` |
| `480` | Destination unavailable | Verify the `to` number; confirm your Unpod number is active |
| `401` (Unpod) | Bad Unpod token | Verify `Authorization: Token …` on Unpod calls |
The same flow, gathering creds in the UIs.
Back to the integrations overview.
# Ultravox (Dashboard)
Source: https://docs.unpod.ai/telephony/integrations/ultravox/dashboard
Connect Ultravox to Unpod - use your Unpod number as the SIP trunk that Ultravox places outbound AI voice calls through.
Use your **Unpod number as the SIP trunk** for **Ultravox** outbound calls. You gather
credentials from both dashboards, then Ultravox places the call through Unpod over SIP -
reaching regular phone numbers worldwide. Prefer raw requests? See the
[API guide](/telephony/integrations/ultravox/api).
**You need:** an Unpod number on a Bridge, and an Ultravox account with an **agent**
and an **API key**.
Treat your SIP username/password and API key as secrets - never paste them into shared
docs or screenshots.
## What you'll collect
| Service | Item | Where |
| -------- | ------------------------ | -------------------------------- |
| Ultravox | **API key** | Ultravox dashboard |
| Ultravox | **Agent ID** | Ultravox dashboard |
| Unpod | **SIP domain / address** | Number's trunk → Origin Endpoint |
| Unpod | **Username** | Number's trunk → Origin Endpoint |
| Unpod | **Password** | Number's trunk → Origin Endpoint |
| Unpod | **Phone number** | Telephony → Numbers |
## Part 1 - Get your Ultravox agent + API key
In the Ultravox console, open **Agents** and create an agent (set its system prompt
and voice). Copy its **Agent ID** - you'll pass it in the call request.
Generate an **API key** in the Ultravox console and copy it.
## Part 2 - Get your Unpod SIP credentials
In **Telephony**, select your number (shown as **Not Linked**) and click **Connect**
to set up its SIP trunk.
Open the trunk panel and under **Origin Endpoint Details** copy:
* **Address** - your Unpod SIP domain (e.g. `sip-lb1.unpod.tel`).
* **Username** and **Password** (under **Authentication**).
* Your **phone number** in E.164 (e.g. `+918071539111`).
## Part 3 - Place the call through Ultravox
Ultravox triggers the outbound call over your Unpod trunk. There's no dashboard button for
this - send one request to Ultravox's **Create Agent Call** endpoint with the SIP details
from Parts 1-2:
```bash cURL theme={null}
curl -X POST https://api.ultravox.ai/api/agents/YOUR_AGENT_ID/calls \
-H "X-API-Key: YOUR_ULTRAVOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"medium": {
"sip": {
"outgoing": {
"to": "sip:+919999999999@",
"from": "+918071539111",
"username": "",
"password": ""
}
}
},
"firstSpeakerSettings": { "user": {} }
}'
```
* **`to`** - destination number as a SIP URI at your Unpod domain.
* **`from`** - your Unpod number (caller ID).
* **`username` / `password`** - the Unpod origin-endpoint credentials.
The agent answers when the callee picks up. See the
[API guide](/telephony/integrations/ultravox/api) for the full request/response and debugging.
## Troubleshooting
| Symptom | Likely cause | Fix |
| ---------------- | ----------------------- | ----------------------------------------------------------- |
| SIP `403` | Auth rejected | Re-copy the Unpod **Username/Password** (case-sensitive) |
| SIP `407` | Proxy auth required | Confirm `username`/`password` are sent in `sip.outgoing` |
| SIP `480` | Destination unavailable | Verify the `to` number and that your Unpod number is active |
| Call never rings | Wrong SIP domain | Use the exact **Address** from the Unpod origin endpoint |
Full request/response + SIP log debugging.
Back to the integrations overview.
# Vapi (API)
Source: https://docs.unpod.ai/telephony/integrations/vapi/api
Set up the Unpod side of a Vapi integration over the REST API - create a SIP trunk to Vapi and attach your number.
The **programmatic** path for the **Unpod side**: create a SIP trunk pointed at Vapi and
attach your number. The trunk's **origin endpoint** is what you then register in Vapi as a
BYO SIP trunk - see the [Dashboard guide](/telephony/integrations/vapi/dashboard) for the
Vapi-side screens.
**Base URL:** `https://unpod.ai/api/v2/platform/`
Every request needs `Authorization: Token ` and an `Org-Handle` header.
See [Authentication](/api/get-started/authentication).
## Prerequisites
* An Unpod **API Token** and **Org-Handle**.
* A number in your org (we'll fetch its `id` below).
## 1. Find your number
List your org's numbers and note the `id` of the one you want to route.
```bash cURL theme={null}
curl https://unpod.ai/api/v2/platform/telephony/numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE"
```
```json Response (200) theme={null}
{
"status_code": 200,
"message": "Telephony numbers fetched successfully.",
"data": [
{ "id": 501, "number": "+15551234567", "state": "NOT_ASSIGNED", "active": true }
]
}
```
Full request/response schema.
## 2. Create the SIP trunk to Vapi
Point the trunk's `sip_url` at Vapi's SIP host. One trunk carries both inbound and outbound.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{
"name": "Vapi trunk",
"sip_url": "sip:sip.vapi.ai",
"transport": "tcp",
"port": "5060"
}'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Trunk created successfully.",
"data": { "id": 21, "name": "Vapi trunk", "sip_url": "sip:sip.vapi.ai", "transport": "tcp", "port": "5060", "active": true }
}
```
All request fields (`sip_url`, `auth_username`, `auth_password`, `transport`, `port`, `source_ips`).
## 3. Attach your number to the trunk
Map the number from step 1 (its `id`) to the trunk from step 2 (its `id`). The response
returns the **origin endpoint** - the address + creds you register in Vapi.
```bash cURL theme={null}
curl -X POST https://unpod.ai/api/v2/platform/telephony/trunks/21/attach-numbers/ \
-H "Authorization: Token $UNPOD_TOKEN" \
-H "Org-Handle: $ORG_HANDLE" \
-H "Content-Type: application/json" \
-d '{ "number_ids": [501] }'
```
```json Response (201) theme={null}
{
"status_code": 201,
"message": "Numbers mapped to trunk.",
"data": {
"trunk_id": 21,
"origin_endpoint": {
"ingress": "sip:sip-lb1.unpod.tel",
"dids": ["+15551234567"],
"accepted_source_ips": [],
"region": "ap-south"
}
}
}
```
The `origin_endpoint.ingress` (plus the trunk's `auth_username` / `auth_password`) is what
you enter in Vapi's BYO SIP trunk.
Path params, request body, and the full origin-endpoint response.
## Configure the Vapi side
The Unpod side is done. Register the origin endpoint in Vapi as a **BYO SIP trunk**, then
import the number. These are **Vapi's own APIs** (`api.vapi.ai`, `Authorization: Bearer `).
### 4. Create the BYO SIP trunk credential
```bash cURL theme={null}
curl -X POST https://api.vapi.ai/credential \
-H "Authorization: Bearer $VAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "byo-sip-trunk",
"name": "Unpod trunk",
"gateways": [{ "ip": "sip-lb1.unpod.tel", "inboundEnabled": true }],
"outboundAuthenticationPlan": {
"authUsername": "",
"authPassword": ""
}
}'
```
`gateways[].ip` = your Unpod `origin_endpoint.ingress` (host only, no `sip:`). Copy the
returned credential `id`.
### 5. Import the number
```bash cURL theme={null}
curl -X POST https://api.vapi.ai/phone-number \
-H "Authorization: Bearer $VAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "byo-phone-number",
"number": "+15551234567",
"credentialId": ""
}'
```
Then assign your assistant to the number (inbound).
Full BYO SIP trunk credential + phone-number reference (fields may change - this is Vapi's API).
## Troubleshooting
| Status | Meaning | Fix |
| ------------------- | ----------------- | --------------------------------------- |
| `400` on `/trunks/` | Missing `sip_url` | Send `sip:sip.vapi.ai` |
| `400` on attach | Number not in org | Use a valid `id` from **GET /numbers/** |
| `401` | Bad Unpod token | Verify `Authorization: Token …` |
| `403` | Wrong org | Verify the `Org-Handle` header |
# Vapi (Dashboard)
Source: https://docs.unpod.ai/telephony/integrations/vapi/dashboard
Connect Vapi to Unpod with no code - configure the provider and route a number straight from the Unpod Studio.
This is the **no-code** path. You create a SIP trunk on your Unpod number, copy its
origin-endpoint credentials into Vapi as a **BYO SIP trunk**, then point a Vapi
number and assistant at it. One trunk carries both inbound and outbound calls - no
separate provider setup. Prefer code? See the [API guide](/telephony/integrations/vapi/api).
**You need:** an Unpod number on a Bridge, and a Vapi account (Settings →
**Integrations** access). Calls flow **Unpod trunk ⇄ Vapi** over SIP - Unpod routes
outbound to `sip.vapi.ai`, and Vapi sends inbound to your Unpod endpoint.
## Part 1 - Create the trunk in Unpod
In **Telephony**, select your number and click **Configure**. On the **New trunk** tab,
set a **Name** you'll recognise and the **trunk origin endpoint**:
* **SIP URI / address** - `sip.vapi.ai` (Vapi's inbound SIP host).
* **Port** `5060` and **Transport** (`TCP` / `UDP` / `TLS`).
* **Allowed IPs / CIDR** - optional source allow-list.
Click **Test** to validate, then **Create Trunk**. This one trunk handles **both
inbound and outbound** calls for the number.
The number now shows **Linked · Vapi**. Under **Origin Endpoint Details** copy the
values - you'll paste them into Vapi next:
* **Address** - e.g. `sip-lb1.unpod.tel`
* **Port** / **Protocol** - `5060` / `UDP`
* **Username** and **Password** (under **Authentication**)
## Part 2 - Register the trunk in Vapi
In the Vapi dashboard go to **Settings → Integrations**, then open the **SIP Trunk**
provider under **Phone Number Providers**.
On the SIP Trunk page click **Configure New SIP Trunk**.
Give the trunk a **Name**, then under **Gateway #1** enter the Unpod values from Part 1:
* **IP Address / Domain** - your Unpod address (e.g. `sip-lb1.unpod.tel`).
* **Port** `5060`, **Outbound Protocol** `UDP`.
* Tick **Allow inbound calls** and **Allow outbound calls**.
* Under **Authentication**, paste the **Username** and **Password**.
## Part 3 - Attach a number and assistant
Under **Phone Numbers → Create Phone Number**, pick **BYO SIP Trunk Number**, enter the
phone number, and select your trunk under **SIP Trunk Credential**.
In **Inbound Settings**, confirm the **Inbound Phone Number** and open the **Assistant**
dropdown.
Choose the assistant that answers inbound calls. (Optionally route to a **Squad**,
**Workflow**, or a **Fallback Destination** instead.)
For outbound, go to **Outbound**, name the campaign, select your Unpod number, upload a
contacts **CSV**, pick an **Assistant**, then **Launch campaign**.
## Publish
Back in the Unpod Studio, click **Publish** to activate the configuration. The number
is then ready for inbound and outbound calls.
## Troubleshooting
| Symptom | Likely cause | Fix |
| ------------------------------- | ----------------------------- | -------------------------------------------------------------------------- |
| Trunk **Test** fails in Unpod | Wrong SIP URI / transport | Use `sip.vapi.ai`; try `UDP`/`TCP` to match Vapi |
| Vapi can't reach the trunk | Wrong gateway address or auth | Re-copy **Address** + **Username/Password** from the Unpod origin endpoint |
| Call connects, assistant silent | No assistant bound | Set the **Assistant** in the number's **Inbound Settings** |
| Inbound not arriving | Source IP blocked | Add Vapi's IPs to **Allowed IPs / CIDR** on the Unpod trunk |
| Number not reachable | Config not published | Click **Publish** in the Unpod Studio |
Same flow over the REST API.
Back to the integrations overview.
# WebSockets
Source: https://docs.unpod.ai/telephony/integrations/websockets
WebSockets integration for Unpod - raw audio streaming. Coming soon.
**Coming soon.** WebSockets isn't a one-click provider in Unpod yet. Here's the planned
model and what you can use today.
**WebSockets** - Raw audio streaming.
## How it will work
A documented **custom WebSocket** integration is on the roadmap for full control over raw audio framing.
📞
Call
Inbound / outbound
WS frames
🔗
Unpod trunk
Routing + failover
route
🔌
WebSockets agent
Raw audio
## Available today
The [WebSocket connectivity guide](/speech-stack/websocket) already covers streaming raw audio frames into the bridge today.
## Want it sooner?
Ping us on Discord and we'll prioritise WebSockets.
Vapi, LiveKit, Twilio and Daily are ready today.
# Introduction
Source: https://docs.unpod.ai/telephony/introduction
Connect phone numbers, SIP trunks, and calls to your Unpod Voice AI agents
The **Connectivity** APIs are the telephony control plane for Unpod. They let you bring
your own carrier (BYO-SIP), map phone numbers to that carrier, route numbers to your
Voice AI agents, and observe the per-number call lifecycle - all over a single REST surface.
**Base URL:** `https://unpod.ai/api/v2/platform/`
Every request needs an `Authorization: Token ` header and an `Org-Handle`
header. See [Authentication](/api/get-started/authentication).
## The three building blocks
Your phone numbers (DIDs). List the pool available to your org and attach a number
to an agent so inbound calls reach it.
A trunk is your SIP carrier credential. Create one, map numbers to it, and get back
the carrier ingress (the *origin endpoint*).
Observe the lifecycle: which numbers are linked, to which agent or carrier, and the
projection sync state - plus full call logs.
## How it fits together
```mermaid theme={null}
flowchart LR
A["📞
Inbound Call"] e1@==>|① SIP INVITE| B["📡
SIP Carrier"]
B e2@==>|② Route| C["🔗
Trunk
Origin Endpoint"]
C e3@==>|③ Match| D["📱
DID / Number
E.164"]
D e4@==>|④ Bridge| E["🤖
Voice AI Agent"]
e1@{ animate: true }
e2@{ animate: true }
e3@{ animate: true }
e4@{ animate: true }
classDef ep fill:#5a4fff,color:#fff,stroke:#3d34d9,stroke-width:3px;
classDef pp fill:#796cff,color:#fff,stroke:#5a4fff,stroke-width:2px;
class A,E ep;
class B,C,D pp;
linkStyle default stroke:#9a90ff,stroke-width:2.5px;
```
Caller dials your DID number → SIP INVITE hits your carrier
Carrier receives call → routes to your configured trunk endpoint
Trunk matches incoming DID → bridges to the assigned agent
Phone number (E.164 format) → maps to your Voice AI Agent
Agent answers → conversation begins
There is one primary termination path:
| Path | What it does | Endpoint |
| ----------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **Leg A - BYO carrier** | Map a number to your SIP trunk; the carrier sends inbound calls to your origin endpoint. | [Attach Numbers to Trunk](/telephony/trunks/attach-numbers) |
## Key concepts
* **DID/Number** - a phone number in E.164 format (e.g. `+15551234567`).
* **Trunk** - a SIP carrier credential (`sip_url`, `transport`, `port`, auth, source-IP allow-list). Secrets are always masked in responses.
* **Origin endpoint** - the shared SBC ingress (`sip:`) the carrier sends inbound calls to, plus the accepted source IPs and mapped DIDs.
* **Bridge** - the routing entity numbers attach onto. It is **auto-resolved and hidden** on the Connectivity surface - you never manage it directly here.
* **Partial success** - attach/detach operate on a list; each number reports `ok`/`error` independently.
Create a trunk, map a number, and confirm the lifecycle - step by step.
# List Numbers
Source: https://docs.unpod.ai/telephony/numbers/list-numbers
GET /api/v2/platform/telephony/numbers/
List the telephony numbers available to your organization
Returns the numbers available to you. Behavior depends on the `Org-Handle` header:
* **With Org-Handle** — returns your organization's own numbers (any state) plus the shared
unassigned pool (`NOT_ASSIGNED`).
* **Without Org-Handle** — returns only the shared unassigned pool.
Use a number's `id` when attaching it to a [trunk](/telephony/trunks/attach-numbers).
**Prerequisites:** API Token. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | No | Organization domain handle |
### Number object fields
| Field | Type | Description |
| ------ | ------- | ------------------------------------------- |
| id | integer | Unique number id (use this in attach calls) |
| number | string | Phone number in E.164 format |
| state | string | `NOT_ASSIGNED` or `ASSIGNED` |
| active | boolean | Whether the number is usable |
```json 200 theme={null}
{
"status_code": 200,
"message": "Telephony numbers fetched successfully.",
"data": [
{ "id": 501, "number": "+15551234567", "state": "NOT_ASSIGNED", "active": true }
]
}
```
```bash cURL theme={null}
curl -s "https://unpod.ai/api/v2/platform/telephony/numbers/" \
-H "Authorization: Token " \
-H "Org-Handle: "
```
# Quickstart
Source: https://docs.unpod.ai/telephony/quickstart
Bring your SIP carrier, map a number, and route a call in under 5 minutes
This walkthrough takes you from zero to a number mapped onto your own SIP carrier. You'll
need your **API Token** and **Org-Handle** - see [Authentication](/api/get-started/authentication).
Every request uses these two headers (writes also send `Content-Type: application/json`):
```bash theme={null}
export BASE="https://unpod.ai"
export AUTH="Authorization: Token "
export ORG="Org-Handle: "
```
Get your `Org-Handle` from [Get All Organizations](/api/space/organizations) - the `domain_handle` field.
List the unassigned numbers in your org's pool. Note an `id` to use later.
```bash theme={null}
curl -s "$BASE/api/v2/platform/telephony/numbers/" -H "$AUTH" -H "$ORG"
```
See [List Numbers](/telephony/numbers/list-numbers).
```bash theme={null}
curl -s -X POST "$BASE/api/v2/platform/telephony/trunks/" \
-H "$AUTH" -H "$ORG" -H "Content-Type: application/json" \
-d '{
"name": "My Carrier Trunk",
"sip_url": "sip:carrier.net",
"auth_username": "user",
"auth_password": "pass",
"transport": "tcp",
"port": "5060",
"source_ips": ["1.2.3.4", "5.6.7.0/24"]
}'
```
The response returns the new trunk `id` (e.g. `21`). Copy it. See [Create Trunk](/telephony/trunks/create-trunk).
Replace `21` with your trunk id and `501` with a number id from Step 2.
```bash theme={null}
curl -s -X POST "$BASE/api/v2/platform/telephony/trunks/21/attach-numbers/" \
-H "$AUTH" -H "$ORG" -H "Product-Id: unpod.dev" \
-H "Content-Type: application/json" \
-d '{"number_ids": [501]}'
```
The response includes the **origin endpoint** - the SBC ingress your carrier sends
inbound calls to, plus the accepted source IPs. See [Attach Numbers to Trunk](/telephony/trunks/attach-numbers).
```bash theme={null}
curl -s "$BASE/api/v2/platform/telephony/overview/" -H "$AUTH" -H "$ORG"
```
Each number shows `connection_state`, termination kind, agent link, and `sync_state`.
See [Telephony Overview](/telephony/calls/overview).
## What next
Step 1 - unmap numbers from the trunk, returning them to `NOT_ASSIGNED`.
Step 2 - remove the trunk and its credential once numbers are detached.
# Attach Numbers to Trunk
Source: https://docs.unpod.ai/telephony/trunks/attach-numbers
POST /api/v2/platform/telephony/trunks/{id}/attach-numbers/
Map one or more numbers to a SIP trunk and get the origin endpoint
Map one or more numbers to this trunk - the **Leg-A** (BYO carrier) path. The bridge is
auto-resolved and hidden. The response returns the trunk-level **origin endpoint**: the
shared SBC ingress your carrier sends inbound calls to, the mapped DIDs, and the accepted
source IPs.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------------------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
| Product-Id | string | No | Product scope for the auto-resolved bridge (default `unpod.dev`) |
| Content-Type | string | Yes | `application/json` |
### Path parameters
| Name | Type | Required | Description |
| ---- | ------- | -------- | ----------- |
| id | integer | Yes | Trunk id |
### Request body
| Field | Type | Required | Description |
| ------------ | ---------- | -------- | ------------------------------------------- |
| number\_ids | integer\[] | Yes | Numbers to map (deduped, order preserved) |
| bridge\_slug | string | No | Explicit bridge; auto-resolved when omitted |
| region | string | No | Region hint (e.g. `IN`) |
### Origin endpoint
| Field | Type | Description |
| --------------------- | --------- | ---------------------------------------- |
| ingress | string | SBC ingress URI your carrier dials in to |
| dids | string\[] | The numbers successfully mapped |
| accepted\_source\_ips | string\[] | The trunk's source-IP allow-list |
| region | string | Resolved bridge region |
### Partial success
`201` if at least one number maps, else `400`. Each `data.numbers` entry reports
`ok`/`error` independently.
```json 201 theme={null}
{
"status_code": 201,
"message": "Numbers mapped to trunk.",
"data": {
"trunk_id": 21,
"origin_endpoint": {
"ingress": "sip:sip.unpod.tel",
"dids": ["+15551234567"],
"accepted_source_ips": ["1.2.3.4", "5.6.7.0/24"],
"region": "us-east"
},
"numbers": [
{ "number_id": 501, "number": "+15551234567", "connection_state": "NOT_LINKED", "ok": true }
]
}
}
```
```json 400 theme={null}
{
"status_code": 400,
"message": "No numbers could be mapped.",
"data": {
"trunk_id": 21,
"origin_endpoint": { "ingress": "sip:sip.unpod.tel", "dids": [], "accepted_source_ips": ["1.2.3.4"], "region": null },
"numbers": [
{ "number_id": 501, "ok": false, "error": "Number not found or not available to this organization." }
]
}
}
```
```bash cURL theme={null}
curl -s -X POST "https://unpod.ai/api/v2/platform/telephony/trunks/21/attach-numbers/" \
-H "Authorization: Token " \
-H "Org-Handle: " \
-H "Product-Id: unpod.dev" \
-H "Content-Type: application/json" \
-d '{"number_ids": [501]}'
```
# Create Trunk
Source: https://docs.unpod.ai/telephony/trunks/create-trunk
POST /api/v2/platform/telephony/trunks/
Create a SIP trunk (carrier credential) for your organization
Create a SIP trunk - your carrier credential. Once created, [map numbers to it](/telephony/trunks/attach-numbers)
to receive inbound calls. The response masks `auth_password`.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
| Content-Type | string | Yes | `application/json` |
### Request body
| Field | Type | Required | Description |
| -------------- | --------- | -------- | ------------------------------------------- |
| name | string | Yes | Display name for the trunk |
| sip\_url | string | Yes | Carrier SIP URL / host |
| auth\_username | string | No | SIP auth username |
| auth\_password | string | No | SIP auth password (masked in responses) |
| transport | string | No | `tcp` (default), `udp`, or `tls` |
| port | string | No | SIP port (default `5060`) |
| source\_ips | string\[] | No | Carrier source-IP allow-list (CIDR allowed) |
```json 201 theme={null}
{
"status_code": 201,
"message": "Trunk created successfully.",
"data": {
"id": 21,
"name": "My Carrier Trunk",
"sip_url": "sip:carrier.net",
"transport": "tcp",
"port": "5060",
"auth_username": "user",
"auth_password": "pass",
"allowed_ips": "1.2.3.4,5.6.7.0/24",
"active": true,
"org_handle": "acme.co"
}
}
```
```json 400 theme={null}
{
"status_code": 400,
"message": "Invalid trunk payload",
"error": { "sip_url": ["This field is required."] }
}
```
```bash cURL theme={null}
curl -s -X POST "https://unpod.ai/api/v2/platform/telephony/trunks/" \
-H "Authorization: Token " \
-H "Org-Handle: " \
-H "Content-Type: application/json" \
-d '{
"name": "My Carrier Trunk",
"sip_url": "sip:carrier.net",
"auth_username": "user",
"auth_password": "pass",
"transport": "tcp",
"port": "5060",
"source_ips": ["1.2.3.4", "5.6.7.0/24"]
}'
```
Copy the returned `id` - you'll need it to attach numbers, fetch, or delete the trunk.
# Delete Trunk
Source: https://docs.unpod.ai/telephony/trunks/delete-trunk
DELETE /api/v2/platform/telephony/trunks/{id}/
Delete a SIP trunk and its number bindings
Delete a SIP trunk. This delegates to the credential teardown, which removes the trunk's
number bindings. Returns `204 No Content` on success.
Deleting a trunk removes its bindings. [Detach any mapped numbers](/telephony/trunks/detach-numbers)
first if you want them cleanly returned to `NOT_ASSIGNED` with deprovisioning.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
### Path parameters
| Name | Type | Required | Description |
| ---- | ------- | -------- | ----------- |
| id | integer | Yes | Trunk id |
```text 204 theme={null}
(No content)
```
```json 404 theme={null}
{ "status_code": 404, "message": "Trunk not found." }
```
```bash cURL theme={null}
curl -s -X DELETE "https://unpod.ai/api/v2/platform/telephony/trunks/21/" \
-H "Authorization: Token " \
-H "Org-Handle: "
```
# Detach Numbers from Trunk
Source: https://docs.unpod.ai/telephony/trunks/detach-numbers
POST /api/v2/platform/telephony/trunks/{id}/detach-numbers/
Unmap one or more numbers from a SIP trunk
Unmap one or more numbers from this trunk. Detaching deletes the number's bridge mapping,
fires deprovision, sets the number back to `NOT_ASSIGNED`, and releases channels.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
| Content-Type | string | Yes | `application/json` |
### Path parameters
| Name | Type | Required | Description |
| ---- | ------- | -------- | ----------- |
| id | integer | Yes | Trunk id |
### Request body
| Field | Type | Required | Description |
| ----------- | ---------- | -------- | ---------------- |
| number\_ids | integer\[] | Yes | Numbers to unmap |
### Partial success
Each `data.numbers` entry reports `ok`/`error`. A number that isn't mapped to this trunk
returns `ok: false` with `"Number is not mapped to this trunk."`
```json 200 theme={null}
{
"status_code": 200,
"message": "Numbers unmapped from trunk.",
"data": {
"trunk_id": 21,
"numbers": [
{ "number_id": 501, "ok": true }
]
}
}
```
```json 404 theme={null}
{ "status_code": 404, "message": "Trunk not found." }
```
```bash cURL theme={null}
curl -s -X POST "https://unpod.ai/api/v2/platform/telephony/trunks/21/detach-numbers/" \
-H "Authorization: Token " \
-H "Org-Handle: " \
-H "Content-Type: application/json" \
-d '{"number_ids": [501]}'
```
# Get Trunk
Source: https://docs.unpod.ai/telephony/trunks/get-trunk
GET /api/v2/platform/telephony/trunks/{id}/
Fetch a single SIP trunk by id
Fetch a single SIP trunk by its `id`. Only trunks owned by your organization are
returned - anything else is `404`. Secrets are masked.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
### Path parameters
| Name | Type | Required | Description |
| ---- | ------- | -------- | ----------- |
| id | integer | Yes | Trunk id |
```json 200 theme={null}
{
"status_code": 200,
"message": "Trunk fetched successfully.",
"data": {
"id": 12,
"name": "Acme Primary Trunk",
"sip_url": "sip:sip.acme-voice.com",
"transport": "tcp",
"port": "5060",
"auth_username": null,
"auth_password": null,
"allowed_ips": "",
"active": true,
"org_handle": "acme.co"
}
}
```
```json 404 theme={null}
{ "status_code": 404, "message": "Trunk not found." }
```
```bash cURL theme={null}
curl -s "https://unpod.ai/api/v2/platform/telephony/trunks/12/" \
-H "Authorization: Token " \
-H "Org-Handle: "
```
# List Trunks
Source: https://docs.unpod.ai/telephony/trunks/list-trunks
GET /api/v2/platform/telephony/trunks/
List your organization's SIP trunks
List the SIP trunks (carrier credentials) owned by your organization, newest first.
Secrets (`auth_password`) are masked in the response.
**Prerequisites:** API Token + Org-Handle. See [Authentication](/api/get-started/authentication).
### Headers
| Name | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------- |
| Authorization | string | Yes | `Token ` |
| Org-Handle | string | Yes | Organization domain handle |
### Trunk object fields
| Field | Type | Description |
| -------------- | ------- | ------------------------------------------ |
| id | integer | Trunk id (use in attach / detach / delete) |
| name | string | Display name |
| sip\_url | string | Carrier SIP URL / host |
| transport | string | `tcp`, `udp`, or `tls` |
| port | string | SIP port (default `5060`) |
| auth\_username | string | SIP auth username (nullable) |
| auth\_password | string | Masked - only the last 4 chars shown |
| allowed\_ips | string | Comma-separated source-IP allow-list |
| active | boolean | Whether the trunk is active |
| org\_handle | string | Owning organization handle |
```json 200 theme={null}
{
"status_code": 200,
"message": "Trunks fetched successfully.",
"data": [
{
"id": 12,
"name": "Acme Primary Trunk",
"sip_url": "sip:sip.acme-voice.com",
"transport": "tcp",
"port": "5060",
"auth_username": null,
"auth_password": null,
"allowed_ips": "",
"active": true,
"org_handle": "acme.co"
}
]
}
```
```bash cURL theme={null}
curl -s "https://unpod.ai/api/v2/platform/telephony/trunks/" \
-H "Authorization: Token " \
-H "Org-Handle: "
```