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 10c58c1a15..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 - - The fundamentals of your first calling app + + 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