From dcaf662c18d0b075f33d2d1a4e1c4a87b535fe5d Mon Sep 17 00:00:00 2001 From: August Date: Thu, 10 Sep 2026 13:12:51 +0000 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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. - - -