From dcaf662c18d0b075f33d2d1a4e1c4a87b535fe5d Mon Sep 17 00:00:00 2001
From: August
Date: Thu, 10 Sep 2026 13:12:51 +0000
Subject: [PATCH 01/12] docs(voice): add route and control calls guide
---
.../platform/pages/calling/voice/overview.mdx | 4 +-
.../calling/voice/route-and-control-calls.mdx | 399 ++++++++++++++++++
2 files changed, 401 insertions(+), 2 deletions(-)
create mode 100644 fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
diff --git a/fern/products/platform/pages/calling/voice/overview.mdx b/fern/products/platform/pages/calling/voice/overview.mdx
index 66eb307434..0ba443ad3c 100644
--- a/fern/products/platform/pages/calling/voice/overview.mdx
+++ b/fern/products/platform/pages/calling/voice/overview.mdx
@@ -18,8 +18,8 @@ Whether building a UCaaS solution, modernizing a legacy IVR, augmenting CX with
Build one agent, call it over a phone number, and send it a text turn through the AI Chat API
-
- The fundamentals of your first calling app
+
+ Write a call flow that answers, listens, and branches on what the caller says
Get started with our Compatibility API
diff --git a/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx b/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
new file mode 100644
index 0000000000..69b95961b2
--- /dev/null
+++ b/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
@@ -0,0 +1,399 @@
+---
+title: Route and control calls
+slug: /voice/route-and-control-calls
+description: Write a call flow that answers, asks the caller a question, and branches on what they say, then run it from the Dashboard and read the result in the call log.
+position: 1
+max-toc-depth: 3
+---
+
+[addresses]: /docs/platform/addresses
+[create-call]: /docs/apis/rest/calls/call-commands
+[phone-numbers]: /docs/platform/phone-numbers
+[resources]: /docs/platform/resources
+[server-sdks]: /docs/server-sdks
+[swml-prompt]: /docs/swml/reference/calling/prompt
+[swml-switch]: /docs/swml/reference/calling/switch
+[swml-transfer]: /docs/swml/reference/calling/transfer
+
+A call flow is a document that tells SignalWire what to do with a live call: answer it, play a
+message, listen for a reply, and decide what happens next. In this guide you write one, call it from
+the Dashboard, say something out loud, and hear the flow respond to what you said.
+
+The flow runs as SignalWire Markup Language (SWML). You can write that document by hand as JSON or
+YAML, or have the Server SDK generate it from Python. Both paths produce the same document, and
+SignalWire runs it the same way.
+
+Before you start, you need:
+
+- A [SignalWire account](https://signalwire.com/signup)
+- For the Server SDK path, Python 3 and [ngrok](https://ngrok.com/download) installed locally
+
+No phone number and no API token are required. The Dashboard can call a flow directly.
+
+---
+
+
+
+### Write a flow that answers and speaks
+
+Start with the smallest flow that does something audible. Choose one authoring path.
+
+
+
+
+
+ This guide uses Python. The [Server SDK documentation][server-sdks] covers the TypeScript SDK as
+ well.
+
+
+#### Install the Server SDK
+
+```bash
+python3 -m pip install signalwire-sdk
+```
+
+#### Write the flow
+
+Create `route_calls.py`:
+
+```python title="route_calls.py"
+from signalwire import SWMLService
+
+service = SWMLService(name="route-calls")
+
+service.add_verb("answer", {})
+service.add_verb("play", {"url": "say:Thanks for calling. Your flow is running."})
+service.add_verb("hangup", {})
+
+
+if __name__ == "__main__":
+ service.serve()
+```
+
+`SWMLService` builds a SWML document and serves it over HTTP. Each `add_verb` call appends one
+instruction to the document's `main` section, and `serve()` starts a web server on port `3000`
+that returns the document when SignalWire requests it.
+
+#### Give the flow a public URL
+
+In a second terminal, start ngrok:
+
+```bash
+ngrok http 3000
+```
+
+Copy the HTTPS forwarding URL, such as `https://abc123.ngrok-free.app`, and keep ngrok running.
+
+#### Start the flow with stable credentials
+
+The service protects its URL with Basic Auth. Set the credentials yourself so they survive a
+restart, then start the flow in the first terminal:
+
+```bash
+export SWML_BASIC_AUTH_USER="signalwire"
+export SWML_BASIC_AUTH_PASSWORD="replace-with-a-long-random-password"
+python3 route_calls.py
+```
+
+#### Verify the generated SWML
+
+From another terminal, request the document through the tunnel:
+
+```bash
+curl --fail \
+ --user "signalwire:replace-with-a-long-random-password" \
+ "https://abc123.ngrok-free.app/" \
+ | python3 -m json.tool
+```
+
+A working flow returns a JSON document with `"version": "1.0.0"` and a `sections.main` array
+holding `answer`, `play`, and `hangup`. A `401` response means the credentials in the request don't
+match the ones you exported before starting the flow.
+
+#### Create an External URL resource
+
+SignalWire needs a resource to call. Open the [SignalWire Dashboard](https://my.signalwire.com),
+select **Script**, and then select **External URL**. Set the Primary Script URL to the tunnel URL
+with the credentials embedded:
+
+```text
+https://signalwire:replace-with-a-long-random-password@abc123.ngrok-free.app/
+```
+
+Select **Create**. The resource appears in **Resources**.
+
+
+
+
+#### Create a SWML Script resource
+
+
+
+Give the script a name and paste this document into the editor:
+
+
+
+```yaml
+version: 1.0.0
+sections:
+ main:
+ - answer: {}
+ - play:
+ url: "say:Thanks for calling. Your flow is running."
+ - hangup: {}
+```
+
+
+```json
+{
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "answer": {} },
+ { "play": { "url": "say:Thanks for calling. Your flow is running." } },
+ { "hangup": {} }
+ ]
+ }
+}
+```
+
+
+
+Save the script. It appears in **Resources**.
+
+Execution starts at the `main` section, which every document must have. Each section is an
+ordered list of methods, and later in this guide you add more sections and jump between them.
+`answer` picks up the call, `play` speaks the text after the `say:` prefix, and `hangup` ends the
+call.
+
+
+
+
+### Call the flow
+
+Open the resource in **Resources** and use the Dashboard's click-to-call control to dial it.
+[NEEDS SOURCE: confirm the label and location of the click-to-call control on a resource in the
+new Dashboard.]
+
+You hear "Thanks for calling. Your flow is running." and the call ends.
+
+If the call connects but stays silent, the flow never ran. On the Server SDK path, repeat the
+`curl` request to confirm the tunnel still answers, then check that the External URL resource
+carries the same URL and credentials. On the SWML path, reopen the script and check that the
+document saved with a `main` section.
+
+To reach the same flow from a phone instead, assign one of your [phone numbers][phone-numbers] to
+the resource under **Inbound Call Settings** and dial it.
+
+### Listen and branch on what the caller says
+
+Now make the flow ask a question and route on the answer. Three methods do the work:
+
+- [`prompt`][swml-prompt] plays a question and waits for input. Setting `speech_hints` switches it
+ from keypad digits to speech and lists the words you expect to hear. When the caller
+ speaks, `prompt` stores the recognized text in the `prompt_value` variable and the outcome in
+ `prompt_result`.
+- [`switch`][swml-switch] compares a variable against a set of cases and runs the matching one.
+ The `default` case runs for anything else, including silence and unrecognized speech.
+- [`transfer`][swml-transfer] jumps to another section of the document and does not return.
+
+Replace the flow with this version. The `sales` and `support` sections each speak a different
+message, and `no_match` handles a caller who says nothing or something the flow doesn't expect.
+
+
+
+
+Replace `route_calls.py`:
+
+```python title="route_calls.py"
+from signalwire import SWMLService
+
+service = SWMLService(name="route-calls")
+
+service.add_verb("answer", {})
+service.add_verb(
+ "prompt",
+ {
+ "play": "say:Thanks for calling. Say sales or support.",
+ "speech_hints": ["sales", "support"],
+ },
+)
+service.add_verb(
+ "switch",
+ {
+ "variable": "prompt_value",
+ "case": {
+ "sales": [{"transfer": {"dest": "sales"}}],
+ "support": [{"transfer": {"dest": "support"}}],
+ },
+ "default": [{"transfer": {"dest": "no_match"}}],
+ },
+)
+
+service.add_verb_to_section("sales", "play", {"url": "say:You said sales."})
+service.add_verb_to_section("sales", "hangup", {})
+
+service.add_verb_to_section("support", "play", {"url": "say:You said support."})
+service.add_verb_to_section("support", "hangup", {})
+
+service.add_verb_to_section("no_match", "play", {"url": "say:Sorry, I didn't catch that. Goodbye."})
+service.add_verb_to_section("no_match", "hangup", {})
+
+
+if __name__ == "__main__":
+ service.serve()
+```
+
+`add_verb_to_section` creates the named section on first use and appends to it after that. The
+SDK validates every verb against the SWML schema as you add it, so a misspelled parameter raises
+an error at startup instead of failing on a live call.
+
+Restart the flow with the same credentials, then request the document again with `curl`. The
+`sections` object now holds `main`, `sales`, `support`, and `no_match`. The External URL resource
+already points at the tunnel, so there is nothing to change in the Dashboard.
+
+
+
+
+Open the script in **Resources** and replace its contents:
+
+
+
+```yaml
+version: 1.0.0
+sections:
+ main:
+ - answer: {}
+ - prompt:
+ play: "say:Thanks for calling. Say sales or support."
+ speech_hints:
+ - sales
+ - support
+ - switch:
+ variable: prompt_value
+ case:
+ sales:
+ - transfer:
+ dest: sales
+ support:
+ - transfer:
+ dest: support
+ default:
+ - transfer:
+ dest: no_match
+ sales:
+ - play:
+ url: "say:You said sales."
+ - hangup: {}
+ support:
+ - play:
+ url: "say:You said support."
+ - hangup: {}
+ no_match:
+ - play:
+ url: "say:Sorry, I didn't catch that. Goodbye."
+ - hangup: {}
+```
+
+
+```json
+{
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "answer": {} },
+ {
+ "prompt": {
+ "play": "say:Thanks for calling. Say sales or support.",
+ "speech_hints": ["sales", "support"]
+ }
+ },
+ {
+ "switch": {
+ "variable": "prompt_value",
+ "case": {
+ "sales": [{ "transfer": { "dest": "sales" } }],
+ "support": [{ "transfer": { "dest": "support" } }]
+ },
+ "default": [{ "transfer": { "dest": "no_match" } }]
+ }
+ }
+ ],
+ "sales": [
+ { "play": { "url": "say:You said sales." } },
+ { "hangup": {} }
+ ],
+ "support": [
+ { "play": { "url": "say:You said support." } },
+ { "hangup": {} }
+ ],
+ "no_match": [
+ { "play": { "url": "say:Sorry, I didn't catch that. Goodbye." } },
+ { "hangup": {} }
+ ]
+ }
+}
+```
+
+
+
+Save the script. The next call picks up the new document.
+
+
+
+
+### Call it three times
+
+Call the resource again and say "sales". The flow answers "You said sales." Call once more and say
+"support" to hear the other branch. On a third call, say nothing: after the input timeout the flow
+says "Sorry, I didn't catch that" and hangs up. The default wait for input is five seconds.
+
+If every call lands in `no_match`, the recognized text didn't equal a case key. `switch` compares
+the whole value exactly, so check the keys for capital letters and stray spaces. The next step
+shows you what the flow heard.
+
+### Read the call log
+
+Open **Logs** > **Calling** in the Dashboard and select the most recent call to see its details.
+[NEEDS SOURCE: confirm which details the new Dashboard's call log shows for a SWML call, in
+particular whether it lists the executed steps and the recognized speech.]
+
+To receive the recognized speech on your own server, add a `status_url` to the `prompt` method.
+SignalWire sends a `calling.call.collect` event to that URL when input arrives. The payload's
+`params.result.type` is `speech`, `no_input`, or `no_match`, and for speech the recognized text is
+included with its confidence score. The [`prompt` reference][swml-prompt] documents every field.
+
+
+
+## One flow, any channel
+
+The document you wrote doesn't know where the call came from. The Dashboard reached it through
+click-to-call. A [phone number][phone-numbers] assigned to the same resource runs the identical
+document, and so does a SIP address, a browser call to the resource's [address][addresses], or an
+outbound call you place with the [REST API][create-call]. Direction and channel are routing
+details on the [resource][resources]. The flow is the same program.
+
+## Next steps
+
+
+
+
+ Loop back to the question with `goto` and labels, call sections like functions with `execute`,
+ and see how `transfer` differs from both.
+
+
+
+ Accept keypad digits alongside speech, record the call, and connect each branch to a real
+ phone number.
+
+
+
+ Serve a different document per call, add routing callbacks, and secure the endpoint for
+ production.
+
+
+
+ Buy a number, assign it to the flow, and dial it from your own phone.
+
+
+
From 033434ad43137d1d40fdcab988712681a4b52a0d Mon Sep 17 00:00:00 2001
From: August
Date: Fri, 11 Sep 2026 15:17:08 +0000
Subject: [PATCH 02/12] docs(voice): source dashboard steps in route and
control calls guide
---
.../calling/voice/route-and-control-calls.mdx | 20 ++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
diff --git a/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx b/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
index 69b95961b2..74280aaed8 100644
--- a/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
+++ b/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
@@ -171,12 +171,15 @@ call.
### Call the flow
-Open the resource in **Resources** and use the Dashboard's click-to-call control to dial it.
-[NEEDS SOURCE: confirm the label and location of the click-to-call control on a resource in the
-new Dashboard.]
+Open the resource in **Resources** and select **Click-to-Test**, under the resource's name. A new
+tab opens, shows "Connecting", and places a browser call to the resource. Allow microphone access
+if the browser asks.
You hear "Thanks for calling. Your flow is running." and the call ends.
+**Click-to-Test** appears only on resources that handle calls. If it is disabled, the resource has
+no address to dial: open its **Addresses** and add an alias address.
+
If the call connects but stays silent, the flow never ran. On the Server SDK path, repeat the
`curl` request to confirm the tunnel still answers, then check that the External URL resource
carries the same URL and credentials. On the SWML path, reopen the script and check that the
@@ -354,11 +357,14 @@ shows you what the flow heard.
### Read the call log
-Open **Logs** > **Calling** in the Dashboard and select the most recent call to see its details.
-[NEEDS SOURCE: confirm which details the new Dashboard's call log shows for a SWML call, in
-particular whether it lists the executed steps and the recognized speech.]
+Open **Logs** > **Voice** in the Dashboard and select the most recent call. The call's timeline
+lists each step the flow ran in order: the call state changes, a **Play** entry for each message,
+and a **Collect** entry for the `prompt`. Select an entry to see its details. A flow that fails
+to load or contains an invalid method shows a **Script Warning** or **Error** entry with the
+reason, so a broken document is visible here without a support ticket.
-To receive the recognized speech on your own server, add a `status_url` to the `prompt` method.
+The timeline records that input was collected, not what the caller said. To receive the
+recognized speech itself, add a `status_url` to the `prompt` method.
SignalWire sends a `calling.call.collect` event to that URL when input arrives. The payload's
`params.result.type` is `speech`, `no_input`, or `no_match`, and for speech the recognized text is
included with its confidence score. The [`prompt` reference][swml-prompt] documents every field.
From 17ac85c64491283aa6d1a857bf4cfef92a479539 Mon Sep 17 00:00:00 2001
From: August
Date: Wed, 16 Sep 2026 16:03:28 +0000
Subject: [PATCH 03/12] docs(voice): rewrite route and control calls as the
call routing capability guide
---
fern/llms.txt | 1 +
.../pages/calling/voice/call-routing.mdx | 1195 +++++++++++++++++
.../platform/pages/calling/voice/overview.mdx | 4 +-
.../calling/voice/route-and-control-calls.mdx | 405 ------
4 files changed, 1198 insertions(+), 407 deletions(-)
create mode 100644 fern/products/platform/pages/calling/voice/call-routing.mdx
delete mode 100644 fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
diff --git a/fern/llms.txt b/fern/llms.txt
index 663001674e..978f009c03 100644
--- a/fern/llms.txt
+++ b/fern/llms.txt
@@ -159,6 +159,7 @@ Feature pages are grouped by channel. SDK pages come first when available, follo
### Calling
+- [Call routing](/docs/platform/voice/call-routing): Answer a call and speak from a flow you host or serve, then collect what the caller says, branch on it, handle silence, and read the result in the call log or your own code.
- [Call recording](/docs/swml/guides/record-calls): Record an ongoing call with a hosted SWML script and access the recording in your Space.
- [Call transfer](/docs/server-sdks/guides/call-transfer): Transfer a call to a phone number, SIP endpoint, or SWML document.
- [Real-time transcription](/docs/server-sdks/reference/python/relay/call/live-transcribe): Start or stop live transcription on a call.
diff --git a/fern/products/platform/pages/calling/voice/call-routing.mdx b/fern/products/platform/pages/calling/voice/call-routing.mdx
new file mode 100644
index 0000000000..5bc689fcd7
--- /dev/null
+++ b/fern/products/platform/pages/calling/voice/call-routing.mdx
@@ -0,0 +1,1195 @@
+---
+title: Call routing
+slug: /voice/call-routing
+description: Answer a call and speak from a flow you host or serve, then collect what the caller says, branch on it, handle silence, and read the result in the call log or your own code.
+position: 1
+max-toc-depth: 3
+---
+
+[addresses]: /docs/platform/addresses
+[api-credentials]: /docs/platform/your-signalwire-api-space
+[browser-sdk]: /docs/browser-sdk
+[cfb]: /docs/call-flow-builder
+[cfb-forward]: /docs/call-flow-builder/reference/forward-to-phone
+[cfb-gather]: /docs/call-flow-builder/reference/gather-input
+[cfb-hangup]: /docs/call-flow-builder/reference/hangup-call
+[cfb-play]: /docs/call-flow-builder/reference/play-audio-or-tts
+[cfb-version]: /docs/call-flow-builder/guides/version
+[control-flow]: /docs/swml/guides/goto-execute-transfer-disambiguation
+[create-call]: /docs/apis/rest/calls/call-commands
+[outbound]: /docs/platform/voice/outbound-calling
+[phone-numbers]: /docs/platform/phone-numbers
+[relay-client]: /docs/server-sdks/guides/relay-client
+[relay-collect]: /docs/server-sdks/reference/python/relay/call/play-and-collect
+[relay-connect]: /docs/server-sdks/reference/python/relay/call/connect
+[relay-events]: /docs/server-sdks/reference/python/relay/events
+[resources]: /docs/platform/resources
+[rest-calling]: /docs/apis/rest/calls/call-commands
+[server-sdks]: /docs/server-sdks
+[swml]: /docs/swml
+[swml-connect]: /docs/swml/reference/calling/connect
+[swml-goto]: /docs/swml/reference/calling/goto
+[swml-prompt]: /docs/swml/reference/calling/prompt
+[swml-switch]: /docs/swml/reference/calling/switch
+[swml-transfer]: /docs/swml/reference/calling/transfer
+[trial-mode]: /docs/platform/trial-mode
+
+Route a call by deciding what happens after SignalWire answers it: greet the caller, ask a
+question, and send the call down a branch based on the reply. The flow you write does not care
+whether the call arrived on a phone number, a SIP address, or a browser. Start by answering a call
+and speaking one line, then collect speech and branch on it, handle silence and unmatched replies,
+and track what the flow heard.
+
+## Pick the right product for call routing
+
+| Function | SWML | Relay | REST Calling API | Browser SDK | Call Flow Builder |
+|---|---|---|---|---|---|
+| Answer the call and speak without running a server | | | | | |
+| Collect speech or keypad digits and branch on the result | | | | | |
+| Run the routing logic in your own process, step by step, while the call is live | | | | | |
+| Command a call that is already in progress, by its call ID | | | | | |
+| Build the flow visually, with no code | | | | | |
+
+- [SWML][swml] is a JSON or YAML document that SignalWire runs step by step. Generate it with the
+ [Server SDKs][server-sdks] or write it by hand, then host it in your Space or serve it from your
+ own server.
+- [Relay][relay-client] is the WebSocket client in the Server SDKs. Your process receives the call
+ and issues each command as the call proceeds.
+- The [REST Calling API][rest-calling] sends the same commands over HTTP to a call you already hold
+ the ID for. Results come back as webhooks.
+- The [Browser SDK][browser-sdk] places and receives calls from a web page. It is the caller or the
+ callee, never the flow.
+- [Call Flow Builder][cfb] is a visual editor in the Dashboard. You connect nodes and SignalWire
+ hosts the result as a Call Flow resource.
+
+## Prepare for call routing
+
+Have these values ready:
+
+- Your Space URL, such as `.signalwire.com`.
+- For the Relay and REST Calling API paths, your Project ID and an API token from the Dashboard's
+ [API credentials][api-credentials] page, with the **Voice** permission enabled. A hosted SWML
+ script or a Call Flow needs no credentials.
+- For SWML served from your own server, Python 3 or Node.js and [ngrok](https://ngrok.com/download).
+- For the hand-off example, a phone number you can answer.
+
+
+A [trial project][trial-mode] can hand a call to a number it has purchased or verified, and cannot
+reach international numbers at all. Every other step in this guide works on a trial project.
+
+
+This guide receives calls. To place them, start with [Outbound calling][outbound].
+
+## Answer a call and speak
+
+The smallest flow that does something audible: answer, speak one line, hang up. You call it from
+the Dashboard, so no phone number is involved yet.
+
+
+
+### Set your credentials
+
+Replace these placeholders in the samples. The hosted SWML and Call Flow Builder paths use none of
+them.
+
+| Value | Replace with |
+|---|---|
+| `` | Your Space's subdomain in `.signalwire.com` |
+| `` | Your Project ID |
+| `` | Your API token |
+| `` | The ID of a call in progress, for the REST Calling API samples |
+| `` | A public HTTPS URL on your server that accepts POST requests |
+| `` | A phone number you can answer, in E.164 format |
+| `` | A number in your Space, or a verified caller ID |
+
+### Write the flow
+
+
+
+
+The document below answers the call, speaks, and hangs up. The Server SDK classes build the same
+document from code and serve it over HTTP; the YAML and JSON forms are what you paste into the
+Dashboard. `SWMLService` is the non-AI base of the Server SDK's agent classes and validates every
+verb against the SWML schema as you add it.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire import SWMLService
+
+service = SWMLService(name="call-routing")
+
+service.add_verb("answer", {})
+service.add_verb("play", {"url": "say:Thanks for calling Bayview Taxi. Your flow is running."})
+service.add_verb("hangup", {})
+
+if __name__ == "__main__":
+ service.serve()
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as call-routing.mjs,
+// then run: node call-routing.mjs
+import { SWMLService } from "@signalwire/sdk";
+
+const service = new SWMLService({ name: "call-routing" });
+
+service.addVerb("answer", {});
+service.addVerb("play", { url: "say:Thanks for calling Bayview Taxi. Your flow is running." });
+service.addVerb("hangup", {});
+
+await service.serve();
+```
+
+
+```yaml
+version: 1.0.0
+sections:
+ main:
+ - answer: {}
+ - play:
+ url: "say:Thanks for calling Bayview Taxi. Your flow is running."
+ - hangup: {}
+```
+
+
+```json
+{
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "answer": {} },
+ { "play": { "url": "say:Thanks for calling Bayview Taxi. Your flow is running." } },
+ { "hangup": {} }
+ ]
+ }
+}
+```
+
+
+
+Execution starts at the `main` section, which every document must have. Each section is an
+ordered list of methods. `answer` picks up the call, `play` speaks the text after the `say:`
+prefix, and `hangup` ends the call.
+
+
+
+
+A Relay client stays connected to SignalWire and receives every call routed to its context. The
+handler answers, speaks, and hangs up when playback completes.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+
+@client.on_call
+async def handle_call(call):
+ await call.answer()
+
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play(
+ [{"type": "tts", "params": {"text": "Thanks for calling Bayview Taxi. Your flow is running."}}],
+ on_completed=hang_up_after_playback,
+ )
+ await call.wait_for_ended()
+
+
+client.run()
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as call-routing.mjs,
+// then run: node call-routing.mjs
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+client.onCall(async (call) => {
+ await call.answer();
+ await call.play([
+ { type: "tts", text: "Thanks for calling Bayview Taxi. Your flow is running." },
+ ], {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ });
+ await call.waitForEnded();
+});
+
+await client.run();
+```
+
+
+
+`contexts` names the topic the client listens on. The Relay Application resource you create in the
+next step uses the same name, so SignalWire knows which running client gets the call. `run()`
+blocks until you stop the process, reconnecting if the connection drops.
+
+
+
+
+Open **Tools** > **Call Flow Builder** in the Dashboard, select **Add New**, name the flow, and
+select **Save**. Open the flow's **More Options** menu and select **Edit** to reach the canvas.
+
+Every flow starts from the **Handle Call** node. Connect these nodes in order:
+
+1. [**Answer Call**](/docs/call-flow-builder/reference/answer-call).
+2. [**Play Audio or TTS**][cfb-play], with the text
+ "Thanks for calling Bayview Taxi. Your flow is running." in its Text to Speech setting.
+3. [**Hangup Call**][cfb-hangup].
+
+Select **Deploy** to make this version the live one. The [versioning guide][cfb-version] explains
+how deployed and draft versions relate.
+
+
+
+
+### Give the flow a resource
+
+SignalWire routes calls to [resources][resources]. This step makes your flow one, and it is the
+only place in this guide where delivery differs by surface. Every later task changes the flow's
+contents, not how it reaches the call.
+
+
+
+
+Choose where the document lives.
+
+#### Host it in your Space
+
+
+
+Name the script, paste the YAML or JSON form of the document, and save it. It appears in
+**Resources** as a SWML Script. Return here to replace its contents in each later task.
+
+#### Serve it from your server
+
+Start the flow from the Server SDK sample, with credentials you choose so the URL survives a
+restart:
+
+```bash
+export SWML_BASIC_AUTH_USER="signalwire"
+export SWML_BASIC_AUTH_PASSWORD="replace-with-a-long-random-password"
+python call_routing.py
+```
+
+In a second terminal, open a tunnel to port 3000 and keep it running:
+
+```bash
+ngrok http 3000
+```
+
+Confirm the document is reachable through the tunnel:
+
+```bash
+curl --fail \
+ --user "signalwire:replace-with-a-long-random-password" \
+ "https://abc123.ngrok-free.app/" \
+ | python -m json.tool
+```
+
+A working flow returns a JSON document with `"version": "1.0.0"` and a `sections.main` array. A
+`401` response means the credentials in the request differ from the ones you exported before
+starting the flow.
+
+Then create the resource. Open the [SignalWire Dashboard](https://my.signalwire.com), select
+**Script**, and then **External URL**. Set the Primary Script URL to the tunnel URL with the
+credentials embedded, and select **Create**:
+
+```text
+https://signalwire:replace-with-a-long-random-password@abc123.ngrok-free.app/
+```
+
+The resource appears in **Resources**. Later tasks change `call_routing.py` and restart it; the
+resource keeps pointing at the tunnel.
+
+
+
+
+Start the client and leave it running:
+
+```bash
+python call_routing.py
+```
+
+In the Dashboard, open **Resources**, select **+ Add New**, and choose **Relay Application**. Set
+**Name** to anything you like and **Topic** to `default`, the value in the client's `contexts`,
+then save. Calls to this resource reach whichever connected client listens on that topic.
+
+
+
+
+A deployed Call Flow is already a resource. Open **Resources** and find it under **Call Flows**.
+
+
+
+
+### Call it and listen
+
+Open the resource in **Resources** and select **Click-to-Test**, under the resource's name. A new
+tab opens, shows "Connecting", and places a browser call to the resource. Allow microphone access
+if the browser asks.
+
+You hear "Thanks for calling Bayview Taxi. Your flow is running." and the call ends.
+
+If the call connects but stays silent, the flow never ran. For a served SWML document, repeat the
+`curl` request to confirm the tunnel still answers, then check that the External URL resource
+carries the same URL and credentials. For a hosted script, reopen it and confirm the document
+saved with a `main` section. For Relay, check that the client process is still running and that
+the resource's **Topic** matches its `contexts`. **Click-to-Test** appears only on resources that
+handle calls; if it is disabled, the resource has no address, so open its **Addresses** and add an
+alias.
+
+
+
+## Collect speech and branch on it
+
+The flow now asks a question and routes on the answer. In every surface the pattern is the same:
+play a prompt, wait for input, compare the result, and continue down one path. Bayview Taxi asks
+callers to say "booking" or "dispatch". Speech hints tell the recognizer which words to expect,
+which makes short single-word replies reliable.
+
+### Collect speech and branch via SWML
+
+Three methods do the work. [`prompt`][swml-prompt] plays the question and waits. Setting any
+speech parameter, such as `speech_hints`, switches it from keypad digits to speech; the recognized
+text lands in the `prompt_value` variable and the outcome in `prompt_result`.
+[`switch`][swml-switch] compares a variable against case keys and runs the matching list, or
+`default` when nothing matches. [`transfer`][swml-transfer] jumps to another section and does not
+return.
+
+Replace the flow with this version. The `booking` and `dispatch` sections each speak a different
+line, and `no_match` catches everything else.
+
+
+
+```python {7-25}
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire import SWMLService
+
+service = SWMLService(name="call-routing")
+
+service.add_verb("answer", {})
+service.add_verb(
+ "prompt",
+ {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ "speech_hints": ["booking", "dispatch"],
+ },
+)
+service.add_verb(
+ "switch",
+ {
+ "variable": "prompt_value",
+ "case": {
+ "booking": [{"transfer": {"dest": "booking"}}],
+ "dispatch": [{"transfer": {"dest": "dispatch"}}],
+ },
+ "default": [{"transfer": {"dest": "no_match"}}],
+ },
+)
+
+service.add_verb_to_section("booking", "play", {"url": "say:You said booking."})
+service.add_verb_to_section("booking", "hangup", {})
+
+service.add_verb_to_section("dispatch", "play", {"url": "say:You said dispatch."})
+service.add_verb_to_section("dispatch", "hangup", {})
+
+service.add_verb_to_section("no_match", "play", {"url": "say:Sorry, I didn't catch that. Goodbye."})
+service.add_verb_to_section("no_match", "hangup", {})
+
+if __name__ == "__main__":
+ service.serve()
+```
+
+
+```typescript {8-20}
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as call-routing.mjs,
+// then run: node call-routing.mjs
+import { SWMLService } from "@signalwire/sdk";
+
+const service = new SWMLService({ name: "call-routing" });
+
+service.addVerb("answer", {});
+service.addVerb("prompt", {
+ play: "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ speech_hints: ["booking", "dispatch"],
+});
+service.addVerb("switch", {
+ variable: "prompt_value",
+ case: {
+ booking: [{ transfer: { dest: "booking" } }],
+ dispatch: [{ transfer: { dest: "dispatch" } }],
+ },
+ default: [{ transfer: { dest: "no_match" } }],
+});
+
+service.addVerbToSection("booking", "play", { url: "say:You said booking." });
+service.addVerbToSection("booking", "hangup", {});
+
+service.addVerbToSection("dispatch", "play", { url: "say:You said dispatch." });
+service.addVerbToSection("dispatch", "hangup", {});
+
+service.addVerbToSection("no_match", "play", { url: "say:Sorry, I didn't catch that. Goodbye." });
+service.addVerbToSection("no_match", "hangup", {});
+
+await service.serve();
+```
+
+
+```yaml {5-19}
+version: 1.0.0
+sections:
+ main:
+ - answer: {}
+ - prompt:
+ play: "say:Thanks for calling Bayview Taxi. Say booking or dispatch."
+ speech_hints:
+ - booking
+ - dispatch
+ - switch:
+ variable: prompt_value
+ case:
+ booking:
+ - transfer:
+ dest: booking
+ dispatch:
+ - transfer:
+ dest: dispatch
+ default:
+ - transfer:
+ dest: no_match
+ booking:
+ - play:
+ url: "say:You said booking."
+ - hangup: {}
+ dispatch:
+ - play:
+ url: "say:You said dispatch."
+ - hangup: {}
+ no_match:
+ - play:
+ url: "say:Sorry, I didn't catch that. Goodbye."
+ - hangup: {}
+```
+
+
+```json {6-20}
+{
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "answer": {} },
+ {
+ "prompt": {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ "speech_hints": ["booking", "dispatch"]
+ }
+ },
+ {
+ "switch": {
+ "variable": "prompt_value",
+ "case": {
+ "booking": [{ "transfer": { "dest": "booking" } }],
+ "dispatch": [{ "transfer": { "dest": "dispatch" } }]
+ },
+ "default": [{ "transfer": { "dest": "no_match" } }]
+ }
+ }
+ ],
+ "booking": [
+ { "play": { "url": "say:You said booking." } },
+ { "hangup": {} }
+ ],
+ "dispatch": [
+ { "play": { "url": "say:You said dispatch." } },
+ { "hangup": {} }
+ ],
+ "no_match": [
+ { "play": { "url": "say:Sorry, I didn't catch that. Goodbye." } },
+ { "hangup": {} }
+ ]
+ }
+}
+```
+
+
+
+`add_verb_to_section` creates a section on first use and appends to it after that. `switch`
+compares the whole value exactly, so a case key must match the recognized text character for
+character. SWML also has a `cond` method that branches on a JavaScript expression, but the Server
+SDKs cannot emit it yet, so this guide uses `switch` throughout.
+
+Restart the served flow, or paste the new document into the hosted script, then call the resource
+three times: say "booking", say "dispatch", and say nothing. You hear a different line each time.
+If every call ends in `no_match`, the recognized text did not equal a case key; the tracking
+section below shows what the flow heard.
+
+### Collect speech and branch via Relay
+
+[`play_and_collect`][relay-collect] plays the prompt and resolves when input arrives, times out,
+or fails. The result carries a `type` of `speech`, `digit`, `no_input`, or `no_match`, and for
+speech the recognized `text` with a `confidence` score. Your code compares the text and continues.
+
+Over the REST Calling API the same collect command runs against a call you already hold the ID
+for, so the request needs the call to exist first, for example a call your Relay client or a hosted
+script answered. The result arrives at `status_url` as a `calling.call.collect` event instead of a
+return value.
+
+
+
+```python {14-36}
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+
+@client.on_call
+async def handle_call(call):
+ await call.answer()
+
+ action = await call.play_and_collect(
+ media=[{"type": "tts", "params": {"text": "Thanks for calling Bayview Taxi. Say booking or dispatch."}}],
+ collect={"speech": {"hints": ["booking", "dispatch"], "language": "en-US"}},
+ )
+ event = await action.wait()
+ result = event.params.get("result", {})
+
+ heard = ""
+ if result.get("type") == "speech":
+ heard = result.get("params", {}).get("text", "").lower()
+
+ if "booking" in heard:
+ reply = "You said booking."
+ elif "dispatch" in heard:
+ reply = "You said dispatch."
+ else:
+ reply = "Sorry, I didn't catch that. Goodbye."
+
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play(
+ [{"type": "tts", "params": {"text": reply}}],
+ on_completed=hang_up_after_playback,
+ )
+ await call.wait_for_ended()
+
+
+client.run()
+```
+
+
+```typescript {13-32}
+// Install: npm install @signalwire/sdk@2.0.5
+// Save as call-routing.mts and run: npx tsx call-routing.mts
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+client.onCall(async (call) => {
+ await call.answer();
+
+ const action = await call.playAndCollect(
+ [{ type: "tts", text: "Thanks for calling Bayview Taxi. Say booking or dispatch." }],
+ { speech: { hints: ["booking", "dispatch"], language: "en-US" } },
+ );
+ const event = await action.wait();
+ const result = (event.params.result ?? {}) as {
+ type?: string;
+ params?: { text?: string };
+ };
+
+ const heard = result.type === "speech" ? (result.params?.text ?? "").toLowerCase() : "";
+
+ let reply = "Sorry, I didn't catch that. Goodbye.";
+ if (heard.includes("booking")) reply = "You said booking.";
+ else if (heard.includes("dispatch")) reply = "You said dispatch.";
+
+ await call.play([{ type: "tts", text: reply }], {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ });
+ await call.waitForEnded();
+});
+
+await client.run();
+```
+
+
+```bash
+# Runs against a call that is already answered; play the question first with calling.play.
+curl --request POST "https://.signalwire.com/api/calling/calls" \
+ --user ":" \
+ --header "Content-Type: application/json" \
+ --data '{
+ "id": "",
+ "command": "calling.collect",
+ "params": {
+ "control_id": "route-1",
+ "speech": { "hints": ["booking", "dispatch"], "language": "en-US" },
+ "status_url": ""
+ }
+ }'
+```
+
+
+
+Because the comparison runs in your code, you can match loosely: `"booking" in heard` accepts
+"booking please" as well as "booking". Restart the client and call the resource three times to hear
+the three outcomes. If the client logs an authentication error at startup, the project, token, and
+Space do not belong together.
+
+### Collect speech and branch via Call Flow Builder
+
+Replace the **Play Audio or TTS** node with a [**Gather Input**][cfb-gather] node. Set its Text to
+Speech to "Thanks for calling Bayview Taxi. Say booking or dispatch.", choose speech input, and add
+two input options, `booking` and `dispatch`. The node grows one output connector per option, plus
+**Unknown** and **No Input**.
+
+Connect each option to its own **Play Audio or TTS** node with the matching line, and each of
+those to a **Hangup Call** node. Leave **Unknown** and **No Input** for the next task. Select
+**Deploy**, then call the resource three times.
+
+## Handle silence and unmatched speech
+
+A caller who says nothing, or says something the flow did not plan for, must still get an answer.
+Each surface reports the two cases separately, so the flow can say something different for each.
+
+### Handle silence and unmatched speech via SWML
+
+After `prompt`, `prompt_result` is `match_speech`, `no_input`, or `no_match`. The `default` case
+already routes both failure results to `no_match`; this change makes that section say which one
+happened, using a JavaScript expression inside the spoken text. Only the `no_match` section
+changes.
+
+
+
+```python {1-6}
+service.add_verb_to_section(
+ "no_match",
+ "play",
+ {"url": "say:${prompt_result == 'no_input' ? \"I didn't hear anything.\" : \"I didn't understand that.\"} Goodbye."},
+)
+service.add_verb_to_section("no_match", "hangup", {})
+```
+
+
+```typescript {1-4}
+service.addVerbToSection("no_match", "play", {
+ url: "say:${prompt_result == 'no_input' ? \"I didn't hear anything.\" : \"I didn't understand that.\"} Goodbye.",
+});
+service.addVerbToSection("no_match", "hangup", {});
+```
+
+
+```yaml {3}
+ no_match:
+ - play:
+ url: "say:${prompt_result == 'no_input' ? \"I didn't hear anything.\" : \"I didn't understand that.\"} Goodbye."
+ - hangup: {}
+```
+
+
+```json {2}
+ "no_match": [
+ { "play": { "url": "say:${prompt_result == 'no_input' ? \"I didn't hear anything.\" : \"I didn't understand that.\"} Goodbye." } },
+ { "hangup": {} }
+ ]
+```
+
+
+
+The wait for input before `no_input` is five seconds by default; `prompt.initial_timeout` changes
+it.
+
+### Handle silence and unmatched speech via Relay
+
+The collect result's `type` is `no_input` when the caller stayed silent and `no_match` when speech
+arrived but matched nothing. Replace the reply selection in the Relay sample:
+
+
+
+```python {1-8}
+ if "booking" in heard:
+ reply = "You said booking."
+ elif "dispatch" in heard:
+ reply = "You said dispatch."
+ elif result.get("type") == "no_input":
+ reply = "I didn't hear anything. Goodbye."
+ else:
+ reply = "I didn't understand that. Goodbye."
+```
+
+
+```typescript {1-4}
+ let reply = "I didn't understand that. Goodbye.";
+ if (heard.includes("booking")) reply = "You said booking.";
+ else if (heard.includes("dispatch")) reply = "You said dispatch.";
+ else if (result.type === "no_input") reply = "I didn't hear anything. Goodbye.";
+```
+
+
+
+### Handle silence and unmatched speech via Call Flow Builder
+
+Connect the **Gather Input** node's **No Input** connector to a **Play Audio or TTS** node that
+says "I didn't hear anything. Goodbye.", and its **Unknown** connector to one that says "I didn't
+understand that. Goodbye." Connect both to a **Hangup Call** node and select **Deploy**.
+
+## Track what the flow heard
+
+Three places show what happened on the call: the Dashboard's call log, a webhook you host, and the
+Relay client's own event stream.
+
+### See the flow's steps in the Dashboard
+
+Open **Logs** > **Voice** in the Dashboard and select the most recent call. The call's timeline
+lists each step the flow ran in order: the call state changes, a **Play** entry for each spoken
+line, and a **Collect** entry for the prompt. Select an entry to see its details. A document that
+fails to load or contains an invalid method shows a **Script Warning** or **Error** entry with the
+reason, so a broken flow is visible here without a support ticket.
+
+The timeline records that input was collected, not what the caller said. For the recognized text
+itself, use one of the two paths below.
+
+### Receive the result via SWML
+
+Add a `status_url` to the `prompt` method. SignalWire sends a `calling.call.collect` event to that
+URL when input arrives. The same event reaches the `status_url` on a REST Calling API `calling.collect`
+command.
+
+
+
+```yaml {4}
+ - prompt:
+ play: "say:Thanks for calling Bayview Taxi. Say booking or dispatch."
+ speech_hints: [booking, dispatch]
+ status_url: ""
+```
+
+
+```json {5}
+ {
+ "prompt": {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ "speech_hints": ["booking", "dispatch"],
+ "status_url": ""
+ }
+ }
+```
+
+
+
+The POST body for a recognized reply looks like this. `params.result.type` is `no_input` or
+`no_match` for the failure cases, with no `params.result.params`.
+
+```json
+{
+ "event_type": "calling.call.collect",
+ "event_channel": "signalwire_calling_...",
+ "timestamp": 1757971200.123,
+ "project_id": "",
+ "space_id": "...",
+ "params": {
+ "call_id": "",
+ "node_id": "...",
+ "control_id": "...",
+ "result": {
+ "type": "speech",
+ "params": {
+ "text": "dispatch",
+ "confidence": 0.94
+ }
+ }
+ }
+}
+```
+
+The [`prompt` reference][swml-prompt] documents every field.
+
+### Receive the result via Relay
+
+The Relay client already holds the result: it is the event that `action.wait()` returned. To log
+every collect event as it happens, including partial ones, register a handler on the call before
+starting the prompt. The [Relay events reference][relay-events] lists the payload fields.
+
+
+
+```python {1-4}
+ def log_collect(event):
+ print("collect:", event.params.get("result"))
+
+ call.on("calling.call.collect", log_collect)
+```
+
+
+```typescript {1-3}
+ call.on("calling.call.collect", (event) => {
+ console.log("collect:", event.params.result);
+ });
+```
+
+
+
+### Compare the surfaces
+
+| Surface | Where the recognized text shows up | How silence and no match are reported |
+|---|---|---|
+| SWML | `prompt_value` inside the document; the `status_url` webhook | `prompt_result` is `no_input` or `no_match` |
+| Relay | The `result` on the event `action.wait()` returns; `call.on` handlers | `result.type` is `no_input` or `no_match` |
+| REST Calling API | The `status_url` webhook only | `params.result.type` is `no_input` or `no_match` |
+| Call Flow Builder | Not exposed; the flow branches on the matched option | The **No Input** and **Unknown** connectors |
+
+The Dashboard's call log shows the Collect step and its outcome for every surface, but never the
+text.
+
+## Reach the same flow from any channel
+
+The flow you wrote does not know where the call came from. The Dashboard reached it through
+**Click-to-Test**. A [phone number][phone-numbers] assigned to the same resource runs the identical
+flow, and so does a SIP address, a browser call to the resource's [address][addresses], or an
+outbound call you place with the [REST Calling API][create-call] and point at the resource.
+Direction and channel are routing details on the resource; the flow is the same program. That is
+also why delivery was a single step above: once a resource exists, every task in this guide edits
+the flow and leaves the routing alone.
+
+## Examples
+
+### Accept a keypad digit as well as speech
+
+Some callers are in a noisy place or on a handset with no microphone to speak of. Offer keys and
+words at once, and treat "press 1" and "say booking" as the same answer.
+
+#### Accept a keypad digit as well as speech via SWML
+
+Setting one digit parameter alongside the speech parameters enables both inputs. Add `max_digits`
+to the prompt and the digit keys to the switch.
+
+
+
+```yaml {3,5,9-11}
+ - prompt:
+ play: "say:Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2."
+ max_digits: 1
+ speech_hints: [booking, dispatch]
+ - switch:
+ variable: prompt_value
+ case:
+ booking: [{ transfer: { dest: booking } }]
+ "1": [{ transfer: { dest: booking } }]
+ dispatch: [{ transfer: { dest: dispatch } }]
+ "2": [{ transfer: { dest: dispatch } }]
+ default: [{ transfer: { dest: no_match } }]
+```
+
+
+```json {4,12-13}
+ {
+ "prompt": {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2.",
+ "max_digits": 1,
+ "speech_hints": ["booking", "dispatch"]
+ }
+ },
+ {
+ "switch": {
+ "variable": "prompt_value",
+ "case": {
+ "booking": [{ "transfer": { "dest": "booking" } }],
+ "1": [{ "transfer": { "dest": "booking" } }],
+ "dispatch": [{ "transfer": { "dest": "dispatch" } }],
+ "2": [{ "transfer": { "dest": "dispatch" } }]
+ },
+ "default": [{ "transfer": { "dest": "no_match" } }]
+ }
+ }
+```
+
+
+
+In the Server SDK samples, add `"max_digits": 1` to the prompt config and the `"1"` and `"2"` keys
+to the `case` object.
+
+#### Accept a keypad digit as well as speech via Relay
+
+Pass both `digits` and `speech` in the collect config, then read `params.digits` when the result
+type is `digit`.
+
+
+
+```python {3,9-10}
+ action = await call.play_and_collect(
+ media=[{"type": "tts", "params": {"text": "Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2."}}],
+ collect={"digits": {"max": 1}, "speech": {"hints": ["booking", "dispatch"], "language": "en-US"}},
+ )
+ event = await action.wait()
+ result = event.params.get("result", {})
+
+ heard = ""
+ if result.get("type") == "digit":
+ heard = {"1": "booking", "2": "dispatch"}.get(result.get("params", {}).get("digits", ""), "")
+ elif result.get("type") == "speech":
+ heard = result.get("params", {}).get("text", "").lower()
+```
+
+
+```typescript {3,10-12}
+ const action = await call.playAndCollect(
+ [{ type: "tts", text: "Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2." }],
+ { digits: { max: 1 }, speech: { hints: ["booking", "dispatch"], language: "en-US" } },
+ );
+ const event = await action.wait();
+ const result = (event.params.result ?? {}) as {
+ type?: string;
+ params?: { text?: string; digits?: string };
+ };
+ const byKey: Record = { "1": "booking", "2": "dispatch" };
+ let heard = "";
+ if (result.type === "digit") heard = byKey[result.params?.digits ?? ""] ?? "";
+ else if (result.type === "speech") heard = (result.params?.text ?? "").toLowerCase();
+```
+
+
+
+#### Accept a keypad digit as well as speech via Call Flow Builder
+
+The **Gather Input** node accepts keypad digits and speech together. Add `1` and `2` as further
+input options and connect each to the same node as its spoken twin.
+
+### Hand the call to a phone
+
+Once the flow knows what the caller wants, connect them to a person. Bayview Taxi sends dispatch
+callers to Ada's desk phone. The caller hears ringing, and the flow resumes only if the connection
+fails or the other side hangs up first.
+
+
+On a [trial project][trial-mode], `` must be a number the project has purchased
+or verified.
+
+
+#### Hand the call to a phone via SWML
+
+Replace the spoken line in the `dispatch` section with [`connect`][swml-connect].
+
+
+
+```yaml {4-5}
+ dispatch:
+ - play:
+ url: "say:Connecting you to dispatch."
+ - connect:
+ to: ""
+ - hangup: {}
+```
+
+
+```json {3}
+ "dispatch": [
+ { "play": { "url": "say:Connecting you to dispatch." } },
+ { "connect": { "to": "" } },
+ { "hangup": {} }
+ ]
+```
+
+
+
+In the Server SDK samples, add `service.add_verb_to_section("dispatch", "connect", {"to": ""})`
+between the play and hangup lines, or the `addVerbToSection` equivalent.
+
+#### Hand the call to a phone via Relay
+
+[`connect`][relay-connect] takes a list of device groups; each inner list rings at once and the
+outer list rings in turn. Replace the dispatch reply with a bridge to one phone.
+
+
+
+```python {1-9}
+ if "dispatch" in heard:
+ await call.play([{"type": "tts", "params": {"text": "Connecting you to dispatch."}}])
+ await call.connect([[{
+ "type": "phone",
+ "params": {"to_number": "", "from_number": "", "timeout": 30},
+ }]])
+ await call.wait_for_ended()
+ return
+```
+
+
+```typescript {1-9}
+ if (heard.includes("dispatch")) {
+ await call.play([{ type: "tts", text: "Connecting you to dispatch." }]);
+ await call.connect([[{
+ type: "phone",
+ params: { to_number: "", from_number: "", timeout: 30 },
+ }]]);
+ await call.waitForEnded();
+ return;
+ }
+```
+
+
+
+#### Hand the call to a phone via Call Flow Builder
+
+Connect the `dispatch` option to a [**Forward to Phone**][cfb-forward] node with
+`` as its number, then select **Deploy**.
+
+### Ask again when the caller says nothing
+
+One retry turns a dead end into a second chance. Ask the question again after silence, then give
+up.
+
+#### Ask again when the caller says nothing via SWML
+
+[`goto`][swml-goto] jumps to a label within the same section, and its `when` condition and `max`
+count keep the loop bounded. Place a label before the prompt and a conditional `goto` after it.
+
+
+
+```yaml {2,6-9}
+ main:
+ - answer: {}
+ - label: ask
+ - prompt:
+ play: "say:Thanks for calling Bayview Taxi. Say booking or dispatch."
+ speech_hints: [booking, dispatch]
+ - goto:
+ label: ask
+ when: "prompt_result == 'no_input'"
+ max: 1
+ - switch:
+ variable: prompt_value
+```
+
+
+```json {3,10-16}
+ "main": [
+ { "answer": {} },
+ { "label": "ask" },
+ {
+ "prompt": {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ "speech_hints": ["booking", "dispatch"]
+ }
+ },
+ {
+ "goto": {
+ "label": "ask",
+ "when": "prompt_result == 'no_input'",
+ "max": 1
+ }
+ },
+ {
+ "switch": {
+ "variable": "prompt_value"
+ }
+ }
+ ]
+```
+
+
+
+The Server SDKs can emit the `goto` step but not `label`, whose value is a bare string rather
+than an object, so this variation is for a hosted or hand-written document. In code, use the Relay
+loop below instead. The [control flow guide][control-flow] compares `goto` with `execute` and
+`transfer`.
+
+#### Ask again when the caller says nothing via Relay
+
+Wrap the prompt in a loop that exits on the first reply.
+
+
+
+```python {1-10}
+ result = {}
+ for _attempt in range(2):
+ action = await call.play_and_collect(
+ media=[{"type": "tts", "params": {"text": "Thanks for calling Bayview Taxi. Say booking or dispatch."}}],
+ collect={"speech": {"hints": ["booking", "dispatch"], "language": "en-US"}},
+ )
+ event = await action.wait()
+ result = event.params.get("result", {})
+ if result.get("type") != "no_input":
+ break
+```
+
+
+```typescript {1-10}
+ let result: { type?: string; params?: { text?: string } } = {};
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const action = await call.playAndCollect(
+ [{ type: "tts", text: "Thanks for calling Bayview Taxi. Say booking or dispatch." }],
+ { speech: { hints: ["booking", "dispatch"], language: "en-US" } },
+ );
+ const event = await action.wait();
+ result = (event.params.result ?? {}) as typeof result;
+ if (result.type !== "no_input") break;
+ }
+```
+
+
+
+## Next steps
+
+
+
+
+ Place the call yourself, then run this flow on the far end once the destination answers.
+
+
+
+ A recorded, multi-department menu in hosted SWML that connects each branch to a real number.
+
+
+
+ Serve a different document per call, add routing callbacks, and secure the endpoint for
+ production.
+
+
+
+ Every node the visual editor offers, including request, recording, and AI agent nodes.
+
+
+
diff --git a/fern/products/platform/pages/calling/voice/overview.mdx b/fern/products/platform/pages/calling/voice/overview.mdx
index 0a4e0460da..bc8f2574c0 100644
--- a/fern/products/platform/pages/calling/voice/overview.mdx
+++ b/fern/products/platform/pages/calling/voice/overview.mdx
@@ -18,8 +18,8 @@ Whether building a UCaaS solution, modernizing a legacy IVR, augmenting CX with
Build one agent, call it over a phone number, and send it a text turn through the AI Chat API
-
- Write a call flow that answers, listens, and branches on what the caller says
+
+ Answer a call, collect what the caller says, and branch on it with SWML, Relay, or Call Flow Builder
Dial from your backend or the browser, and choose what runs when someone answers
diff --git a/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx b/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
deleted file mode 100644
index 74280aaed8..0000000000
--- a/fern/products/platform/pages/calling/voice/route-and-control-calls.mdx
+++ /dev/null
@@ -1,405 +0,0 @@
----
-title: Route and control calls
-slug: /voice/route-and-control-calls
-description: Write a call flow that answers, asks the caller a question, and branches on what they say, then run it from the Dashboard and read the result in the call log.
-position: 1
-max-toc-depth: 3
----
-
-[addresses]: /docs/platform/addresses
-[create-call]: /docs/apis/rest/calls/call-commands
-[phone-numbers]: /docs/platform/phone-numbers
-[resources]: /docs/platform/resources
-[server-sdks]: /docs/server-sdks
-[swml-prompt]: /docs/swml/reference/calling/prompt
-[swml-switch]: /docs/swml/reference/calling/switch
-[swml-transfer]: /docs/swml/reference/calling/transfer
-
-A call flow is a document that tells SignalWire what to do with a live call: answer it, play a
-message, listen for a reply, and decide what happens next. In this guide you write one, call it from
-the Dashboard, say something out loud, and hear the flow respond to what you said.
-
-The flow runs as SignalWire Markup Language (SWML). You can write that document by hand as JSON or
-YAML, or have the Server SDK generate it from Python. Both paths produce the same document, and
-SignalWire runs it the same way.
-
-Before you start, you need:
-
-- A [SignalWire account](https://signalwire.com/signup)
-- For the Server SDK path, Python 3 and [ngrok](https://ngrok.com/download) installed locally
-
-No phone number and no API token are required. The Dashboard can call a flow directly.
-
----
-
-
-
-### Write a flow that answers and speaks
-
-Start with the smallest flow that does something audible. Choose one authoring path.
-
-
-
-
-
- This guide uses Python. The [Server SDK documentation][server-sdks] covers the TypeScript SDK as
- well.
-
-
-#### Install the Server SDK
-
-```bash
-python3 -m pip install signalwire-sdk
-```
-
-#### Write the flow
-
-Create `route_calls.py`:
-
-```python title="route_calls.py"
-from signalwire import SWMLService
-
-service = SWMLService(name="route-calls")
-
-service.add_verb("answer", {})
-service.add_verb("play", {"url": "say:Thanks for calling. Your flow is running."})
-service.add_verb("hangup", {})
-
-
-if __name__ == "__main__":
- service.serve()
-```
-
-`SWMLService` builds a SWML document and serves it over HTTP. Each `add_verb` call appends one
-instruction to the document's `main` section, and `serve()` starts a web server on port `3000`
-that returns the document when SignalWire requests it.
-
-#### Give the flow a public URL
-
-In a second terminal, start ngrok:
-
-```bash
-ngrok http 3000
-```
-
-Copy the HTTPS forwarding URL, such as `https://abc123.ngrok-free.app`, and keep ngrok running.
-
-#### Start the flow with stable credentials
-
-The service protects its URL with Basic Auth. Set the credentials yourself so they survive a
-restart, then start the flow in the first terminal:
-
-```bash
-export SWML_BASIC_AUTH_USER="signalwire"
-export SWML_BASIC_AUTH_PASSWORD="replace-with-a-long-random-password"
-python3 route_calls.py
-```
-
-#### Verify the generated SWML
-
-From another terminal, request the document through the tunnel:
-
-```bash
-curl --fail \
- --user "signalwire:replace-with-a-long-random-password" \
- "https://abc123.ngrok-free.app/" \
- | python3 -m json.tool
-```
-
-A working flow returns a JSON document with `"version": "1.0.0"` and a `sections.main` array
-holding `answer`, `play`, and `hangup`. A `401` response means the credentials in the request don't
-match the ones you exported before starting the flow.
-
-#### Create an External URL resource
-
-SignalWire needs a resource to call. Open the [SignalWire Dashboard](https://my.signalwire.com),
-select **Script**, and then select **External URL**. Set the Primary Script URL to the tunnel URL
-with the credentials embedded:
-
-```text
-https://signalwire:replace-with-a-long-random-password@abc123.ngrok-free.app/
-```
-
-Select **Create**. The resource appears in **Resources**.
-
-
-
-
-#### Create a SWML Script resource
-
-
-
-Give the script a name and paste this document into the editor:
-
-
-
-```yaml
-version: 1.0.0
-sections:
- main:
- - answer: {}
- - play:
- url: "say:Thanks for calling. Your flow is running."
- - hangup: {}
-```
-
-
-```json
-{
- "version": "1.0.0",
- "sections": {
- "main": [
- { "answer": {} },
- { "play": { "url": "say:Thanks for calling. Your flow is running." } },
- { "hangup": {} }
- ]
- }
-}
-```
-
-
-
-Save the script. It appears in **Resources**.
-
-Execution starts at the `main` section, which every document must have. Each section is an
-ordered list of methods, and later in this guide you add more sections and jump between them.
-`answer` picks up the call, `play` speaks the text after the `say:` prefix, and `hangup` ends the
-call.
-
-
-
-
-### Call the flow
-
-Open the resource in **Resources** and select **Click-to-Test**, under the resource's name. A new
-tab opens, shows "Connecting", and places a browser call to the resource. Allow microphone access
-if the browser asks.
-
-You hear "Thanks for calling. Your flow is running." and the call ends.
-
-**Click-to-Test** appears only on resources that handle calls. If it is disabled, the resource has
-no address to dial: open its **Addresses** and add an alias address.
-
-If the call connects but stays silent, the flow never ran. On the Server SDK path, repeat the
-`curl` request to confirm the tunnel still answers, then check that the External URL resource
-carries the same URL and credentials. On the SWML path, reopen the script and check that the
-document saved with a `main` section.
-
-To reach the same flow from a phone instead, assign one of your [phone numbers][phone-numbers] to
-the resource under **Inbound Call Settings** and dial it.
-
-### Listen and branch on what the caller says
-
-Now make the flow ask a question and route on the answer. Three methods do the work:
-
-- [`prompt`][swml-prompt] plays a question and waits for input. Setting `speech_hints` switches it
- from keypad digits to speech and lists the words you expect to hear. When the caller
- speaks, `prompt` stores the recognized text in the `prompt_value` variable and the outcome in
- `prompt_result`.
-- [`switch`][swml-switch] compares a variable against a set of cases and runs the matching one.
- The `default` case runs for anything else, including silence and unrecognized speech.
-- [`transfer`][swml-transfer] jumps to another section of the document and does not return.
-
-Replace the flow with this version. The `sales` and `support` sections each speak a different
-message, and `no_match` handles a caller who says nothing or something the flow doesn't expect.
-
-
-
-
-Replace `route_calls.py`:
-
-```python title="route_calls.py"
-from signalwire import SWMLService
-
-service = SWMLService(name="route-calls")
-
-service.add_verb("answer", {})
-service.add_verb(
- "prompt",
- {
- "play": "say:Thanks for calling. Say sales or support.",
- "speech_hints": ["sales", "support"],
- },
-)
-service.add_verb(
- "switch",
- {
- "variable": "prompt_value",
- "case": {
- "sales": [{"transfer": {"dest": "sales"}}],
- "support": [{"transfer": {"dest": "support"}}],
- },
- "default": [{"transfer": {"dest": "no_match"}}],
- },
-)
-
-service.add_verb_to_section("sales", "play", {"url": "say:You said sales."})
-service.add_verb_to_section("sales", "hangup", {})
-
-service.add_verb_to_section("support", "play", {"url": "say:You said support."})
-service.add_verb_to_section("support", "hangup", {})
-
-service.add_verb_to_section("no_match", "play", {"url": "say:Sorry, I didn't catch that. Goodbye."})
-service.add_verb_to_section("no_match", "hangup", {})
-
-
-if __name__ == "__main__":
- service.serve()
-```
-
-`add_verb_to_section` creates the named section on first use and appends to it after that. The
-SDK validates every verb against the SWML schema as you add it, so a misspelled parameter raises
-an error at startup instead of failing on a live call.
-
-Restart the flow with the same credentials, then request the document again with `curl`. The
-`sections` object now holds `main`, `sales`, `support`, and `no_match`. The External URL resource
-already points at the tunnel, so there is nothing to change in the Dashboard.
-
-
-
-
-Open the script in **Resources** and replace its contents:
-
-
-
-```yaml
-version: 1.0.0
-sections:
- main:
- - answer: {}
- - prompt:
- play: "say:Thanks for calling. Say sales or support."
- speech_hints:
- - sales
- - support
- - switch:
- variable: prompt_value
- case:
- sales:
- - transfer:
- dest: sales
- support:
- - transfer:
- dest: support
- default:
- - transfer:
- dest: no_match
- sales:
- - play:
- url: "say:You said sales."
- - hangup: {}
- support:
- - play:
- url: "say:You said support."
- - hangup: {}
- no_match:
- - play:
- url: "say:Sorry, I didn't catch that. Goodbye."
- - hangup: {}
-```
-
-
-```json
-{
- "version": "1.0.0",
- "sections": {
- "main": [
- { "answer": {} },
- {
- "prompt": {
- "play": "say:Thanks for calling. Say sales or support.",
- "speech_hints": ["sales", "support"]
- }
- },
- {
- "switch": {
- "variable": "prompt_value",
- "case": {
- "sales": [{ "transfer": { "dest": "sales" } }],
- "support": [{ "transfer": { "dest": "support" } }]
- },
- "default": [{ "transfer": { "dest": "no_match" } }]
- }
- }
- ],
- "sales": [
- { "play": { "url": "say:You said sales." } },
- { "hangup": {} }
- ],
- "support": [
- { "play": { "url": "say:You said support." } },
- { "hangup": {} }
- ],
- "no_match": [
- { "play": { "url": "say:Sorry, I didn't catch that. Goodbye." } },
- { "hangup": {} }
- ]
- }
-}
-```
-
-
-
-Save the script. The next call picks up the new document.
-
-
-
-
-### Call it three times
-
-Call the resource again and say "sales". The flow answers "You said sales." Call once more and say
-"support" to hear the other branch. On a third call, say nothing: after the input timeout the flow
-says "Sorry, I didn't catch that" and hangs up. The default wait for input is five seconds.
-
-If every call lands in `no_match`, the recognized text didn't equal a case key. `switch` compares
-the whole value exactly, so check the keys for capital letters and stray spaces. The next step
-shows you what the flow heard.
-
-### Read the call log
-
-Open **Logs** > **Voice** in the Dashboard and select the most recent call. The call's timeline
-lists each step the flow ran in order: the call state changes, a **Play** entry for each message,
-and a **Collect** entry for the `prompt`. Select an entry to see its details. A flow that fails
-to load or contains an invalid method shows a **Script Warning** or **Error** entry with the
-reason, so a broken document is visible here without a support ticket.
-
-The timeline records that input was collected, not what the caller said. To receive the
-recognized speech itself, add a `status_url` to the `prompt` method.
-SignalWire sends a `calling.call.collect` event to that URL when input arrives. The payload's
-`params.result.type` is `speech`, `no_input`, or `no_match`, and for speech the recognized text is
-included with its confidence score. The [`prompt` reference][swml-prompt] documents every field.
-
-
-
-## One flow, any channel
-
-The document you wrote doesn't know where the call came from. The Dashboard reached it through
-click-to-call. A [phone number][phone-numbers] assigned to the same resource runs the identical
-document, and so does a SIP address, a browser call to the resource's [address][addresses], or an
-outbound call you place with the [REST API][create-call]. Direction and channel are routing
-details on the [resource][resources]. The flow is the same program.
-
-## Next steps
-
-
-
-
- Loop back to the question with `goto` and labels, call sections like functions with `execute`,
- and see how `transfer` differs from both.
-
-
-
- Accept keypad digits alongside speech, record the call, and connect each branch to a real
- phone number.
-
-
-
- Serve a different document per call, add routing callbacks, and secure the endpoint for
- production.
-
-
-
- Buy a number, assign it to the flow, and dial it from your own phone.
-
-
-
From 55259594eb888f1d5530401a5a5756062ca42bfa Mon Sep 17 00:00:00 2001
From: August
Date: Fri, 9 Oct 2026 14:55:21 +0000
Subject: [PATCH 04/12] docs(voice): complete call routing examples and add
collect lifecycle diagrams
---
.../img/collect-relay-lifecycle-themed.svg | 99 +++
.../img/collect-webhook-lifecycle-themed.svg | 91 +++
.../pages/calling/voice/call-routing.mdx | 743 ++++++++++++++++--
3 files changed, 854 insertions(+), 79 deletions(-)
create mode 100644 fern/assets/images/img/collect-relay-lifecycle-themed.svg
create mode 100644 fern/assets/images/img/collect-webhook-lifecycle-themed.svg
diff --git a/fern/assets/images/img/collect-relay-lifecycle-themed.svg b/fern/assets/images/img/collect-relay-lifecycle-themed.svg
new file mode 100644
index 0000000000..6971eb930f
--- /dev/null
+++ b/fern/assets/images/img/collect-relay-lifecycle-themed.svg
@@ -0,0 +1,99 @@
+
diff --git a/fern/assets/images/img/collect-webhook-lifecycle-themed.svg b/fern/assets/images/img/collect-webhook-lifecycle-themed.svg
new file mode 100644
index 0000000000..5c636eb1fa
--- /dev/null
+++ b/fern/assets/images/img/collect-webhook-lifecycle-themed.svg
@@ -0,0 +1,91 @@
+
+ Collect result delivered to your server as a webhook
+ SignalWire plays the prompt to the caller. The caller says dispatch. SignalWire stores dispatch in prompt_value and switch runs the dispatch branch. SignalWire posts a calling.call.collect event to your status_url with result type speech, the text, and a confidence score. Your server returns 200 OK. The dispatch branch plays to the caller.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Caller
+
+
+
+
+
+ SignalWire
+
+
+
+
+
+ Your server
+
+
+ prompt plays
+ say booking or dispatch
+
+
+
+ speaks
+ "dispatch"
+
+
+
+
+ prompt_value: dispatch
+ switch runs the dispatch branch
+
+
+ calling.call.collect
+ POST to status_url
+ speech, text, confidence
+
+
+ 200 OK
+
+
+
+ branch plays
+ You said dispatch
+
+
+
diff --git a/fern/products/platform/pages/calling/voice/call-routing.mdx b/fern/products/platform/pages/calling/voice/call-routing.mdx
index 5bc689fcd7..1615bd7cdb 100644
--- a/fern/products/platform/pages/calling/voice/call-routing.mdx
+++ b/fern/products/platform/pages/calling/voice/call-routing.mdx
@@ -388,7 +388,7 @@ line, and `no_match` catches everything else.
-```python {7-25}
+```python
# Install: python -m pip install signalwire-sdk==3.4.1
# Save as call_routing.py and run: python call_routing.py
from signalwire import SWMLService
@@ -429,7 +429,7 @@ if __name__ == "__main__":
```
-```typescript {8-20}
+```typescript
// Install: npm install @signalwire/sdk@2.0.5
// This sample also runs as JavaScript: save as call-routing.mjs,
// then run: node call-routing.mjs
@@ -464,7 +464,7 @@ await service.serve();
```
-```yaml {5-19}
+```yaml
version: 1.0.0
sections:
main:
@@ -501,7 +501,7 @@ sections:
```
-```json {6-20}
+```json
{
"version": "1.0.0",
"sections": {
@@ -565,7 +565,7 @@ return value.
-```python {14-36}
+```python
# Install: python -m pip install signalwire-sdk==3.4.1
# Save as call_routing.py and run: python call_routing.py
from signalwire.relay import RelayClient
@@ -615,7 +615,7 @@ client.run()
```
-```typescript {13-32}
+```typescript
// Install: npm install @signalwire/sdk@2.0.5
// Save as call-routing.mts and run: npx tsx call-routing.mts
import { RelayClient } from "@signalwire/sdk";
@@ -849,6 +849,30 @@ The POST body for a recognized reply looks like this. `params.result.type` is `n
The [`prompt` reference][swml-prompt] documents every field.
+
+
+
+
+
+
+
+
+```mermaid
+sequenceDiagram
+ participant Caller
+ participant SW as SignalWire
+ participant Srv as Your server
+
+ SW->>Caller: prompt plays: say booking or dispatch
+ Caller->>SW: says "dispatch"
+ Note over SW: prompt_value is dispatch, switch runs the dispatch branch
+ SW->>Srv: POST status_url: calling.call.collect, type speech, text, confidence
+ Srv-->>SW: 200 OK
+ SW->>Caller: dispatch branch plays
+```
+
+
+
### Receive the result via Relay
The Relay client already holds the result: it is the event that `action.wait()` returned. To log
@@ -873,6 +897,32 @@ starting the prompt. The [Relay events reference][relay-events] lists the payloa
+
+
+
+
+
+
+
+
+```mermaid
+sequenceDiagram
+ participant Caller
+ participant SW as SignalWire
+ participant App as Your Relay client
+
+ Caller->>SW: calls the resource address
+ SW->>App: call arrives on topic default
+ App->>SW: answer, play_and_collect
+ SW->>Caller: prompt plays: say booking or dispatch
+ Caller->>SW: says "dispatch"
+ SW-->>App: calling.call.collect: type speech, text
+ Note over App: wait() returns, your code branches
+ App->>SW: play reply, then hangup
+```
+
+
+
### Compare the surfaces
| Surface | Where the recognized text shows up | How silence and no match are reported |
@@ -904,28 +954,143 @@ words at once, and treat "press 1" and "say booking" as the same answer.
#### Accept a keypad digit as well as speech via SWML
-Setting one digit parameter alongside the speech parameters enables both inputs. Add `max_digits`
-to the prompt and the digit keys to the switch.
+Setting one digit parameter alongside the speech parameters enables both inputs. `max_digits`
+joins the prompt, and the digit keys join the switch.
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire import SWMLService
+
+service = SWMLService(name="call-routing")
+
+service.add_verb("answer", {})
+service.add_verb(
+ "prompt",
+ {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2.",
+ "max_digits": 1,
+ "speech_hints": ["booking", "dispatch"],
+ },
+)
+service.add_verb(
+ "switch",
+ {
+ "variable": "prompt_value",
+ "case": {
+ "booking": [{"transfer": {"dest": "booking"}}],
+ "1": [{"transfer": {"dest": "booking"}}],
+ "dispatch": [{"transfer": {"dest": "dispatch"}}],
+ "2": [{"transfer": {"dest": "dispatch"}}],
+ },
+ "default": [{"transfer": {"dest": "no_match"}}],
+ },
+)
+
+service.add_verb_to_section("booking", "play", {"url": "say:You said booking."})
+service.add_verb_to_section("booking", "hangup", {})
+
+service.add_verb_to_section("dispatch", "play", {"url": "say:You said dispatch."})
+service.add_verb_to_section("dispatch", "hangup", {})
+
+service.add_verb_to_section("no_match", "play", {"url": "say:Sorry, I didn't catch that. Goodbye."})
+service.add_verb_to_section("no_match", "hangup", {})
+
+if __name__ == "__main__":
+ service.serve()
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as call-routing.mjs,
+// then run: node call-routing.mjs
+import { SWMLService } from "@signalwire/sdk";
+
+const service = new SWMLService({ name: "call-routing" });
+
+service.addVerb("answer", {});
+service.addVerb("prompt", {
+ play: "say:Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2.",
+ max_digits: 1,
+ speech_hints: ["booking", "dispatch"],
+});
+service.addVerb("switch", {
+ variable: "prompt_value",
+ case: {
+ booking: [{ transfer: { dest: "booking" } }],
+ "1": [{ transfer: { dest: "booking" } }],
+ dispatch: [{ transfer: { dest: "dispatch" } }],
+ "2": [{ transfer: { dest: "dispatch" } }],
+ },
+ default: [{ transfer: { dest: "no_match" } }],
+});
+
+service.addVerbToSection("booking", "play", { url: "say:You said booking." });
+service.addVerbToSection("booking", "hangup", {});
+
+service.addVerbToSection("dispatch", "play", { url: "say:You said dispatch." });
+service.addVerbToSection("dispatch", "hangup", {});
+
+service.addVerbToSection("no_match", "play", { url: "say:Sorry, I didn't catch that. Goodbye." });
+service.addVerbToSection("no_match", "hangup", {});
+
+await service.serve();
+```
+
-```yaml {3,5,9-11}
+```yaml
+version: 1.0.0
+sections:
+ main:
+ - answer: {}
- prompt:
play: "say:Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2."
max_digits: 1
- speech_hints: [booking, dispatch]
+ speech_hints:
+ - booking
+ - dispatch
- switch:
variable: prompt_value
case:
- booking: [{ transfer: { dest: booking } }]
- "1": [{ transfer: { dest: booking } }]
- dispatch: [{ transfer: { dest: dispatch } }]
- "2": [{ transfer: { dest: dispatch } }]
- default: [{ transfer: { dest: no_match } }]
+ booking:
+ - transfer:
+ dest: booking
+ "1":
+ - transfer:
+ dest: booking
+ dispatch:
+ - transfer:
+ dest: dispatch
+ "2":
+ - transfer:
+ dest: dispatch
+ default:
+ - transfer:
+ dest: no_match
+ booking:
+ - play:
+ url: "say:You said booking."
+ - hangup: {}
+ dispatch:
+ - play:
+ url: "say:You said dispatch."
+ - hangup: {}
+ no_match:
+ - play:
+ url: "say:Sorry, I didn't catch that. Goodbye."
+ - hangup: {}
```
-```json {4,12-13}
+```json
+{
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "answer": {} },
{
"prompt": {
"play": "say:Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2.",
@@ -945,21 +1110,51 @@ to the prompt and the digit keys to the switch.
"default": [{ "transfer": { "dest": "no_match" } }]
}
}
+ ],
+ "booking": [
+ { "play": { "url": "say:You said booking." } },
+ { "hangup": {} }
+ ],
+ "dispatch": [
+ { "play": { "url": "say:You said dispatch." } },
+ { "hangup": {} }
+ ],
+ "no_match": [
+ { "play": { "url": "say:Sorry, I didn't catch that. Goodbye." } },
+ { "hangup": {} }
+ ]
+ }
+}
```
-In the Server SDK samples, add `"max_digits": 1` to the prompt config and the `"1"` and `"2"` keys
-to the `case` object.
-
#### Accept a keypad digit as well as speech via Relay
-Pass both `digits` and `speech` in the collect config, then read `params.digits` when the result
-type is `digit`.
+Pass both `digits` and `speech` in the collect config. A keypad reply comes back with type
+`digit` and the pressed key in `params.digits`, so the code maps keys to the same words.
-```python {3,9-10}
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+BY_KEY = {"1": "booking", "2": "dispatch"}
+
+
+@client.on_call
+async def handle_call(call):
+ await call.answer()
+
action = await call.play_and_collect(
media=[{"type": "tts", "params": {"text": "Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2."}}],
collect={"digits": {"max": 1}, "speech": {"hints": ["booking", "dispatch"], "language": "en-US"}},
@@ -969,13 +1164,49 @@ type is `digit`.
heard = ""
if result.get("type") == "digit":
- heard = {"1": "booking", "2": "dispatch"}.get(result.get("params", {}).get("digits", ""), "")
+ heard = BY_KEY.get(result.get("params", {}).get("digits", ""), "")
elif result.get("type") == "speech":
heard = result.get("params", {}).get("text", "").lower()
+
+ if "booking" in heard:
+ reply = "You said booking."
+ elif "dispatch" in heard:
+ reply = "You said dispatch."
+ else:
+ reply = "Sorry, I didn't catch that. Goodbye."
+
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play(
+ [{"type": "tts", "params": {"text": reply}}],
+ on_completed=hang_up_after_playback,
+ )
+ await call.wait_for_ended()
+
+
+client.run()
```
-```typescript {3,10-12}
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// Save as call-routing.mts and run: npx tsx call-routing.mts
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+const byKey: Record = { "1": "booking", "2": "dispatch" };
+
+client.onCall(async (call) => {
+ await call.answer();
+
const action = await call.playAndCollect(
[{ type: "tts", text: "Thanks for calling Bayview Taxi. Say booking or press 1. Say dispatch or press 2." }],
{ digits: { max: 1 }, speech: { hints: ["booking", "dispatch"], language: "en-US" } },
@@ -985,10 +1216,24 @@ type is `digit`.
type?: string;
params?: { text?: string; digits?: string };
};
- const byKey: Record = { "1": "booking", "2": "dispatch" };
+
let heard = "";
if (result.type === "digit") heard = byKey[result.params?.digits ?? ""] ?? "";
else if (result.type === "speech") heard = (result.params?.text ?? "").toLowerCase();
+
+ let reply = "Sorry, I didn't catch that. Goodbye.";
+ if (heard.includes("booking")) reply = "You said booking.";
+ else if (heard.includes("dispatch")) reply = "You said dispatch.";
+
+ await call.play([{ type: "tts", text: reply }], {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ });
+ await call.waitForEnded();
+});
+
+await client.run();
```
@@ -1011,55 +1256,262 @@ or verified.
#### Hand the call to a phone via SWML
-Replace the spoken line in the `dispatch` section with [`connect`][swml-connect].
+The `dispatch` section announces the hand-off, then [`connect`][swml-connect] dials the
+destination. The caller's own number is presented as caller ID unless you set `from`.
-
-```yaml {4-5}
- dispatch:
- - play:
- url: "say:Connecting you to dispatch."
- - connect:
- to: ""
- - hangup: {}
-```
-
-
-```json {3}
- "dispatch": [
- { "play": { "url": "say:Connecting you to dispatch." } },
- { "connect": { "to": "" } },
- { "hangup": {} }
- ]
-```
-
-
-
-In the Server SDK samples, add `service.add_verb_to_section("dispatch", "connect", {"to": ""})`
-between the play and hangup lines, or the `addVerbToSection` equivalent.
-
-#### Hand the call to a phone via Relay
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire import SWMLService
-[`connect`][relay-connect] takes a list of device groups; each inner list rings at once and the
-outer list rings in turn. Replace the dispatch reply with a bridge to one phone.
+service = SWMLService(name="call-routing")
-
-
-```python {1-9}
- if "dispatch" in heard:
- await call.play([{"type": "tts", "params": {"text": "Connecting you to dispatch."}}])
- await call.connect([[{
- "type": "phone",
- "params": {"to_number": "", "from_number": "", "timeout": 30},
- }]])
+service.add_verb("answer", {})
+service.add_verb(
+ "prompt",
+ {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ "speech_hints": ["booking", "dispatch"],
+ },
+)
+service.add_verb(
+ "switch",
+ {
+ "variable": "prompt_value",
+ "case": {
+ "booking": [{"transfer": {"dest": "booking"}}],
+ "dispatch": [{"transfer": {"dest": "dispatch"}}],
+ },
+ "default": [{"transfer": {"dest": "no_match"}}],
+ },
+)
+
+service.add_verb_to_section("booking", "play", {"url": "say:You said booking."})
+service.add_verb_to_section("booking", "hangup", {})
+
+service.add_verb_to_section("dispatch", "play", {"url": "say:Connecting you to dispatch."})
+service.add_verb_to_section("dispatch", "connect", {"to": ""})
+service.add_verb_to_section("dispatch", "hangup", {})
+
+service.add_verb_to_section("no_match", "play", {"url": "say:Sorry, I didn't catch that. Goodbye."})
+service.add_verb_to_section("no_match", "hangup", {})
+
+if __name__ == "__main__":
+ service.serve()
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as call-routing.mjs,
+// then run: node call-routing.mjs
+import { SWMLService } from "@signalwire/sdk";
+
+const service = new SWMLService({ name: "call-routing" });
+
+service.addVerb("answer", {});
+service.addVerb("prompt", {
+ play: "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ speech_hints: ["booking", "dispatch"],
+});
+service.addVerb("switch", {
+ variable: "prompt_value",
+ case: {
+ booking: [{ transfer: { dest: "booking" } }],
+ dispatch: [{ transfer: { dest: "dispatch" } }],
+ },
+ default: [{ transfer: { dest: "no_match" } }],
+});
+
+service.addVerbToSection("booking", "play", { url: "say:You said booking." });
+service.addVerbToSection("booking", "hangup", {});
+
+service.addVerbToSection("dispatch", "play", { url: "say:Connecting you to dispatch." });
+service.addVerbToSection("dispatch", "connect", { to: "" });
+service.addVerbToSection("dispatch", "hangup", {});
+
+service.addVerbToSection("no_match", "play", { url: "say:Sorry, I didn't catch that. Goodbye." });
+service.addVerbToSection("no_match", "hangup", {});
+
+await service.serve();
+```
+
+
+```yaml
+version: 1.0.0
+sections:
+ main:
+ - answer: {}
+ - prompt:
+ play: "say:Thanks for calling Bayview Taxi. Say booking or dispatch."
+ speech_hints:
+ - booking
+ - dispatch
+ - switch:
+ variable: prompt_value
+ case:
+ booking:
+ - transfer:
+ dest: booking
+ dispatch:
+ - transfer:
+ dest: dispatch
+ default:
+ - transfer:
+ dest: no_match
+ booking:
+ - play:
+ url: "say:You said booking."
+ - hangup: {}
+ dispatch:
+ - play:
+ url: "say:Connecting you to dispatch."
+ - connect:
+ to: ""
+ - hangup: {}
+ no_match:
+ - play:
+ url: "say:Sorry, I didn't catch that. Goodbye."
+ - hangup: {}
+```
+
+
+```json
+{
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "answer": {} },
+ {
+ "prompt": {
+ "play": "say:Thanks for calling Bayview Taxi. Say booking or dispatch.",
+ "speech_hints": ["booking", "dispatch"]
+ }
+ },
+ {
+ "switch": {
+ "variable": "prompt_value",
+ "case": {
+ "booking": [{ "transfer": { "dest": "booking" } }],
+ "dispatch": [{ "transfer": { "dest": "dispatch" } }]
+ },
+ "default": [{ "transfer": { "dest": "no_match" } }]
+ }
+ }
+ ],
+ "booking": [
+ { "play": { "url": "say:You said booking." } },
+ { "hangup": {} }
+ ],
+ "dispatch": [
+ { "play": { "url": "say:Connecting you to dispatch." } },
+ { "connect": { "to": "" } },
+ { "hangup": {} }
+ ],
+ "no_match": [
+ { "play": { "url": "say:Sorry, I didn't catch that. Goodbye." } },
+ { "hangup": {} }
+ ]
+ }
+}
+```
+
+
+
+#### Hand the call to a phone via Relay
+
+[`connect`][relay-connect] takes a list of device groups; each inner list rings at once and the
+outer list rings in turn. The dispatch branch bridges to one phone and then waits for the call to
+end.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+
+@client.on_call
+async def handle_call(call):
+ await call.answer()
+
+ action = await call.play_and_collect(
+ media=[{"type": "tts", "params": {"text": "Thanks for calling Bayview Taxi. Say booking or dispatch."}}],
+ collect={"speech": {"hints": ["booking", "dispatch"], "language": "en-US"}},
+ )
+ event = await action.wait()
+ result = event.params.get("result", {})
+
+ heard = ""
+ if result.get("type") == "speech":
+ heard = result.get("params", {}).get("text", "").lower()
+
+ if "dispatch" in heard:
+ announce = await call.play([{"type": "tts", "params": {"text": "Connecting you to dispatch."}}])
+ await announce.wait()
+ await call.connect([[{
+ "type": "phone",
+ "params": {"to_number": "", "from_number": "", "timeout": 30},
+ }]])
await call.wait_for_ended()
return
+
+ reply = "You said booking." if "booking" in heard else "Sorry, I didn't catch that. Goodbye."
+
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play(
+ [{"type": "tts", "params": {"text": reply}}],
+ on_completed=hang_up_after_playback,
+ )
+ await call.wait_for_ended()
+
+
+client.run()
```
-```typescript {1-9}
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// Save as call-routing.mts and run: npx tsx call-routing.mts
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+client.onCall(async (call) => {
+ await call.answer();
+
+ const action = await call.playAndCollect(
+ [{ type: "tts", text: "Thanks for calling Bayview Taxi. Say booking or dispatch." }],
+ { speech: { hints: ["booking", "dispatch"], language: "en-US" } },
+ );
+ const event = await action.wait();
+ const result = (event.params.result ?? {}) as {
+ type?: string;
+ params?: { text?: string };
+ };
+ const heard = result.type === "speech" ? (result.params?.text ?? "").toLowerCase() : "";
+
if (heard.includes("dispatch")) {
- await call.play([{ type: "tts", text: "Connecting you to dispatch." }]);
+ const announce = await call.play([{ type: "tts", text: "Connecting you to dispatch." }]);
+ await announce.wait();
await call.connect([[{
type: "phone",
params: { to_number: "", from_number: "", timeout: 30 },
@@ -1067,6 +1519,17 @@ outer list rings in turn. Replace the dispatch reply with a bridge to one phone.
await call.waitForEnded();
return;
}
+
+ const reply = heard.includes("booking") ? "You said booking." : "Sorry, I didn't catch that. Goodbye.";
+ await call.play([{ type: "tts", text: reply }], {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ });
+ await call.waitForEnded();
+});
+
+await client.run();
```
@@ -1084,27 +1547,59 @@ up.
#### Ask again when the caller says nothing via SWML
[`goto`][swml-goto] jumps to a label within the same section, and its `when` condition and `max`
-count keep the loop bounded. Place a label before the prompt and a conditional `goto` after it.
+count keep the loop bounded. A `label` before the prompt and a conditional `goto` after it repeat
+the question once. The Server SDKs can emit the `goto` step but not `label`, whose value is a bare
+string rather than an object, so this variation is for a hosted or hand-written document. The
+[control flow guide][control-flow] compares `goto` with `execute` and `transfer`.
-```yaml {2,6-9}
+```yaml
+version: 1.0.0
+sections:
main:
- answer: {}
- label: ask
- prompt:
play: "say:Thanks for calling Bayview Taxi. Say booking or dispatch."
- speech_hints: [booking, dispatch]
+ speech_hints:
+ - booking
+ - dispatch
- goto:
label: ask
when: "prompt_result == 'no_input'"
max: 1
- switch:
variable: prompt_value
+ case:
+ booking:
+ - transfer:
+ dest: booking
+ dispatch:
+ - transfer:
+ dest: dispatch
+ default:
+ - transfer:
+ dest: no_match
+ booking:
+ - play:
+ url: "say:You said booking."
+ - hangup: {}
+ dispatch:
+ - play:
+ url: "say:You said dispatch."
+ - hangup: {}
+ no_match:
+ - play:
+ url: "say:Sorry, I didn't catch that. Goodbye."
+ - hangup: {}
```
-```json {3,10-16}
+```json
+{
+ "version": "1.0.0",
+ "sections": {
"main": [
{ "answer": {} },
{ "label": "ask" },
@@ -1123,26 +1618,57 @@ count keep the loop bounded. Place a label before the prompt and a conditional `
},
{
"switch": {
- "variable": "prompt_value"
+ "variable": "prompt_value",
+ "case": {
+ "booking": [{ "transfer": { "dest": "booking" } }],
+ "dispatch": [{ "transfer": { "dest": "dispatch" } }]
+ },
+ "default": [{ "transfer": { "dest": "no_match" } }]
}
}
+ ],
+ "booking": [
+ { "play": { "url": "say:You said booking." } },
+ { "hangup": {} }
+ ],
+ "dispatch": [
+ { "play": { "url": "say:You said dispatch." } },
+ { "hangup": {} }
+ ],
+ "no_match": [
+ { "play": { "url": "say:Sorry, I didn't catch that. Goodbye." } },
+ { "hangup": {} }
]
+ }
+}
```
-The Server SDKs can emit the `goto` step but not `label`, whose value is a bare string rather
-than an object, so this variation is for a hosted or hand-written document. In code, use the Relay
-loop below instead. The [control flow guide][control-flow] compares `goto` with `execute` and
-`transfer`.
-
#### Ask again when the caller says nothing via Relay
-Wrap the prompt in a loop that exits on the first reply.
+A loop around the prompt exits on the first reply of any kind and gives up after two silent
+attempts.
-```python {1-10}
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as call_routing.py and run: python call_routing.py
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+
+@client.on_call
+async def handle_call(call):
+ await call.answer()
+
result = {}
for _attempt in range(2):
action = await call.play_and_collect(
@@ -1153,20 +1679,79 @@ Wrap the prompt in a loop that exits on the first reply.
result = event.params.get("result", {})
if result.get("type") != "no_input":
break
+
+ heard = ""
+ if result.get("type") == "speech":
+ heard = result.get("params", {}).get("text", "").lower()
+
+ if "booking" in heard:
+ reply = "You said booking."
+ elif "dispatch" in heard:
+ reply = "You said dispatch."
+ elif result.get("type") == "no_input":
+ reply = "I didn't hear anything. Goodbye."
+ else:
+ reply = "I didn't understand that. Goodbye."
+
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play(
+ [{"type": "tts", "params": {"text": reply}}],
+ on_completed=hang_up_after_playback,
+ )
+ await call.wait_for_ended()
+
+
+client.run()
```
-```typescript {1-10}
- let result: { type?: string; params?: { text?: string } } = {};
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// Save as call-routing.mts and run: npx tsx call-routing.mts
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+type CollectResult = { type?: string; params?: { text?: string } };
+
+client.onCall(async (call) => {
+ await call.answer();
+
+ let result: CollectResult = {};
for (let attempt = 0; attempt < 2; attempt++) {
const action = await call.playAndCollect(
[{ type: "tts", text: "Thanks for calling Bayview Taxi. Say booking or dispatch." }],
{ speech: { hints: ["booking", "dispatch"], language: "en-US" } },
);
const event = await action.wait();
- result = (event.params.result ?? {}) as typeof result;
+ result = (event.params.result ?? {}) as CollectResult;
if (result.type !== "no_input") break;
}
+
+ const heard = result.type === "speech" ? (result.params?.text ?? "").toLowerCase() : "";
+
+ let reply = "I didn't understand that. Goodbye.";
+ if (heard.includes("booking")) reply = "You said booking.";
+ else if (heard.includes("dispatch")) reply = "You said dispatch.";
+ else if (result.type === "no_input") reply = "I didn't hear anything. Goodbye.";
+
+ await call.play([{ type: "tts", text: reply }], {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ });
+ await call.waitForEnded();
+});
+
+await client.run();
```
From 6331c991283b53438a0c11e3fd96c82d1cbd9cb2 Mon Sep 17 00:00:00 2001
From: August
Date: Fri, 9 Oct 2026 15:56:33 +0000
Subject: [PATCH 05/12] merge
---
.claude/rules/mdx-capability-guides.md | 57 +-
.claude/rules/mdx-style.md | 3 +-
CLAUDE.md | 2 +-
CONTRIBUTING.md | 1 +
README.md | 5 +-
fern/apis/compatibility/openapi.yaml | 55 +-
fern/apis/signalwire-rest/openapi.yaml | 300 ++-
.../campaign-registry/sms/TCR-wizard-1.webp | Bin 53654 -> 0 bytes
.../campaign-registry/sms/TCR-wizard-2.webp | Bin 33858 -> 0 bytes
.../sms/TCR-wizard-new-campaign.webp | Bin 48024 -> 0 bytes
.../sms/TCR-wizard-phone-assignment.webp | Bin 26772 -> 0 bytes
.../campaign-registry/sms/brand-details.webp | Bin 39744 -> 0 bytes
.../campaign-registry/sms/brands.webp | Bin 46160 -> 0 bytes
.../sms/campaign-details.webp | Bin 68952 -> 0 bytes
.../sms/campaign-numbers.webp | Bin 58490 -> 0 bytes
.../campaign-registry/sms/campaigns.webp | Bin 53576 -> 0 bytes
.../sms/existing-campaign-details.webp | Bin 34426 -> 0 bytes
.../images/img/ai-chat-rest-flow-themed.svg | 110 +
.../img/inbound-call-lifecycle-themed.svg | 85 +
fern/assets/images/tools/swsh/swsh-warp.webp | Bin 76976 -> 0 bytes
fern/assets/images/tools/swsh/swsh.webp | Bin 11240 -> 0 bytes
.../tools/wirestarter/swsh-to-setup.webp | Bin 433294 -> 0 bytes
.../tools/wirestarter/wirestarter-space.webp | Bin 57550 -> 0 bytes
fern/components/voice-widget/engine.tsx | 6 +
fern/components/voice-widget/index.tsx | 11 +-
fern/docs.yml | 864 +-------
fern/fern.config.json | 2 +-
fern/llms.txt | 20 +-
.../build-voice-video/call-controls.mdx | 2 +-
.../build-voice-video/device-management.mdx | 2 +-
.../build-voice-video/inbound-calls.mdx | 4 +-
.../v4/guides/build-voice-video/overview.mdx | 6 +-
.../build-voice-video/screen-sharing.mdx | 2 +-
.../guides/getting-started/authentication.mdx | 2 +-
.../pages/nodes/ai_agent.mdx | 2 +-
fern/products/home/pages/welcome.mdx | 12 +-
.../platform/changelog/2026-09-05.mdx | 2 +-
.../platform/changelog/2026-09-21.mdx | 7 +
.../platform/changelog/2026-09-28.mdx | 12 +
.../products/platform/pages/ai/chat.mdx.draft | 330 ---
.../pages/ai/get-started/quickstart.mdx | 7 +-
.../ai/get-started/tool-calling/index.mdx | 3 +-
.../pages/ai/guides/Integrations/vapi.mdx | 4 +-
.../platform/pages/ai/guides/chat.mdx | 713 +++++++
fern/products/platform/pages/ai/overview.mdx | 14 +-
.../pages/calling/voice/SIP/index.mdx | 4 +-
.../voice/SIP/sip-credentials/index.mdx | 2 +-
.../pages/calling/voice/TTS/index.mdx | 9 +-
.../pages/calling/voice/TTS/minimax.mdx | 2 +
.../platform/pages/calling/voice/TTS/rime.mdx | 2 +-
.../pages/calling/voice/TTS/speechify.mdx | 2 +
.../pages/calling/voice/call-streaming.mdx | 1605 ++++++++++++++
.../pages/calling/voice/machine-detection.mdx | 843 ++++++++
.../calling/voice/make-and-receive-calls.mdx | 1350 ++++++++++++
.../pages/calling/voice/outbound-calling.mdx | 1871 -----------------
.../platform/pages/calling/voice/overview.mdx | 25 +-
.../pages/calling/voice/record-calls.mdx | 1158 ++++++++++
.../pages/calling/whatsapp/receive-calls.mdx | 2 -
.../platform/pages/getting-started.mdx | 21 +-
.../registration/index.mdx | 183 +-
.../pages/platform/call-fabric/addresses.mdx | 251 ++-
.../pages/platform/call-fabric/resources.mdx | 38 +-
.../platform/call-fabric/subscribers.mdx | 14 +-
.../platform/pages/platform/core/glossary.mdx | 61 +-
.../pages/platform/core/rate-limits.mdx | 1 +
.../index.mdx | 4 +-
.../pages/platform/phone-numbers/overview.mdx | 2 +-
.../pages/platform/setup/auto-top-up.mdx | 64 +
.../pages/platform/setup/trial-mode.mdx | 5 +
.../platform/pages/tools/overview.mdx | 30 -
.../platform/pages/tools/swsh/index.mdx | 438 ----
.../pages/tools/wirestarter/index.mdx | 231 --
fern/products/platform/platform.yml | 38 +-
.../guides/build-ai-agents/call-recording.mdx | 507 -----
.../pages/guides/build-ai-agents/overview.mdx | 4 +-
.../guides/build-ai-agents/voice-language.mdx | 2 -
.../pages/reference/python/core/overview.mdx | 2 +-
.../reference/python/relay/call/connect.mdx | 3 +
.../reference/python/relay/client/dial.mdx | 3 +
.../reference/typescript/core/overview.mdx | 2 +-
.../typescript/relay/call/connect.mdx | 3 +
.../typescript/relay/client/dial.mdx | 3 +
.../reference/typescript/relay/overview.mdx | 4 +-
.../guides/recipes/creating-ivr-with-swml.mdx | 4 +-
.../pages/guides/recipes/forwarding-calls.mdx | 9 +-
.../recipes/making-and-receiving-calls.mdx | 184 --
.../pages/guides/recipes/recording-calls.mdx | 72 -
.../methods/calling/ai/ai_params/index.mdx | 2 +-
.../methods/calling/ai/languages/index.mdx | 16 +
.../methods/calling/connect/index.mdx | 120 +-
.../reference/methods/calling/enter_queue.mdx | 17 +-
.../reference/methods/calling/record_call.mdx | 81 +-
fern/redirects.yml | 883 ++++++++
.../common/call-fabric/resource-addresses.md | 56 -
.../common/call-fabric/resource-contexts.md | 6 +-
.../common/call-fabric/resources-fyi-card.mdx | 2 +-
.../dashboard/_assign-resource-to-number.mdx | 16 +
.../common/dashboard/_resource-admonition.mdx | 2 +-
fern/styles.css | 6 +
package.json | 2 +-
specs/compatibility-api/tokens/main.tsp | 10 +-
.../compatibility-api/tokens/models/core.tsp | 11 +-
.../tokens/models/responses.tsp | 5 +
.../calling-api/calls/models/examples.tsp | 1 +
.../calling-api/calls/models/requests.tsp | 4 +-
.../fabric-api/_shared/enums.tsp | 1 +
.../fabric-api/resources/models/core.tsp | 22 +
.../fabric-api/resources/models/responses.tsp | 11 +
.../relay-rest/recordings/models/core.tsp | 19 +-
.../swml/calling/Methods/ai/ai_languages.tsp | 77 +-
specs/swml/calling/Methods/connect/main.tsp | 21 +-
.../swml/calling/Methods/enter_queue/main.tsp | 14 +-
.../@typespec/json-schema/SWMLObject.json | 131 +-
yarn.lock | 8 +-
114 files changed, 8036 insertions(+), 5211 deletions(-)
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/TCR-wizard-1.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/TCR-wizard-2.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/TCR-wizard-new-campaign.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/TCR-wizard-phone-assignment.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/brand-details.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/brands.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/campaign-details.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/campaign-numbers.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/campaigns.webp
delete mode 100644 fern/assets/images/dashboard/campaign-registry/sms/existing-campaign-details.webp
create mode 100644 fern/assets/images/img/ai-chat-rest-flow-themed.svg
create mode 100644 fern/assets/images/img/inbound-call-lifecycle-themed.svg
delete mode 100644 fern/assets/images/tools/swsh/swsh-warp.webp
delete mode 100644 fern/assets/images/tools/swsh/swsh.webp
delete mode 100644 fern/assets/images/tools/wirestarter/swsh-to-setup.webp
delete mode 100644 fern/assets/images/tools/wirestarter/wirestarter-space.webp
create mode 100644 fern/products/platform/changelog/2026-09-21.mdx
create mode 100644 fern/products/platform/changelog/2026-09-28.mdx
delete mode 100644 fern/products/platform/pages/ai/chat.mdx.draft
create mode 100644 fern/products/platform/pages/ai/guides/chat.mdx
create mode 100644 fern/products/platform/pages/calling/voice/call-streaming.mdx
create mode 100644 fern/products/platform/pages/calling/voice/machine-detection.mdx
create mode 100644 fern/products/platform/pages/calling/voice/make-and-receive-calls.mdx
delete mode 100644 fern/products/platform/pages/calling/voice/outbound-calling.mdx
create mode 100644 fern/products/platform/pages/calling/voice/record-calls.mdx
create mode 100644 fern/products/platform/pages/platform/setup/auto-top-up.mdx
delete mode 100644 fern/products/platform/pages/tools/overview.mdx
delete mode 100644 fern/products/platform/pages/tools/swsh/index.mdx
delete mode 100644 fern/products/platform/pages/tools/wirestarter/index.mdx
delete mode 100644 fern/products/server-sdks/pages/guides/build-ai-agents/call-recording.mdx
delete mode 100644 fern/products/swml/pages/guides/recipes/making-and-receiving-calls.mdx
delete mode 100644 fern/products/swml/pages/guides/recipes/recording-calls.mdx
create mode 100644 fern/redirects.yml
delete mode 100644 fern/snippets/common/call-fabric/resource-addresses.md
create mode 100644 fern/snippets/common/dashboard/_assign-resource-to-number.mdx
diff --git a/.claude/rules/mdx-capability-guides.md b/.claude/rules/mdx-capability-guides.md
index fa28bd8014..a70324170a 100644
--- a/.claude/rules/mdx-capability-guides.md
+++ b/.claude/rules/mdx-capability-guides.md
@@ -5,14 +5,21 @@ paths:
# Capability guides — one capability, every surface
-Applies to a how-to that teaches one platform capability across more than one surface (SWML, Relay, the REST Calling API, the Browser SDK, Call Flow Builder). Exemplars: `fern/products/platform/pages/calling/voice/outbound-calling.mdx` (first-run walkthrough, complete samples) and `fern/products/platform/pages/calling/record-calls.mdx` (task sections, merged Relay and REST blocks). A single-surface how-to or an integration guide (the Vapi page) follows `mdx-style` alone. Voice, grounding, and `## Next steps` come from `mdx-style`; this rule fixes the skeleton and the sample conventions so guides match each other.
+Applies to a how-to that teaches one platform capability across more than one surface (SWML, Relay, the REST Calling API, the Browser SDK, Call Flow Builder). Exemplars: `fern/products/platform/pages/calling/voice/record-calls.mdx` (task-first layout: capability matrix, task sections, merged Relay and REST blocks) and `fern/products/platform/pages/calling/voice/make-and-receive-calls.mdx` (approach-first layout: one section per approach, first-run walkthroughs, complete samples). A single-surface how-to or an integration guide (the Vapi page) follows `mdx-style` alone. Voice, grounding, and `## Next steps` come from `mdx-style`; this rule fixes the skeleton and the sample conventions so guides match each other.
## Skeleton
Every guide answers the same questions in the same order: which product, how to start, how to do the thing, how to know it worked, where to go next.
+Pick one of two layouts:
+
+- **Task-first** (the default). Use it when the surfaces differ in what they can do, or when a reader can mix them for one task (start a recording over SWML, stop it over REST). The body is task sections, each with one `###` per surface.
+- **Approach-first**. Use it when every approach can do every task in the guide, but the approaches don't mix: each needs its own code and its own kind of Resource, so a reader follows one of them start to finish (placing and answering calls with SWML, Relay, or the Browser SDK). The body is one `##` per approach, each with its own tasks beneath it. A task-first layout here would interleave three unrelated code paths under every heading and invite readers to switch between them.
+
+Task-first:
+
```text
-frontmatter: title = capability as a noun phrase ("Call streaming"); slug //;
+frontmatter: title = the task, verb first ("Record calls", "Stream call audio"); slug //;
description = one sentence naming the first-run outcome, then each extension in page order
[reference-style link definitions]
intro, 2–3 sentences, no heading: what the reader does, then "Start by , then ."
@@ -24,29 +31,50 @@ intro, 2–3 sentences, no heading: what the reader does, then "Start by
### (optional — "Choose a destination", "Run a WebSocket server")
### ("Place the call", "Start the stream")
+ SDK examples first
+ Python —
+ TypeScript —
+
+ the direct HTTP alternative, when a reference example fits;
+ otherwise a `cURL — ` block inside the CodeBlocks
### ("Answer the call", "Answer and speak") — the verification step
## one per task the guide must cover
### via one per surface that supports it
-## Examples
+## Examples (optional)
###
#### via
## Next steps
```
-- **Pick the right product.** The first job of the page is to show which surface can do what, so the reader chooses before reading further. See "Capability matrix" below.
+Approach-first:
+
+```text
+frontmatter, link definitions, intro as above
+## Choose an approach one paragraph saying the approaches don't mix, then one bullet per approach
+## Prepare for one placeholder table for every approach
+## How works (optional — the model every approach shares, such as how a call reaches your code)
+## via one per approach, in SDK-first order: SWML, then WebSocket (Relay), then the browser
+ ### via the same tasks, in the same order, under every approach
+ … first-run tasks are walkthroughs ending in a verification step
+## Next steps
+```
+
+- **Pick the right product** (task-first). The first job of the page is to show which surface can do what, so the reader chooses before reading further. See "Capability matrix" below.
+- **Choose an approach** (approach-first). Say plainly that the approaches are separate ways to build the app and that the sections don't build on each other, then give one bullet per approach: its canonical name linked to its reference, what it is, and when to choose it. No matrix: when every approach can do every task, it would be a grid of check marks that doesn't help the reader choose.
- **Prepare.** "Have these values ready:" then bullets: Space URL, Project ID and API token with the **Voice** permission, a purchased number or verified caller ID, a destination the reader can answer, then topic-specific items. Hard constraints follow as titled ``s (trial and international limits, `wss://` only, TCPA). Add one pointer sentence when a reader might want a sibling guide instead.
- **How it works.** Put it before the first run only when the reader can't interpret the result without the model (machine detection's outcome table). Otherwise conceptual depth goes after the first run, as its own `##` ("Handle the audio stream").
- **First run.** A `` walkthrough is for a reader starting from zero: a first call, a first stream. A guide that picks up mid-stream (recording assumes you can already place a call) skips `` and opens with its first task section. The walkthrough targets the smallest thing the reader can confirm alone, usually calling their own phone: one code block per surface, no options, no error handling.
-- **Verification step** closes the ``: what the reader hears or sees on success, then the likeliest failure and what it means. A guide without `` puts the same check at the end of its first task section. Never skip it.
-- **Task sections** are the body of the guide. Name each `##` after what the reader wants ("Record the whole call"), never after the API surface that provides it ("The record_call verb"). One `###` per surface beneath it, surface named in the heading. Options get their own `##` after the task that introduces them ("Recording options: format, stereo, and direction").
+- **Verification step** closes the ``: what the reader hears or sees on success, then the likeliest failure and what it means. When several failures are common, list them by symptom. A guide without `` puts the same check at the end of its first task section. Never skip it.
+- **Task sections** are the body of a task-first guide. Name each `##` after what the reader wants ("Record the whole call"), never after the API surface that provides it ("The record_call verb"). One `###` per surface beneath it, surface named in the heading. Options get their own `##` after the task that introduces them ("Recording options: format, stereo, and direction").
- **Track and control** is a task section like any other: REST gets `status_url`/`status_events` and a JSON callback payload; Relay gets `call.on(…)` handlers. One lifecycle diagram per surface family, themed SVG in `` with an `` mermaid `sequenceDiagram` twin (`mdx-diagrams`). Add `### Compare the surfaces` when defaults or parameter names differ by surface.
+- **Approach sections** are the body of an approach-first guide. Each `##` covers one approach end to end, and each `###` beneath it is a task named with its surface (`### Place a call via SWML`, `### Answer a call in a browser`), so every heading still stands alone in search. Keep the tasks in the same order under every approach. Setup shared by every approach (credentials, placeholders, the mental model) goes once above the approach sections, never repeated inside them.
- **Examples** hold the variations you could delete and still have a guide. If removing a section would leave a hole in the main path, it is a task section and belongs above `## Examples`. Each example is `### `: one-sentence goal, a titled `` when compliance applies, then a `####` per surface.
- **Next steps** is required here as on every guide (`mdx-style`). Cards deepen this capability only.
## Capability matrix
-One table under `## Pick the right product`: `| Function | SWML | Relay | REST Calling API | Browser SDK | Call Flow Builder |`, keeping every column where the capability plausibly lives and adding an API-area column (`Recordings API`) when a REST resource is part of the story. Rows are things the reader wants to do ("Get the recording's URL back in your own code, without a webhook"), not feature names. Three cell states:
+Task-first guides only. One table under `## Pick the right product`: `| Function | SWML | WebSocket (Relay) | REST | Browser SDK | Call Flow Builder |`, keeping every column where the capability plausibly lives. The REST column covers both commands sent to a live call and REST resources such as the Recordings API, so a REST resource never gets its own column. Rows are things the reader wants to do ("Get the recording's URL back in your own code, without a webhook"), not feature names. Three cell states:
- `` shipped
- `` not available
@@ -56,22 +84,23 @@ Follow the table with one bullet per column: the surface's canonical name linked
## Surface names
-Canonical names come from the glossary (`/docs/platform/glossary`); the ones this rule uses are `SWML`, `Relay`, `REST Calling API`, `Server SDKs`, `Browser SDK`, `Call Flow Builder`. Never "Realtime SDK", "Agents SDK", "the WebSocket API", or "CFB". How specific to be depends on where the name sits:
+Canonical names come from the glossary (`/docs/platform/glossary`); the ones this rule uses are `SWML`, `Relay`, `REST Calling API`, `Server SDKs`, `Browser SDK`, `Call Flow Builder`. Prose uses these names. Matrix columns, headings, and tab titles name the two transports `WebSocket (Relay)` and `REST`. Never "Realtime SDK", "Agents SDK", "the WebSocket API", or "CFB". How specific to be depends on where the name sits:
-- **Headings** name the surface in full: `### Stop recording the call via Relay`, `### Place the call via the REST Calling API`. Two shapes: ` via ` when the surface is a transport; ` from ` when it is a caller context ("Hold an AI chat conversation from a browser"). Every heading stands alone in a search result with no page title above it, so it carries the keywords: "Recording options: format, stereo, and direction", not "Recording options".
+- **Headings** name the surface: `### Stop recording the call via WebSocket (Relay)`, `### Place the call via REST`, `### Record the whole call via SWML`. Two shapes: ` via ` when the surface is a transport; ` from ` when it is a caller context ("Hold an AI chat conversation from a browser"). Every heading stands alone in a search result with no page title above it, so it carries the keywords: "Recording options: format, stereo, and direction", not "Recording options".
- **Tab titles** are short: `REST`, `WebSocket (Relay)`.
-- **Code block titles** are ` — `: `Python — Relay client`, `TypeScript — Relay client`, `cURL — REST Calling API`, `Python — Agents`, `TypeScript — Agents`, `Python — REST client`, `TypeScript — REST client`, `JavaScript — Browser SDK`, `YAML`, `JSON`.
+- **Code block titles** are ` — `: `Python — Relay client`, `TypeScript — Relay client`, `cURL — Calling API`, `Python — SWML builder`, `TypeScript — SWML builder`, `Python — REST client`, `TypeScript — REST client`, `JavaScript — Browser SDK`, `YAML`, `JSON`.
## Surfaces: headings by default, tabs only inside Steps
- A task shows each surface under its own `###`, never in tabs. Headings appear in the table of contents, in search, and in the Markdown export; tab panes hide from all three.
- Inside ``, `###` is already the step heading, so a step that differs by surface uses `` with `` and ``. That is the only place tabs belong. Guides under one overview share a single `groupId` so the reader's choice follows them between pages; the voice guides use `outbound-api`.
-- **Relay and the REST Calling API are the same commands over two transports.** When only the transport differs (pausing a recording, stopping a stream), one `### via Relay` heading holds a single `` of `Python — Relay client`, `TypeScript — Relay client`, and `cURL — REST Calling API`. Split them into separate headings only when the flow genuinely differs: placing a call over HTTP with inline SWML is not the same flow as dialing over a live socket.
-- **SWML is one surface, authored several ways and delivered several ways.** Under `### via SWML`, show the document once, in the forms a reader authors it, in one ``: `Python — Agents` and `TypeScript — Agents` (`AgentBase`) first, then `YAML`, then `JSON`. Don't encourage hand-writing SWML where the SDK covers the verb. How the document reaches the call (served from your server, stored as a hosted script resource, passed inline in a REST `dial`) is a delivery choice: cover it once per guide, in the first-run section, and don't repeat it under every task. A REST `dial` that carries inline SWML is SWML delivery, not the REST Calling API surface.
-- Where the reference already documents a REST request, use `EndpointRequestSnippet` and `EndpointResponseSnippet` with a link to the reference page instead of hand-writing the request; describe only the fields the task needs. A first-run REST step that dials still ends with the "returns a call `id` and status `queued` … save the `id`" paragraph and ``.
+- **Relay and the REST Calling API are the same commands over two transports.** When only the transport differs (pausing a recording, stopping a stream), one `### via WebSocket (Relay)` heading holds a single `` of `Python — Relay client` and `TypeScript — Relay client`, followed by the REST Calling API command as an `EndpointRequestSnippet` or, when no reference example fits, a `cURL — Calling API` block. Split them into separate headings only when the flow genuinely differs: placing a call over HTTP with inline SWML is not the same flow as dialing over a live socket.
+- **SWML is one surface, authored several ways and delivered several ways.** Under `### via SWML`, show the document once, in the forms a reader authors it, in one ``: `Python — SWML builder` (`SWMLBuilder`) and `TypeScript — SWML builder` (`SwmlBuilder`) first, then `YAML`, then `JSON`. Use builder methods for supported verbs, including nested branches and confirmation instructions. Use `AgentBase` when the example needs its agent features. How the document reaches the call (served from your server, stored as a hosted script resource, passed inline in a REST `dial`) is a delivery choice: cover it once per guide, in the first-run section, and don't repeat it under every task. A REST `dial` that carries inline SWML is SWML delivery, not the REST surface.
+- **Direct HTTP: a reference snippet when it fits, a cURL block when it doesn't.** When the API reference's example matches the request the task needs, place `EndpointRequestSnippet` after the SDK examples, with one sentence linking the reference page ("To call the REST API directly, use [Update phone number][…]"); pick a named example with `example="…"` when the default isn't the one. When no existing example matches (a `dial` with inline SWML, a phone number assigned to a Relay topic), write a `cURL — ` block as the last entry in the SDK `` and link the reference page after it. Never add an `@opExample` to the TypeSpec just to fit a guide. Add `EndpointResponseSnippet` when the reader needs the response, and describe only the fields the task needs. A first-run REST step that dials still ends with the "returns a call `id` and status `queued` … save the `id`" paragraph and ``.
## Sample conventions
+- **SDK examples first.** For every API operation covered by a supported SDK, lead the explanation with the SDK method and make its example the first visible option. In a shared ``, order Python SDK, then TypeScript SDK, then the direct HTTP alternative (see "Direct HTTP" above). Apply this to setup, resource creation, authentication, call control, and follow-up tasks as well as the first call. In prose, say "the Server SDKs" and link to the landing page (`/docs/server-sdks`), before the underlying REST endpoint; never name a language or link a per-language reference page. Code block titles still name the language. If no supported SDK method exists for the documented version, explain the gap and use direct HTTP for that operation. A hosted-script-only step can show YAML and JSON directly because the reader pastes a document into the Dashboard.
- **Complete programs.** Every sample under `` and `## Examples` runs as pasted: imports, client setup, the call, and the wait or disconnect. A block under a task section may show only the changed lines, with line highlights (`{6-8}`), when its paragraph names the sample it extends.
- **Header comments** on every complete sample. Python: `# Install: python -m pip install signalwire-sdk==3.4.1` then `# Save as .py and run: python .py`. TypeScript with no type annotations: `// Install: npm install @signalwire/sdk@2.0.5` then `// This sample also runs as JavaScript: save as .mjs,` / `// then run: node .mjs`. TypeScript that uses types: `// Save as .mts and run: npx tsx .mts`. Browser: `// Install: npm install @signalwire/js@4.0.0-rc.2 rxjs@7.8.2` plus the HTML elements the script expects, as comments.
- **Pinned versions** are literal and identical across every guide: `signalwire-sdk==3.4.1`, `@signalwire/sdk@2.0.5`, `@signalwire/js@4.0.0-rc.2 rxjs@7.8.2`. Bump them in every guide in one PR, never one page at a time.
@@ -84,7 +113,7 @@ Canonical names come from the glossary (`/docs/platform/glossary`); the ones thi
- A `` on the parent overview (`fern/products/platform/pages/calling/voice/overview.mdx` for voice), and a fix for any FAQ or stale link there that the guide now answers.
- The `fern/llms.txt` bullet: add or repoint, description copied from the page's frontmatter.
-- Redirects in `fern/docs.yml` only when the guide consolidates existing pages.
+- Redirects in `fern/redirects.yml` only when the guide consolidates existing pages.
- No changelog entry (documentation work). A nav yml entry only where the folder isn't folder-driven (`platform.yml` lists `pages/calling/` pages explicitly).
## Before opening the PR
diff --git a/.claude/rules/mdx-style.md b/.claude/rules/mdx-style.md
index e38055529d..2fa958c100 100644
--- a/.claude/rules/mdx-style.md
+++ b/.claude/rules/mdx-style.md
@@ -35,7 +35,7 @@ Tutorials and how-tos give the reader a way to confirm the result — a command
- No "simply/easily/just" and no "please" in instructions. [G]
- Em-dashes are a house device, not a default connector: one earns its place on a genuine break in thought or an appositive commas would muddle. Where a comma reads the same, write the comma. Never several per paragraph. [MS][SW] Aphorisms land at 2–3 per page, max. [SW] (judgment)
- No emoji — not in headings, tab/card titles, or prose. Say good/bad in words. [SW]
-- Terminology: canonical names are "Server SDKs" (never "Agents SDK") and "resource addresses" (never "Fabric addresses"). The published glossary at `/docs/platform/glossary` (`fern/products/platform/pages/platform/core/glossary.mdx`) is the source of truth for the full set of canonical names, aliases, and historical names — check it before naming a product or entity, and record a new decision there rather than here. Tokens are *created*/*issued* (never *minted*); expand every acronym at first use. [SW]
+- Terminology: canonical names are "Server SDKs" (never "Agents SDK") and "Resource Addresses" (never "Fabric Addresses"). The published glossary at `/docs/platform/glossary` (`fern/products/platform/pages/platform/core/glossary.mdx`) is the source of truth for the full set of canonical names, aliases, and historical names — check it before naming a product or entity, and record a new decision there rather than here. Tokens are *created*/*issued* (never *minted*); expand every acronym at first use. [SW]
## Accessibility and inclusive language [G][MS]
@@ -49,6 +49,7 @@ Tutorials and how-tos give the reader a way to confirm the result — a command
- Customer-facing only: no internal implementation detail (backend endpoints, transports, engine-side params). Placeholder data only — no real names, emails, or domains.
- Examples do real work: tool call → your server → system of record → response. No knowledge-trivia bots.
- Examples run as written: real imports, every required parameter, and only placeholders the reader can obviously fill. Verify each against the spec or SDK source — and against an actual run when the page ships a whole flow.
+- In tutorials and how-to guides, explain API usage through the supported SDK first. Show Python and TypeScript SDK examples first, then the direct HTTP alternative: an `EndpointRequestSnippet` when a reference example matches the request, otherwise a cURL block. Use SWML builders for supported instructions; put equivalent YAML and JSON after the SDK examples. Explain any SDK/version gap that requires a raw request or verb. See `mdx-capability-guides` for the example ordering template.
- A cluster of guides shares one running example, so cross-links compound instead of restarting context. The AI guides use Bayview Taxi and its dispatcher Ada across `platform/pages/ai/overview.mdx` and `platform/pages/ai/guides/**`, and the calling guides under `platform/pages/calling/voice/` borrow the same cast whenever a scenario has a business voice; adopt a cluster's existing cast when you add a page to it, and introduce a new one only for a new cluster. [SW]
- Don't cite other vendors' docs as authority; never disparage anyone's product.
diff --git a/CLAUDE.md b/CLAUDE.md
index 5d678b7ae3..002591d96f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -58,7 +58,7 @@ Assume no prior knowledge, and never expose internal implementation detail — b
| Change | Run |
|---|---|
| MDX pages | `yarn fern-md-check` |
-| Nav yml (`fern/docs.yml`, `fern/products/*/*.yml`) | `yarn fern-check` |
+| Nav yml (`fern/docs.yml`, `fern/products/*/*.yml`) or redirects (`fern/redirects.yml`) | `yarn fern-check` |
| TypeSpec under `specs/` | `yarn build:specs` |
CI runs `fern-check` and `fern-md-check` on every PR. No check covers `#anchor` fragments — verify by hand any link you touch.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 87269d72dc..0af1a91be9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -68,6 +68,7 @@ and the strict MDX v3 syntax rules are all documented in the repo's authoring gu
- [`.claude/rules/mdx-mechanics.md`](.claude/rules/mdx-mechanics.md) — frontmatter, structure, links, the LLM view, and MDX rules that break the build.
- [`.claude/rules/mdx-components.md`](.claude/rules/mdx-components.md) — the component catalog (house set, custom components, what not to use).
- [`.claude/rules/mdx-reference-pages.md`](.claude/rules/mdx-reference-pages.md) — reference-page patterns (`ParamField`, section names, examples).
+- [`.claude/rules/mdx-capability-guides.md`](.claude/rules/mdx-capability-guides.md) — guide template and SDK-first examples, with cURL after the SDKs.
- [`.claude/rules/docs-conventions.md`](.claude/rules/docs-conventions.md) — which docs are hand-authored vs. generated.
These are the single source of truth (and double as the guardrails your AI editor loads automatically), so follow them rather than any summary here.
diff --git a/README.md b/README.md
index 9a763050bc..9207a568d9 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
Get Started ·
API Reference ·
SWML ·
- Agents SDK ·
+ Server SDKs ·
Report an Issue
@@ -60,8 +60,7 @@ This is the source repository for [signalwire.com/docs](https://signalwire.com/d
| | Product | Description |
|---|---|---|
-| | [Agents SDK](https://signalwire.com/docs/agents-sdk) | Build AI-powered voice agents |
-| | [Server SDK](https://signalwire.com/docs/server-sdk) | Control communications in real time |
+| | [Server SDKs](https://signalwire.com/docs/server-sdks) | Build AI agents, control calls, send messages, and more |
| | [Browser SDK](https://signalwire.com/docs/browser-sdk) | Voice, video & chat in the browser |
diff --git a/fern/apis/compatibility/openapi.yaml b/fern/apis/compatibility/openapi.yaml
index 660ec70301..460c536ecd 100644
--- a/fern/apis/compatibility/openapi.yaml
+++ b/fern/apis/compatibility/openapi.yaml
@@ -4533,6 +4533,8 @@ paths:
For the SignalWire REST API's project route, use [Create API token](/docs/apis/rest/project-tokens/create-token).
+ The response is the only place the token secret is returned. No later request, including [Update an API Token](/docs/compatibility-api/rest/tokens/update-token), can read it back. If you lose it, delete the token and create a new one.
+
#### Permissions
The API token used to authenticate must have the following scope(s) enabled to make a successful request: _Management_.
@@ -4546,7 +4548,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/TokenResponse'
+ $ref: '#/components/schemas/CreateTokenResponse'
'400':
description: The request was invalid or cannot be processed. Check the error details for more information.
content:
@@ -4580,6 +4582,10 @@ paths:
description: |-
Changes the name or enabled permission categories of an existing project API token. For the SignalWire REST API's project route, use [Update API token](/docs/apis/rest/project-tokens/update-token).
+ The response contains the token's `id`, `name`, and `permissions` only. The token secret is returned once, by [Generate a new API Token](/docs/compatibility-api/rest/tokens/create-token), and cannot be retrieved afterwards.
+
+ Updating a token does not rotate or reissue its secret. The existing secret keeps working after the update.
+
#### Permissions
The API token used to authenticate must have the following scope(s) enabled to make a successful request: _Management_.
@@ -8565,6 +8571,43 @@ components:
unevaluatedProperties:
not: {}
description: Request body for creating an API token.
+ CreateTokenResponse:
+ type: object
+ required:
+ - id
+ - name
+ - permissions
+ - token
+ properties:
+ id:
+ allOf:
+ - $ref: '#/components/schemas/uuid'
+ description: The unique identifier of the API Token.
+ examples:
+ - ea14556a-984f-11ee-b9d1-0242ac120002
+ name:
+ type: string
+ minLength: 1
+ description: The name of the API Token.
+ examples:
+ - John Doe's Token
+ permissions:
+ type: array
+ items:
+ type: string
+ description: The permissions enabled for this token.
+ examples:
+ - - calling
+ - fax
+ - messaging
+ token:
+ type: string
+ description: The API token secret, used as the password in HTTP Basic auth alongside the project ID. Returned only in this response and cannot be retrieved again; store it securely.
+ examples:
+ - swapi_0123456789ab0123456789ab0123456789ab
+ unevaluatedProperties:
+ not: {}
+ description: Response returned when a token is created. This is the only response that includes the token secret.
CxmlScript:
type: object
required:
@@ -11661,18 +11704,17 @@ components:
- id
- name
- permissions
- - token
properties:
id:
allOf:
- $ref: '#/components/schemas/uuid'
- description: The unique identifier of the created API Token.
+ description: The unique identifier of the API Token.
examples:
- ea14556a-984f-11ee-b9d1-0242ac120002
name:
type: string
minLength: 1
- description: The name of the created API Token.
+ description: The name of the API Token.
examples:
- John Doe's Token
permissions:
@@ -11684,11 +11726,6 @@ components:
- - calling
- fax
- messaging
- token:
- type: string
- description: The API token that can be used along with the project ID for basic authentication.
- examples:
- - swapi_0123456789ab0123456789ab0123456789ab
unevaluatedProperties:
not: {}
description: Response containing a single token.
diff --git a/fern/apis/signalwire-rest/openapi.yaml b/fern/apis/signalwire-rest/openapi.yaml
index b4f874d7c5..4bcaf767e4 100644
--- a/fern/apis/signalwire-rest/openapi.yaml
+++ b/fern/apis/signalwire-rest/openapi.yaml
@@ -874,6 +874,7 @@ paths:
id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
params:
control_id: detect-control-1
+ status_url: https://example.com/webhooks/detection
detect:
type: machine
params:
@@ -22155,7 +22156,9 @@ components:
- 0.05
send_digits:
type: string
- description: 'DTMF digits to send after the call is answered. Allowed characters: `0-9`, `A-D`, `*`, `#`, `w` (wait), `,` (pause).'
+ maxLength: 98
+ pattern: ^[0-9A-Da-d*#wW,]{1,98}$
+ description: 'DTMF digits to send after the call is answered, at most 98 characters. Allowed characters: `0-9`, `A-D`, `*`, `#`, and the pauses `w` (0.5 seconds), `W` (1 second), and `,` (0.5 seconds). SignalWire waits 0.5 seconds after answer before sending the first digit.'
examples:
- 1234#
region:
@@ -22293,7 +22296,9 @@ components:
- 0.05
send_digits:
type: string
- description: 'DTMF digits to send after the call is answered. Allowed characters: `0-9`, `A-D`, `*`, `#`, `w` (wait), `,` (pause).'
+ maxLength: 98
+ pattern: ^[0-9A-Da-d*#wW,]{1,98}$
+ description: 'DTMF digits to send after the call is answered, at most 98 characters. Allowed characters: `0-9`, `A-D`, `*`, `#`, and the pauses `w` (0.5 seconds), `W` (1 second), and `,` (0.5 seconds). SignalWire waits 0.5 seconds after answer before sending the first digit.'
examples:
- 1234#
region:
@@ -22442,7 +22447,9 @@ components:
- 0.05
send_digits:
type: string
- description: 'DTMF digits to send after the call is answered. Allowed characters: `0-9`, `A-D`, `*`, `#`, `w` (wait), `,` (pause).'
+ maxLength: 98
+ pattern: ^[0-9A-Da-d*#wW,]{1,98}$
+ description: 'DTMF digits to send after the call is answered, at most 98 characters. Allowed characters: `0-9`, `A-D`, `*`, `#`, and the pauses `w` (0.5 seconds), `W` (1 second), and `,` (0.5 seconds). SignalWire waits 0.5 seconds after answer before sending the first digit.'
examples:
- 1234#
region:
@@ -26273,6 +26280,7 @@ components:
- created_at
- updated_at
- duration_in_seconds
+ - error_code
- price
- price_unit
- status
@@ -26308,8 +26316,10 @@ components:
examples:
- 2
error_code:
- type: string
- description: Error code if the recording failed.
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Error code if the recording failed, and `null` otherwise.
price:
type: number
format: double
@@ -26323,14 +26333,19 @@ components:
- USD
status:
type: string
- description: Status of the recording.
+ description: |-
+ Status of the recording. One of `recording`, `paused`, `finished`, or `no_input`.
+ `no_input` means audio wasn't detected before the timeout.
+ The media is only downloadable once the status is `finished`.
examples:
- - completed
+ - finished
url:
type: string
- description: URL of the recording file.
+ description: |-
+ Path to the recording, relative to your Space URL. The full URL returns the recording's JSON metadata.
+ Add a format suffix to download the recording: `.wav` or `.mp3`.
examples:
- - https://example.com/recording.mp3
+ - /api/relay/rest/recordings/d3444402-7b43-4512-8735-9d5e1f387814
stereo:
type: boolean
description: Indicates whether the recording is stereo.
@@ -35077,6 +35092,7 @@ components:
- created_at
- updated_at
- duration_in_seconds
+ - error_code
- price
- price_unit
- status
@@ -35112,8 +35128,10 @@ components:
examples:
- 2
error_code:
- type: string
- description: Error code if the recording failed.
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Error code if the recording failed, and `null` otherwise.
price:
type: number
format: double
@@ -35127,14 +35145,19 @@ components:
- USD
status:
type: string
- description: Status of the recording.
+ description: |-
+ Status of the recording. One of `recording`, `paused`, `finished`, or `no_input`.
+ `no_input` means audio wasn't detected before the timeout.
+ The media is only downloadable once the status is `finished`.
examples:
- - completed
+ - finished
url:
type: string
- description: URL of the recording file.
+ description: |-
+ Path to the recording, relative to your Space URL. The full URL returns the recording's JSON metadata.
+ Add a format suffix to download the recording: `.wav` or `.mp3`.
examples:
- - https://example.com/recording.mp3
+ - /api/relay/rest/recordings/d3444402-7b43-4512-8735-9d5e1f387814
stereo:
type: boolean
description: Indicates whether the recording is stereo.
@@ -35843,6 +35866,50 @@ components:
message: webhook_url must be a valid URL
attribute: webhook_url
url: https://signalwire.com/docs/apis/error-codes
+ RemoteAIAgent:
+ type: object
+ required:
+ - id
+ - name
+ - primary_request_url
+ - fallback_request_url
+ - status_callback_url
+ properties:
+ id:
+ allOf:
+ - $ref: '#/components/schemas/uuid'
+ description: Unique ID of the Remote AI Agent.
+ examples:
+ - b2a4c6e8-1f3d-4a5b-9c7e-0d2f4a6b8c1e
+ name:
+ type: string
+ description: Name of the Remote AI Agent.
+ examples:
+ - Support Agent
+ primary_request_url:
+ type: string
+ format: uri
+ description: URL of the agent you host. SignalWire requests the SWML document for each call from this URL.
+ examples:
+ - https://agent.example.com/swml
+ fallback_request_url:
+ anyOf:
+ - type: string
+ format: uri
+ - type: 'null'
+ description: URL SignalWire requests the SWML document from if the primary URL cannot be reached or fails.
+ examples:
+ - https://agent.example.com/fallback
+ status_callback_url:
+ anyOf:
+ - type: string
+ format: uri
+ - type: 'null'
+ description: URL that receives a JSON event when a call to this agent completes, with details of the finished call.
+ examples:
+ - https://agent.example.com/status
+ unevaluatedProperties:
+ not: {}
RequestUrlMethodType:
type: string
enum:
@@ -35950,6 +36017,7 @@ components:
ResourceResponse:
oneOf:
- $ref: '#/components/schemas/ResourceResponseAI'
+ - $ref: '#/components/schemas/ResourceResponseRemoteAIAgent'
- $ref: '#/components/schemas/ResourceResponseCallFlow'
- $ref: '#/components/schemas/ResourceResponseCXMLWebhook'
- $ref: '#/components/schemas/ResourceResponseCXMLScript'
@@ -36449,6 +36517,60 @@ components:
unevaluatedProperties:
not: {}
title: Relay Application
+ ResourceResponseRemoteAIAgent:
+ type: object
+ required:
+ - id
+ - project_id
+ - display_name
+ - created_at
+ - updated_at
+ - type
+ - remote_ai_agent
+ properties:
+ id:
+ allOf:
+ - $ref: '#/components/schemas/uuid'
+ description: Unique ID of the Resource.
+ examples:
+ - 993ed018-9e79-4e50-b97b-984bd5534095
+ project_id:
+ allOf:
+ - $ref: '#/components/schemas/uuid'
+ description: Unique ID of the Project.
+ examples:
+ - 1313fe58-5e14-4c11-bbe7-6fdfa11fe780
+ display_name:
+ type: string
+ description: Display name of the Resource
+ examples:
+ - My Resource
+ created_at:
+ type: string
+ format: date-time
+ description: Date and time when the resource was created.
+ examples:
+ - '2024-05-06T12:20:00Z'
+ updated_at:
+ type: string
+ format: date-time
+ description: Date and time when the resource was updated.
+ examples:
+ - '2024-05-06T12:20:00Z'
+ type:
+ type: string
+ enum:
+ - remote_ai_agent
+ description: The type of Resource
+ examples:
+ - remote_ai_agent
+ remote_ai_agent:
+ allOf:
+ - $ref: '#/components/schemas/RemoteAIAgent'
+ description: An object containing the response data of the Remote AI Agent
+ unevaluatedProperties:
+ not: {}
+ title: Remote AI Agent
ResourceResponseSWMLScript:
type: object
required:
@@ -40063,17 +40185,17 @@ components:
description: Webhook URL to send call status change notifications to. Authentication can also be set in the URL in the format of `username:password@url`.
examples:
- https://example.com/call-status
- transfer_after_bridge:
+ execute_after_queue:
type: string
description: |-
- SWML to execute after the bridge completes. This defines what should happen after the call is connected and the bridge ends.
+ SWML to execute on this call after its bridge with the queued call ends. Only applies when
+ `to` starts with `queue:`. When omitted, the call hangs up when the bridge ends. Either way,
+ the script does not continue past `connect` for queue targets, so `result` never runs.
Can be either:
- A URL (http or https) that returns a SWML document
- An inline SWML document (as a JSON string)
-
- **Note:** This parameter is REQUIRED when connecting to a queue (when `to` starts with "queue:")
examples:
- - https://example.com/after-bridge.swml
+ - https://example.com/after-queue.swml
call_state_events:
type: array
items:
@@ -40100,7 +40222,9 @@ components:
type: array
items:
$ref: '#/components/schemas/SWML.Calling.ConnectDestination'
- description: Array of destination objects to dial simultaneously. All destinations ring at the same time — the first to answer is bridged and the remaining calls are cancelled.
+ description: |-
+ Array of destination objects to dial simultaneously. All destinations ring at the same time — the first to answer is bridged and the remaining calls are cancelled.
+ Up to 20 destinations are dialed. Any destination after the 20th is skipped without an error.
unevaluatedProperties:
not: {}
description: Dial multiple destinations simultaneously. All destinations in the array ring at the same time — the first to answer is bridged and the remaining calls are cancelled.
@@ -40233,17 +40357,17 @@ components:
description: Webhook URL to send call status change notifications to. Authentication can also be set in the URL in the format of `username:password@url`.
examples:
- https://example.com/call-status
- transfer_after_bridge:
+ execute_after_queue:
type: string
description: |-
- SWML to execute after the bridge completes. This defines what should happen after the call is connected and the bridge ends.
+ SWML to execute on this call after its bridge with the queued call ends. Only applies when
+ `to` starts with `queue:`. When omitted, the call hangs up when the bridge ends. Either way,
+ the script does not continue past `connect` for queue targets, so `result` never runs.
Can be either:
- A URL (http or https) that returns a SWML document
- An inline SWML document (as a JSON string)
-
- **Note:** This parameter is REQUIRED when connecting to a queue (when `to` starts with "queue:")
examples:
- - https://example.com/after-bridge.swml
+ - https://example.com/after-queue.swml
call_state_events:
type: array
items:
@@ -40270,7 +40394,9 @@ components:
type: array
items:
$ref: '#/components/schemas/SWML.Calling.ConnectDestination'
- description: Array of destination objects to dial in order. Each destination is tried sequentially — if the current destination does not answer, the next one in the array is attempted.
+ description: |-
+ Array of destination objects to dial in order. Each destination is tried sequentially — if the current destination does not answer, the next one in the array is attempted.
+ Up to 20 destinations are tried. Any destination after the 20th is skipped without an error.
unevaluatedProperties:
not: {}
description: Dial destinations one at a time in sequence. If the first destination does not answer, the next destination in the array is tried, and so on.
@@ -40403,17 +40529,17 @@ components:
description: Webhook URL to send call status change notifications to. Authentication can also be set in the URL in the format of `username:password@url`.
examples:
- https://example.com/call-status
- transfer_after_bridge:
+ execute_after_queue:
type: string
description: |-
- SWML to execute after the bridge completes. This defines what should happen after the call is connected and the bridge ends.
+ SWML to execute on this call after its bridge with the queued call ends. Only applies when
+ `to` starts with `queue:`. When omitted, the call hangs up when the bridge ends. Either way,
+ the script does not continue past `connect` for queue targets, so `result` never runs.
Can be either:
- A URL (http or https) that returns a SWML document
- An inline SWML document (as a JSON string)
-
- **Note:** This parameter is REQUIRED when connecting to a queue (when `to` starts with "queue:")
examples:
- - https://example.com/after-bridge.swml
+ - https://example.com/after-queue.swml
call_state_events:
type: array
items:
@@ -40447,6 +40573,7 @@ components:
The outer array is the **serial** dimension — each element is a group tried in order.
Each inner array is the **parallel** dimension — all destinations in that group are dialed simultaneously.
If no destination in the current group answers, the next group is attempted.
+ Up to 20 destinations are dialed in total, counted across all groups rather than per group. Any destination after the 20th is skipped without an error.
unevaluatedProperties:
not: {}
description: Combine both serial and parallel strategies using a two-dimensional array. The outer array is the serial dimension — each element is a group tried one at a time, in order. Each inner array is the parallel dimension — all destinations in that group are dialed simultaneously. If no destination in the current group answers, the next group is attempted.
@@ -40579,17 +40706,17 @@ components:
description: Webhook URL to send call status change notifications to. Authentication can also be set in the URL in the format of `username:password@url`.
examples:
- https://example.com/call-status
- transfer_after_bridge:
+ execute_after_queue:
type: string
description: |-
- SWML to execute after the bridge completes. This defines what should happen after the call is connected and the bridge ends.
+ SWML to execute on this call after its bridge with the queued call ends. Only applies when
+ `to` starts with `queue:`. When omitted, the call hangs up when the bridge ends. Either way,
+ the script does not continue past `connect` for queue targets, so `result` never runs.
Can be either:
- A URL (http or https) that returns a SWML document
- An inline SWML document (as a JSON string)
-
- **Note:** This parameter is REQUIRED when connecting to a queue (when `to` starts with "queue:")
examples:
- - https://example.com/after-bridge.swml
+ - https://example.com/after-queue.swml
call_state_events:
type: array
items:
@@ -41180,7 +41307,8 @@ components:
Place the current call in a named queue where it will wait to be connected to an available agent or resource.
While waiting, callers will hear music or custom audio.
When an agent connects to the queue (using the connect method), the caller and agent are bridged together.
- After the bridge completes, execution continues with the SWML script specified in transfer_after_bridge.
+ After the bridge completes, execution continues with the SWML script specified in execute_after_queue,
+ or the call hangs up if none was given.
title: enter_queue
unevaluatedProperties:
not: {}
@@ -41189,17 +41317,19 @@ components:
type: object
required:
- queue_name
- - transfer_after_bridge
properties:
queue_name:
type: string
description: Name of the queue to enter. If a queue with this name does not exist, it will be automatically created.
examples:
- support-queue
- transfer_after_bridge:
+ execute_after_queue:
type: string
description: |-
- SWML to execute after the bridge completes. This defines what should happen after the call is connected to an agent and the bridge ends.
+ SWML to execute after the bridge with the agent ends. When omitted, the call hangs up when
+ the bridge ends. If the call leaves the queue without being bridged (`wait_time` expires or
+ the caller hangs up), this field is not used and the script continues with the next
+ instruction; check `queue_result`.
Can be either:
- A URL (http or https) that returns a SWML document
- An inline SWML document (as a JSON string)
@@ -41220,10 +41350,10 @@ components:
wait_time:
type: integer
minimum: 1
- description: Maximum time in seconds to wait in the queue before timeout. Default `3600`
+ description: Maximum time in seconds to wait in the queue before timeout. Default `180`
examples:
- 1800
- default: 3600
+ default: 180
unevaluatedProperties:
not: {}
title: EnterQueueObject object
@@ -42501,41 +42631,6 @@ components:
maximum: 2
description: 'Controls the randomness and expressiveness of the generated speech. Lower values produce a more consistent, predictable delivery; higher values introduce more variation. IMPORTANT: Only works with the Inworld TTS engine.'
default: 1
- speed:
- type: number
- minimum: 0.5
- maximum: 2
- description: 'How quickly the voice speaks. Values below `1.0` slow the voice down; values above `1.0` speed it up. IMPORTANT: Only works with the MiniMax TTS engine.'
- default: 1
- vol:
- type: number
- minimum: 0.1
- maximum: 1
- description: 'The speaking volume. Lower values are quieter. IMPORTANT: Only works with the MiniMax TTS engine.'
- default: 1
- pitch:
- type: integer
- format: int32
- minimum: -12
- maximum: 12
- description: 'The pitch shift in semitones. Negative values lower the pitch; positive values raise it. IMPORTANT: Only works with the MiniMax TTS engine.'
- default: 0
- emotion:
- type: string
- enum:
- - happy
- - sad
- - angry
- - fearful
- - disgusted
- - surprised
- - neutral
- description: |-
- A fixed emotional tone for the generated speech.
- To vary the emotion automatically during a conversation, use [`languages[].auto_emotion`](#languagesauto_emotion) instead.
- IMPORTANT: Only works with the MiniMax TTS engine.
- examples:
- - happy
unevaluatedProperties:
not: {}
title: LanguageParams
@@ -42589,7 +42684,7 @@ components:
Voice to use for the language. String format: `.`, with an optional
`:` suffix for engines that offer a choice of model.
Select engine from `gcloud`, `polly`, `amazon`, `azure`, `openai`, `elevenlabs`, `cartesia`, `deepgram`, `rime`,
- `inworld`, `minimax`, `groq-tts`, `mistral`, `grok`, `smallest`, `fish`, or `speechify`.
+ `inworld`, `groq-tts`, `mistral`, `grok`, `smallest`, or `fish`.
For example, `gcloud.fr-FR-Neural2-B` or `elevenlabs.rachel:eleven_flash_v2_5`.
Always name the engine: a bare voice id resolves against a default engine that can change.
examples:
@@ -42607,8 +42702,7 @@ components:
description: |-
Lets the AI vary its emotion as it speaks. A global emotion, or specific emotions for certain
topics, can be set within the prompt of the AI.
- IMPORTANT: Only works with the [`Cartesia`](/docs/platform/voice/tts/cartesia) and [`MiniMax`](/docs/platform/voice/tts/minimax) TTS engines.
- For a fixed (non-automatic) MiniMax emotion, use [`params.emotion`](#paramsemotion) instead.
+ IMPORTANT: Only works with the [`Cartesia`](/docs/platform/voice/tts/cartesia) TTS engine.
examples:
- true
default: false
@@ -42618,7 +42712,7 @@ components:
Lets the AI vary its speaking speed during the conversation. The speed behavior can be defined
in the prompt of the AI.
IMPORTANT: Only works with the [`Cartesia`](/docs/platform/voice/tts/cartesia),
- [`ElevenLabs`](/docs/platform/voice/tts/elevenlabs), [`MiniMax`](/docs/platform/voice/tts/minimax),
+ [`ElevenLabs`](/docs/platform/voice/tts/elevenlabs),
[`Rime`](/docs/platform/voice/tts/rime), [`Inworld`](/docs/platform/voice/tts/inworld),
[`Smallest`](/docs/platform/voice/tts/smallest), and [`Fish`](/docs/platform/voice/tts/fish) TTS engines.
examples:
@@ -42904,7 +42998,7 @@ components:
Voice to use for the language. String format: `.`, with an optional
`:` suffix for engines that offer a choice of model.
Select engine from `gcloud`, `polly`, `amazon`, `azure`, `openai`, `elevenlabs`, `cartesia`, `deepgram`, `rime`,
- `inworld`, `minimax`, `groq-tts`, `mistral`, `grok`, `smallest`, `fish`, or `speechify`.
+ `inworld`, `groq-tts`, `mistral`, `grok`, `smallest`, or `fish`.
For example, `gcloud.fr-FR-Neural2-B` or `elevenlabs.rachel:eleven_flash_v2_5`.
Always name the engine: a bare voice id resolves against a default engine that can change.
examples:
@@ -49193,6 +49287,7 @@ components:
- created_at
- updated_at
- duration_in_seconds
+ - error_code
- price
- price_unit
- status
@@ -49228,8 +49323,10 @@ components:
examples:
- 2
error_code:
- type: string
- description: Error code if the recording failed.
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Error code if the recording failed, and `null` otherwise.
price:
type: number
format: double
@@ -49243,14 +49340,19 @@ components:
- USD
status:
type: string
- description: Status of the recording.
+ description: |-
+ Status of the recording. One of `recording`, `paused`, `finished`, or `no_input`.
+ `no_input` means audio wasn't detected before the timeout.
+ The media is only downloadable once the status is `finished`.
examples:
- - completed
+ - finished
url:
type: string
- description: URL of the recording file.
+ description: |-
+ Path to the recording, relative to your Space URL. The full URL returns the recording's JSON metadata.
+ Add a format suffix to download the recording: `.wav` or `.mp3`.
examples:
- - https://example.com/recording.mp3
+ - /api/relay/rest/recordings/d3444402-7b43-4512-8735-9d5e1f387814
stereo:
type: boolean
description: Indicates whether the recording is stereo.
@@ -56570,6 +56672,7 @@ components:
- created_at
- updated_at
- duration_in_seconds
+ - error_code
- price
- price_unit
- status
@@ -56605,8 +56708,10 @@ components:
examples:
- 2
error_code:
- type: string
- description: Error code if the recording failed.
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Error code if the recording failed, and `null` otherwise.
price:
type: number
format: double
@@ -56620,14 +56725,19 @@ components:
- USD
status:
type: string
- description: Status of the recording.
+ description: |-
+ Status of the recording. One of `recording`, `paused`, `finished`, or `no_input`.
+ `no_input` means audio wasn't detected before the timeout.
+ The media is only downloadable once the status is `finished`.
examples:
- - completed
+ - finished
url:
type: string
- description: URL of the recording file.
+ description: |-
+ Path to the recording, relative to your Space URL. The full URL returns the recording's JSON metadata.
+ Add a format suffix to download the recording: `.wav` or `.mp3`.
examples:
- - https://example.com/recording.mp3
+ - /api/relay/rest/recordings/d3444402-7b43-4512-8735-9d5e1f387814
stereo:
type: boolean
description: Indicates whether the recording is stereo.
diff --git a/fern/assets/images/dashboard/campaign-registry/sms/TCR-wizard-1.webp b/fern/assets/images/dashboard/campaign-registry/sms/TCR-wizard-1.webp
deleted file mode 100644
index c2d5833cd667c3c4c99c435d0b6f1d464a92d8c4..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 53654
zcmZU3V_Yq6&}}%`=BakJ?RGocwrzK+lWp6^$*z-a*U7f6`+x7f-`;QYn~(F%tTk&r
zqbwyZ9ysAe+mn#LTkc2
z(2gN#q+tfWMcoBuOczjpEqwKMZgBPs{SmrHndRS5o>nhbA~TH!Yn%;~iA<)>WcfEJ
zO3#NDh;Z_8#>I+)V)QJU}G=hp*%b>JK+zSYEInsQfno*qeBX
zV=(?7$m8JC1&$wxf_cwn{4-vTu-Ps|LRQjcBTfbER`c*qOj(AcMp3%)1wJN`NL(Go
z9hS-|`v26?ipU2dx=tTE8N?bRRSxV5M~)mje=0R;i?^_M>ZVCy)`_k+K6U6k#UdP5
zM;(w(M6+Af7OBxk;+4=~=NQAth{w8{QW2)HEM%F~&Wn~wH|d7fMp~?x$c(k=s1G*;
z(W^8C#y^FNunH8c*l{(O^J5Drov~6PlqP;yTIgTbjf~DUV*#xri}k0BO1v88N)N
z@36UY*sVyzlr!hEY|J^TM$Bx7x~;@AbPhOEleKD3dB55;O1Vc;PIcQi8XM4+_`EPP
zrg_B%ZmX-B@axFSo{5ynW=coYGJ@@u2OYDr<}ngZV`y}F)vP41yEW=a>$;W-RSw!5
zF)Zk3sO3|-mchXO7O^)xv4}dIbkhU|!fTqia#+hYjP+dD`FE%FK5uQDUmtoiz0TY_
zw>tPpx$ondP!#5ulaz-)6q0r$lCa9&{{8+gVqb6hEK`Jmws15o4~otwp@vVj1hKR{
z3?{RgHByY3H!`qf)kp}nv^G8$5s&27ZOstYU1qgpnj0G<M>-c&kM3e8}d9GhnjOJhacHN_pU
z4BKbE=f2>M&EckUpWGdsX8r!!-oAboibLV|bNuFs-g^1BaS!rqWmCXg+|Cj!rAII!~HM5a|Vyup99O}Gt4xW$7DQRelv4(
z^P?wwwNea=$O(e7KhY9%Qgi!>D-%|#UNWiKB`)=z;}@Rq<%}v#JvxySjIC=E^O_S`
zRhoh+@)=_)f(d(_$vPSc3FH4d4%!K5A~oK&K@(kbgXgRaji9j>JClvZYXUxsjh3mx
z^&2kA7)j#9O1u45yI)Vwm(3slTI{ShTI{-;-Pc=w<)+#F7xS6bp1cuQc(0AxZiu(M
zDS)@w(Zw{`&oAHu4NNP;g?4Uzq2=?f91;+~q&5pNf9w>0mfhwJ`@j1{Ba7eFe`!><
zpqMC3p$qn}3+l!^_A0MbJXKOh99S~p`Z+LEb9`9Xg^GBUNz6vHI5Jq*)Rs*7v^&m_
z(Um3-7+g1(*LS2Qdo(JTa5NZOd;RKt)?U<@5}Wz-BCe}VSE~JVP}MmU>qGJrs5ds}
z`5*;@8+G-yW5%yegbf#QeuTL+D^RjwJ+8j|K;Q`Z?P%MRsK+Al>I+U6IsgDa(~yeaZ!(RsuGl1;Y6ZavcVi%dp*sp
zXE1zHnpLyS%5l-Z%zWCWj6@3=Y?}X#IN|2z7QZnJEBf|mdIds|68R&<^)<+E4v0;O
zugq6jVnwpQI_0D_l~3Bin)DP#;&vY|MAUCbw9_Y|Se`
zPzzDk=#NO16s;!=43MD}m({dvb3PoW=)i2z(ZC)@ppgEN9z~L<8xI7Sj#@?;!6ptd
zt01S4#}x9ECI8AV7p^DCFc$nVWYc$Oh{%wF2ov98Lut1dNd`$FGf0a?wyZrrI7ji>
zuS%h{MOpw_lvF6DkSnOdfTBn*zMOk9F$=G8FC|PsPWWRNol90$s!@8ViTUQu!gMA+>gfVEbc7>%#q}P8@A@FF0k4a|l
zfhB#qIu0B47TE>hqDh=ej4`av(Fy8Sc$}eP3T4n%uyVf!2tp0cNRQzQefGh48b3T6U?S6&BJjG$V&fA^MCbh{EkM3+Rh
zL%m003>;MxWl_u_wZY0<53000={9Ye<7AH$DxVs!y3pjira;C@$nGHz_
zgI8sJ01*cSjoo`RPyEzt*D3~vLQYicaw2&yEQhz=4Y`$kX~aQjd&sH^rJYt
z27_Ul#HD9zVkT+SIziYiU{6fUeM327JcX*e)-xpkBO6Kv`(L)+pzOg0QqFl3g)z;V
zBo4cbvKvOg;X~SA%*++aOo^D|<853BQqSa%QcO=-B`AH2`~8W(p+%0CHh-T?V2%r=J5!^I%n$ni?B59XJk
z);lA;a_&>xE-DL&QXwtwO+^R1_h8i}^8?KK5L_Ic@AgjzD}c+4i!uqp2vKbdim~ED
zuL7nG4J^PH5P$cG)W6r@hRuE2ntpo82pX^tE$@Jne9bXFPjI&WQRfp2-bs#j$!k=I
zWG+~tSa=2}a{*2bE>X;G(5=-`uSv)=X0A0{H!=`)msOdP8)l5Fi9KMtr)*VE3iLdr
zv}>sCg0Jwt14*5ShQvj9&X;9k;T+Q|Y!`f!nt~>=2p3MNT#-bJnt`iecGf8HOH9Q&Ta0F&Q8Okh7;$8(_2(uoQ7_XliqP
zRHQhtE!jqM_ZkAlnu
z3U%288;OR1{nNLh*-lN!|L_2((f!sKD}h_VEZ`G#X_SrH8Nsbf4u%`*xK5
zamX*<`WkxsLBeEx@g~UhI;8qH8=^AJUUMHCVx5gNwHWdv-C)x{fhABvGY&OrEKWmg
zdeU-WQ-d1!d0`U=4FZ*gKlWWwSRBRdU+<3xl)N*R;WW~@&3_ulwK#J?-V7T(B9nKj
zK?{VDGIFz%PDVahRGH~a5UDH%kRFo_7Jv%L1JD+eR&Wi@K$S>-)TYr91x#af3Ijm#
zl?W?W57?L4a^EA1|4xKm*D|RH%RW!A<9eEYwsKh1Yep&v{#>`)l`@c_lBC+Tkh9Xg
zQyzon#th`#(3sAGJhYl4r52Yxm8{Ehj76z0jiHIb8JZHqWIvR4A+|}rEIp=AU%P>t
z&u(j%jGK1tiC~<95jL5Jre{X31{2RX{2G^XF&jd!+`kmBL9fN(6D2X5j1+w^7!)3H
zac-8+#&|s^ZfO4s#YFkI-^R&a57Mdp4ZCXON&Pn{jtf4Ti2U5=y>dOQ)>_p>M`#6D
z^MWEX&g|H2AtK=t{?Zz^4p+|*ZN*6stOh7%DvA{16)(~OWyAaE62x(8Ytmfe#M9@D
z+4mMKKl~GN34UInAWeEv%Xl_D+1omuNYba)gyqlY^c=vL)PL|do%cscQ(eQZ4Oc5`
z0PFCKxrrii3Rkic|3JZQU`KY+NKpX5%Hx1k-koQT1Vy55JVFVKgagdDCVeoOAdOuabKIRM*3`PPxl5Sm=;KP
z+h%Y*hxfNgZla$>w_DC~%SZCvP70+Z8oP@Y75>4()|;--|0oMzG;qOq=C>3b^#9|`
zV?UH`;)G|chz?Zbr)Z|dEK%DJrlvxg1(qYEoyn)NzqfLI=>g#H+J1fC|ET!9CENf@
z-CO9}!fx#{ox-ma+|T_Z!1JIx<7w0+w_ANjHMk1t>Fttt?!=P1#ojKn9x49-d$h=t
z@W*}??B*uoz61trk4Bv}}j4UdZ)d@VL@?TUN4$}zAq@vZG9O*O$;%3B;xX1$9Bn5g@s@rd+6
z=Z^Pa*$p8=F-Aw6Qi^M()*EhWJTww*F8F5#O#|#P~nKFAC0`G9tg0dR9j%{l#dn
zteXOEzl&LhgLc(p3KUu?`-`R2gIf-}=yGrc?^)nS`OqeX^&rfq>Pjk^nL*D85-p4dWH?Vny3VL~IF$Mvi?>(h}I=Pw92n!l5KT32`H
zR`Sfv>?zGm0{t0zuO$LCOg0(9jke8fEiB7%z6nny05uL;|AxpF5iX}
zLE#M*8)84>prG9y3u3CwrCTCt(%O(9Raog&S`)d(|8%$rPh%;&sOknxIXLrohgLq5
zD=qo80ky`*5OSq4XgVh#Y6%rfIRt$wBzw~PD3kcX>T6&fAGf(D!BeEcGj2Z0$>7!i
zKkpzxa(utXJE`A)-{HanjAEr`-{a_Oc)Kn9#B`g@Nzc%K_$6vwXlYI0FuA#VJGrDv
zl7+8@rR6t}Pz!7s5@-w68U(ae{jCNKh&-P)<8WmA#U;)oLuO#W=cQC#a-*CdHC4dy
zXEGxA@suY0l$If_gav!TU6&$Vq$JF$-jT{LFO!8Q2Q=J8qW{cTqTI4KtruZNSSN*h
zR@ciMkn1FwN2;rgi)AZO7dR@H@s(w
z4VVr^&|YE5BMtgI;%R$!)p8wVGz_7$YOr$>DfHWLEU026fJ%?VtrJOBfIeRR$IXV{
z#v<(eKYZv^lj(otn|pbfKzbHiWKs6bhT34oU)$2c3g;Sg+JbtY%5&fLQ%QL^VhO;e
zI*R-#r+pE6D{9H_RyF~X375I#MfHCG7j?etpEjDUzrWt3Arp0(4o~+!oXI(?k
zAKI@Mu3Ma}9M6cUy$qOB{M+f1RDWnApNU=cXF~pFz68m+mVXHwv4b6t?hg*O
zdHBirstg9-g_Ju^4FnWYw0}qC1l%Z>f|wn^DI&)6`})+_j1HCN^B(C_&=DyFr#k