Skip to content

fix(swml): align the SWML method reference with platform behavior - #852

Merged
Devon-White merged 70 commits into
mainfrom
devon/implement-validated-swml-audit-findings
Oct 29, 2026
Merged

Devon-White merged 70 commits into
mainfrom
devon/implement-validated-swml-audit-findings

Conversation

@Devon-White

@Devon-White Devon-White commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR corrects the SWML method reference for calling and messaging. Each method's documented parameters, types, defaults, limits, results and callbacks now match how SignalWire runs SWML. The TypeSpec sources, the reference pages, and the generated calling and messaging JSON Schemas are updated together, so validators built on the schemas agree with the docs.

What changes in the docs

  • Control flow (cond, execute, goto, label, request, return, set, unset, switch, transfer, user_event)
    • Label and variable-name rules, and how goto counts jumps and when it stops.
    • How cond picks a branch: else can't appear beside when/then in the same entry, and there can be at most one else.
    • request timeout defaults, header rules and output variables.
    • Which value types return accepts and how nesting depth is limited.
    • Supported transfer destinations.
  • Telephony (answer, hangup, sleep, send_digits, send_sms, send_fax, receive_fax, sip_refer)
    • answer accepts codec lists, and hangup lists all six reasons.
    • sleep no longer accepts 0 or values below -1.
    • Digit rules and the 99-character limit for send_digits.
    • send_sms requires non-empty numbers and a text body.
    • Callback field paths and error events for fax.
    • The to alias and the REFER states for sip_refer.
  • Media (play, prompt, record, record_call, stop_record_call)
    • play loop, volume and text-to-speech settings, plus play_result.
    • prompt inputs limited to supported values, with corrected outputs and callbacks.
    • record supports direction both; sensitivity bounds and the max_length default for both recording methods.
    • Accurate beep and callback behavior.
  • Streaming and transcription (stream, stop_stream, tap, transcribe, live_transcribe, live_translate, denoise, stop_denoise, detect_machine)
    • tap rtp_ptime limits, and RTP targets must be public IPv4 addresses.
    • Voice activity detection defaults for live_transcribe and live_translate, and the order of translation directions.
    • start.hints for live_transcribe.
    • Variable references wherever they're supported.
    • Clearer result variables and callback payloads.
  • Conferences, rooms, queues and payments (connect, join_conference, join_room, enter_queue, pay)
    • connect documents stop_all_on_reject, ringback and codecs, and its confirm cond follows the same rules as cond.
    • join_conference drops result and adds min_participants and emit_call_quality.
    • enter_queue documents whisper_url and corrects queue results.
    • pay documents ACH payments and its result variables.
  • AI (ai, amazon_bedrock, ai_sidecar)
    • Saved agents (agent).
    • Hook names corrected to startup_hook and hangup_hook.
    • Contexts and steps: reset, gather_info and top-level steps.
    • Parameter defaults and ranges, including parameters that have no effect.
    • SWAIG data_map, actions and function parameters.
    • amazon_bedrock now lists only the prompt, params, functions and data_map fields it supports.
    • ai_sidecar corrects transcription, permissions, actions and tuning, and documents summarize requests, which don't need lang.
  • Messaging (reply, request, switch, execute, goto, transfer, return)
    • reply requires content; length limits, and queued vs. delivered status.
    • request results and body serialization.
    • How switch matches cases.
    • URL and inline destinations for execute and transfer.

The REST API reference changes only where it embeds SWML definitions. That covers SWML-based request bodies, the AI agent hints description, and calling.ai_sidecar, which now also accepts a summarize request.

Documents the schemas now reject

These changes affect schema validation only, for example editor tooling that uses the published JSON Schemas. Each change rejects input SignalWire already rejects, ignores or reads differently than the old docs suggested:

  • ai
    • Variable references are no longer accepted in AI settings that are read literally. These include numeric and boolean params, pronounce.ignore_case, POM numbered, function active, and webhook input_args_as_params and foreach.max.
    • digit_timeout must be between 1 and 29,999 ms, and hard_stop_time doesn't accept unit suffixes.
    • Context-level text is replaced by prompt.
    • data_map webhooks accept only GET and POST, and header values must be strings.
    • toggle_functions.function takes a single name, and unset_global_data/unset_meta_data don't accept an object.
  • amazon_bedrock no longer accepts ai-only fields:
    • prompt confidence, max_tokens and penalties, and post-prompt tuning
    • function active, the say and hangup actions, and webhook require_args
    • attention_timeout above 60,000 ms
  • ai_sidecar: direction must list both call legs.
  • cond, execute results and the connect confirm cond: no else beside when/then, and no more than one else.
  • label and goto reject an empty label. set and unset accept only plain variable names. sleep rejects 0 and values below -1. transfer rejects an empty dest. request header values must be strings.
  • connect rejects:
    • empty parallel, serial and serial_parallel lists
    • an http:// stream status_url on a destination inside parallel, serial or serial_parallel (the top-level status_url still accepts http://)
    • header names shorter than 3 characters, and empty header values
    • caller ID names longer than 70 characters or containing unsupported characters
    • answer inside a confirm cond
  • prompt rejects:
    • unknown speech engines and unsupported say_gender values
    • too-short timeouts and max_digits: 0
    • malformed language tags and voice names
    • a status_url that doesn't use HTTP or HTTPS
    • unknown ringtones and empty say text
    • silence or ring durations under 1 second (the silence limit also applies to play)
  • record and record_call: input_sensitivity must be 0–100. In record_call, control_id can't contain { } , ; ' \ or line breaks outside ${...}/%{...} references.
  • Other calling methods
    • detect_machine rejects initial_timeout, end_silence_timeout, machine_ready_timeout, machine_voice_threshold and machine_words_threshold below 1.
    • join_conference no longer accepts result.
    • live_translate direction needs at least one entry.
    • pay rejects:
      • literal booleans for security_code and postal_code
      • attempts on a payment prompt (the field is attempt)
      • unknown prompt targets
      • an http:// payment_connector_url
    • send_sms rejects empty numbers and an empty text-only body.
    • tap rtp_ptime must be a multiple of 10 between 10 and 120.
  • Messaging reply rejects an empty string, or an empty media list with no body, including inside inline switch outcomes.

Linked issues

Not included

The older combined schema file specs/swml/tsp-output/@typespec/json-schema/SWMLObject.json is no longer produced by the build, and this PR doesn't update it. The maintained schemas are specs/swml/calling/... and specs/swml/messaging/....

Checks

  • yarn build:specs passes, and a rebuild reproduces the committed generated files.
  • yarn fern-md-check passes.
  • YAML and JSON examples on the changed pages validate against the regenerated schemas, and each YAML/JSON pair is equivalent.

join_conference.max_participants default (signalwire/docs-legacy#812)

  • The documented default is now 250 in the spec and on the page. This follows Product's decision.
  • Notes for reviewers:
    • The platform currently uses 100000 when max_participants isn't set (the conference controller's built-in default). That's the value the reference documented before this change. Until the platform default changes, a conference with max_participants unset still accepts more than 250 participants.
    • The spec's upper bound for both max_participants and min_participants is now 250 (was 100000), so a document that sets either above 250 fails schema validation. The platform itself still accepts values up to 100000.
    • The min_participants description no longer mentions the 100000 fallback.
    • The Server SDKs' join_conference helper already defaults to and caps at 250.

Closes signalwire/docs-legacy#812.

hangup reason SIP codes (signalwire/docs-legacy#810)

  • The reason description now names the SIP response sent for an unanswered inbound call: 486 Busy Here when the reason is omitted, hangup, or busy; 603 Decline for any other value. This follows the FreeSWITCH cause-to-SIP mapping (USER_BUSY → 486, DECLINE → 603).
  • The rest of that ticket was already covered by this PR: all six reason values, behavior by call state, the default, hangup_result, and a realistic example.

Closes signalwire/docs-legacy#810.

connect from_name (signalwire/docs-legacy#811)

  • from_name was already documented on main. This PR adds the per-destination override and the length and character rules. The page states that it applies to SIP calls only; the platform sends it only to SIP destinations.

Closes signalwire/docs-legacy#811.

live_translate languages (signalwire/docs-legacy#778, with #888)

  • start.from_lang and start.to_lang no longer link only to the text-to-speech voices page. Speech recognition has to recognize each language too: speech_engine, Deepgram Nova-3 by default. Both fields now say to use a code the engine recognizes (linking Deepgram's Nova-3 table) and that the voice speaking it supports. This answers the Slack report that the page listed only output languages. The from_voice/to_voice wording was already fixed in this PR.
  • Server SDK voice guide: align language codes with the SWML reference #888 covers the Server SDK guide half and closes the ticket.

Also resolves

These were already fixed by changes in this PR:

Remaining SWML issues (verified against source)

Notes for reviewers

  • double_turns billing: it bills under its own SKU (ai_agent_double_turn_per_minute). The page says it's billed separately but gives no rate. Product should confirm the rate, and confirm the feature should be public.
  • ai_response with pay (likely bug): the content-redaction guide and the Python SDK's pay() put set ai_response before pay. In source, set fills in ${pay_result} when it runs, which would leave it empty. The new reference text says to set it after the method; the guide and SDK default need a live test.
  • Production speech engine: the "Nova-3 default" sentence assumes production sets OPENAI_ASR_ENGINE to deepgram:nova-3. Engineering should confirm.

Review follow-up (Manny's review of b6da4cb)

Merged origin/main (a3a9cbc), then addressed the review in 1e7a589:

  • pay: the "Successful tokenization" and "Failed tokenization" response tabs are back. A 200 reply with a token_id, no charge_id and no error succeeds when charge_amount is omitted or all zeros, and payment_token then carries that token_id. With any other amount, token_id is ignored. security_code is now typed string, and both security_code and postal_code list the string values the platform reads.
  • prompt: no say_voice default; the wording matches play.
  • live_translate: a voice without a supported lowercase engine prefix falls back to azure.en-US-RyanMultilingualNeural. The spec examples now use polly. instead of Polly..
  • connect: a per-destination stream status_url must be https://; the top-level status_url accepts either scheme, including for a single stream: destination.
  • detect_machine: the timing and threshold params must be at least 1.
  • send_digits: the page says that a sequence over 99 characters isn't sent but still sets send_digits_result to success.

Notes for reviewers (review follow-up)

  • detect_machine minimum: taken from the current validator source. I couldn't confirm which revision is deployed.
  • pay.postal_code with a digits-only value (likely bug): the platform reads postal_code as a boolean first, and any all-digit string counts as a number. "94107" is read as true, so the caller is prompted and the value is not sent; "00000" is read as false. The page now says this and uses "K1A 0B1" as the pass-through example. Engineering should decide whether a 5-digit ZIP should pass through.
  • live_translate voice casing: the engine prefix check is case-sensitive, so Polly.Joanna falls back to the Azure voice. No other live_translate page used a capitalized prefix.

…sult timing

cond: a when/then entry can no longer carry its own else; the else
fallback is a standalone entry, at most one per list, and the schema now
enforces both. The reference explains first-match selection, fallback and
no-match continuation, and the user-input example tests the collected
digit instead of the call type.

execute: result runs after the section or URL returns and before
on_return, only when a return_value was set; the object/array choice is
made on the shape of result itself. params documents the 63-level nesting
limit. The hosted-server example keeps some_info numeric and reads the
parameter it actually sends.

Audit keys:
- calling/cond::SWML-COND-001
- calling/cond::SWML-COND-002
- calling/cond::SWML-COND-003
- calling/cond::SWML-COND-004
- calling/execute::SWML-EXECUTE-001
- calling/execute::SWML-EXECUTE-002
- calling/execute::SWML-EXECUTE-003
- calling/execute::SWML-EXECUTE-004
- calling/execute::SWML-EXECUTE-005
- calling/execute::SWML-EXECUTE-006
…variables

timeout and connect_timeout default to 5 seconds (0 uses the default,
values above 30 are capped), not "no timeout". headers is a map of
header names to string values; the page now says which names are allowed
and which characters a value may contain, and that invalid entries are
skipped. method accepts a SWML variable reference. request_result is
defined by the 2xx response code, return_value is no longer listed as an
output, the unsupported 64-header response limit is gone, and the
62-level nesting limit for saved responses is documented.

Audit keys:
- calling/request::SWML-REQUEST-001
- calling/request::SWML-REQUEST-002
- calling/request::SWML-REQUEST-003
- calling/request::SWML-REQUEST-004
- calling/request::SWML-REQUEST-005
- calling/request::SWML-REQUEST-006
- calling/request::SWML-REQUEST-007
- calling/request::SWML-REQUEST-008
label: names must be nonempty (now enforced by the schema) and unique
within the section. goto.label gets its own description of the label it
jumps to instead of inheriting the label method's text. goto.max is
documented as accepting a SWML variable reference, as the schema already
allowed, and explains that the jump count belongs to the target label and
is shared by every goto to it.

Audit keys:
- calling/label::SWML-LABEL-001
- calling/label::SWML-LABEL-002
- calling/goto::SWML-GOTO-002
- calling/goto::SWML-GOTO-003
- calling/goto::SWML-GOTO-004
…and depth limit

return takes a string, number, object or array; booleans and null only
work nested or as the result of a variable reference. The page now
explains that variables are resolved in string values throughout the
returned data (keys stay literal, a lone reference keeps its JSON type),
that resolved values deeper than 62 object/array levels are not stored,
and adds an example returning an object built from variables.

Audit keys:
- calling/return::SWML-RETURN-001
- calling/return::SWML-RETURN-002
- calling/return::SWML-RETURN-003
set and unset only accept identifier-style variable names (an ASCII
letter or underscore, then up to 255 letters, digits or underscores,
with an optional vars. prefix). A shared SetVariableName scalar now
validates set's keys and unset's names in the schema. set documents the
62-level nesting limit for assigned values; unset explains that it
removes whole top-level variables of the current call and cannot target
child paths or other namespaces.

Audit keys:
- calling/set::SWML-SET-001
- calling/set::SWML-SET-002
- calling/unset::SWML-UNSET-001
- calling/unset::SWML-UNSET-002
The case entry is described as a value-to-methods mapping under
switch.case rather than a second variable name. The page and schema now
say the inspected value is converted to a string before lookup (so 5
matches "5"), and that with no match and no default no branch runs, a
relay_script_method_no_matching_condition warning is reported and
script_error is set, documented in a new Variables section.

Audit keys:
- calling/switch::SWML-SWITCH-001
- calling/switch::SWML-SWITCH-002
- calling/switch::SWML-SWITCH-003
…uto-answer

event is documented as the payload of one custom event (an object), and
can also be a SWML variable reference that resolves to an event object;
the schema now allows that form. The page explains that string values in
the payload are expanded, that SignalWire adds call details at the top
level so custom data belongs under its own key, and that the method
answers an unanswered inbound call before sending. The multi-field
example is retitled as one event, and a variable-payload example is added.

Audit keys:
- calling/user_event::SWML-USER_EVENT-001
- calling/user_event::SWML-USER_EVENT-002
- calling/user_event::SWML-USER_EVENT-003
- calling/user_event::SWML-USER_EVENT-004
- calling/user_event::SWML-USER_EVENT-005
…ocument dest and params limits

- Remove Relay application and inline JSON document destinations; state that
  relay: destinations are not supported.
- Require a nonempty dest (minLength 1) and document that a value expanding to
  an empty string stops execution.
- Document the 63-level object/array nesting limit on params after expansion.
- Fix the sub-parameters example to use object form (was arrays of objects).
- Interpolate params.where in the Express server example.

Audit keys:
calling/transfer::SWML-TRANSFER-001
calling/transfer::SWML-TRANSFER-002
calling/transfer::SWML-TRANSFER-003
calling/transfer::SWML-TRANSFER-004
calling/transfer::SWML-TRANSFER-006
calling/transfer::SWML-TRANSFER-007
…eferences

- New SleepDuration union (positive integer, -1, or SWML variable reference)
  used by both sleep.duration and the sleep shorthand, so 0 and values below -1
  no longer validate.
- MDX parameter types now include the SWML variable reference string form.

Audit keys:
calling/sleep::SWML-SLEEP-001
calling/sleep::SWML-SLEEP-002
…lling.error events

- Add `to` as an alias for `to_uri`; the schema now requires at least one of
  the two (both allowed), with to_uri taking precedence unless empty.
- Enumerate sip_refer_result values and params.state values, including the
  intermediate inProgress state.
- Qualify event_type and describe the calling.error event that can be sent
  to status_url instead of a calling.call.refer event.

Audit keys:
calling/sip_refer::SWML-SIP_REFER-001
calling/sip_refer::SWML-SIP_REFER-002
calling/sip_refer::SWML-SIP_REFER-003
…ocument answer_result

- codecs accepts a comma-separated string or an array of strings (entries may
  be variable references); valid codecs listed per call type, with the
  not-applied-and-error behavior for invalid preferences.
- username is applied only with a nonempty password after expansion.
- New Variables section for answer_result (success/failed) with its caveats.
- MDX max_duration type and prose cover SWML variable references; new
  YAML/JSON examples for codec arrays and a variable max_duration.

Audit keys:
calling/answer::SWML-ANSWER-001
calling/answer::SWML-ANSWER-002
calling/answer::SWML-ANSWER-003
calling/answer::SWML-ANSWER-004
calling/answer::SWML-ANSWER-005
…plain when reason applies

- reason accepts hangup, cancel, busy, noAnswer, decline, error, or a SWML
  variable reference.
- TypeSpec and MDX explain that reason only affects unanswered inbound calls
  (busy vs declined); answered calls end normally and unanswered outbound
  calls are canceled.
- MDX reason is now a ParamField; new Variables section for hangup_result;
  the reason example no longer answers first.

Audit keys:
calling/hangup::SWML-HANGUP-001
calling/hangup::SWML-HANGUP-002
calling/hangup::SWML-HANGUP-003
calling/hangup::SWML-HANGUP-004
…paths, document calling.error

- status_url accepts a SWML variable reference (existing SWMLVar scalar);
  the resolved value must still be an HTTP(S) URL.
- Callback fields use full params.fax.type / params.fax.params.* paths with a
  params.fax.params object entry, for finished and page events.
- Scope event_type to calling.call.fax and add an Error events section for
  calling.error (params.error.code, params.error.message, diagnostic
  params.request).
- Replace the invented API download route in the example document URL with
  a neutral illustrative URL.

Audit keys:
calling/receive_fax::SWML-RECEIVE_FAX-001
calling/receive_fax::SWML-RECEIVE_FAX-002
calling/receive_fax::SWML-RECEIVE_FAX-003
calling/receive_fax::SWML-RECEIVE_FAX-004
…callback paths and calling.error

- header_info falls back to the call's destination address and identity to
  the originating address (number for phone calls); removed the universal
  caller-ID default badge.
- Note that the method attempts to answer a live, unanswered inbound call.
- Qualify output variables: strings, check send_fax_result first, details may
  be unset on failure.
- Callback fields use full params.fax.* paths; event_type scoped to
  calling.call.fax with a new Error events section for calling.error.

Audit keys:
calling/send_fax::SWML-SEND_FAX-001
calling/send_fax::SWML-SEND_FAX-002
calling/send_fax::SWML-SEND_FAX-003
calling/send_fax::SWML-SEND_FAX-004
calling/send_fax::SWML-SEND_FAX-005
calling/send_fax::SWML-SEND_FAX-006
…-answer and result meaning

- digits: lowercase a-d accepted; at least one valid character and at most
  99 characters (including W/w) after variable expansion; longer sequences
  are not sent.
- Note that the method attempts to answer a live, unanswered inbound call.
- Explain what send_digits_result success and failed mean.

Audit keys:
calling/send_digits::SWML-SEND_DIGITS-001
calling/send_digits::SWML-SEND_DIGITS-002
calling/send_digits::SWML-SEND_DIGITS-003
calling/send_digits::SWML-SEND_DIGITS-004
…result variables

- to_number, from_number and the text-only body now have minLength 1, and
  the docs say they must be nonempty after variable expansion (media-branch
  body unchanged).
- Explain what send_sms_result success/failed mean and add the
  send_sms_response_code output variable.

Audit keys:
calling/send_sms::SWML-SEND_SMS-001
calling/send_sms::SWML-SEND_SMS-002
calling/send_sms::SWML-SEND_SMS-003
Withheld: calling/send_sms::SWML-SEND_SMS-004 (legacy schema artifact only;
canonical TypeSpec and calling schema already include status_callback)
… accurate ringtone and callback behavior

- Add loop (default 1, 0 repeats continuously) to the spec and page.
- Add volume, say_voice, say_language, say_gender ParamFields with the
  property > SWML variable > fallback precedence; drop the fixed
  Polly.Salli say_voice default.
- auto_answer: false does not currently prevent answering.
- urls entries may each be a literal or a SWML variable; status_url
  accepts a SWML variable.
- Accept the bong ringtone; align the ringtone list with the 50
  supported names and drop us-old.
- Document play_result, scope calling.call.play to playback
  notifications, and note the separate calling.error event.

Audit keys: calling/play::SWML-PLAY-001, SWML-PLAY-002, SWML-PLAY-003,
SWML-PLAY-004, SWML-PLAY-005, SWML-PLAY-006, SWML-PLAY-007,
SWML-PLAY-008, SWML-PLAY-009, SWML-PLAY-010, SWML-PLAY-011
…rect outputs and callbacks

- Spec: speech_engine enum (Google, Google.V2, Deepgram); say_gender
  male/female; say_voice, say_language, speech_language and status_url
  literal syntax; timeouts and max_digits at least 1 (max_digits capped
  at 100); volume accepts a SWML variable; speech_hints are literal
  strings; play accepts mixed literal/variable arrays.
- Shared play_url: supported ringtone names only, non-empty say: text,
  ring and silence durations of at least 1 second.
- Page: speech activators and initial_timeout clarified; prompt_value
  normalization, new prompt_value_raw, open prompt_result values;
  callbacks cover calling.call.collect and calling.call.play, drop
  params.final and start_of_input/finished result types, note
  calling.error; prompt.play is string or string array only.

Audit keys: calling/prompt::SWML-PROMPT-002, -003, -004, -005, -006,
-007, -008, -009, -010, -011, -012, -013, -014, -015, -016, -017,
-018, -019, -020, -021, -022, -023
…nds, max_length default, accurate beep and callbacks

- direction accepts both; format and direction accept SWML variables.
- input_sensitivity bounded to 0..100 in the schema.
- max_length defaults to 0 (no maximum length).
- beep plays as recording starts and can be in the recording.
- Callback: drop the error recording state, scope calling.call.record,
  note the separate calling.error event, add params.record.audio and
  full params.record.audio.* paths with mp4/both, add
  params.first_frame_time.

Audit keys: calling/record::SWML-RECORD-001, SWML-RECORD-002,
SWML-RECORD-003, SWML-RECORD-004, SWML-RECORD-005, SWML-RECORD-006,
SWML-RECORD-007, SWML-RECORD-008, SWML-RECORD-009, SWML-RECORD-010
…length default and record_call_start, correct beep and callbacks

- control_id: literal IDs exclude {, }, comma, semicolon, apostrophe,
  backslash and ASCII control characters (recording fails to start
  otherwise); fix the stop_record_call name in the description.
- format and direction accept SWML variables; input_sensitivity bounded
  0..100 and documented as ignored in continuous recording.
- max_length defaults to 0 (no maximum length).
- beep plays as recording starts and can be in the recording.
- Document record_call_start; callback drops the error state, scopes
  calling.call.record, notes calling.error, adds params.record.audio
  with full paths, and corrects first_frame_time presence.

Audit keys: calling/record_call::SWML-RECORD_CALL-001, -002, -003,
-004, -005, -006, -007, -008, -009, -010, -011, -012
…stop_record_call_result

Audit keys: calling/stop_record_call::SWML-STOP_RECORD_CALL-001
… accept variables

Explain that start.direction is ordered and how the first and second
directions map to from_lang/to_lang, voices and filters; require at least
one direction; correct the Deepgram vad_silence_ms default (700), the
omitted-voice default, and drop the bogus inject.direction default; note
that debug_level only applies when service debugging is enabled; document
the string summarize form; add false defaults for live_events/ai_summary;
accept SWML variable references for action, direction and its items,
inject.direction, filters and speech_engine.

Audit keys:
- calling/live_translate::SWML-LIVE_TRANSLATE-001
- calling/live_translate::SWML-LIVE_TRANSLATE-002
- calling/live_translate::SWML-LIVE_TRANSLATE-003
- calling/live_translate::SWML-LIVE_TRANSLATE-004
- calling/live_translate::SWML-LIVE_TRANSLATE-005
- calling/live_translate::SWML-LIVE_TRANSLATE-006
- calling/live_translate::SWML-LIVE_TRANSLATE-007
- calling/live_translate::SWML-LIVE_TRANSLATE-008
- calling/live_translate::SWML-LIVE_TRANSLATE-009
…ept variables

Document start.hints recognition vocabulary; correct the Deepgram
vad_silence_ms default (700) and explain that 0 falls back to the engine
default; explain that vad_thresh 0 uses 400 rather than a zero threshold;
document the string summarize form with an example; accept SWML variable
references for start.direction (whole array or items) and speech_engine.

Audit keys:
- calling/live_transcribe::SWML-LIVE_TRANSCRIBE-001
- calling/live_transcribe::SWML-LIVE_TRANSCRIBE-002
- calling/live_transcribe::SWML-LIVE_TRANSCRIBE-003
- calling/live_transcribe::SWML-LIVE_TRANSCRIBE-004
- calling/live_transcribe::SWML-LIVE_TRANSCRIBE-005
…en-US language

Say that transcribe records from the moment it runs until transcribe_stop
or the call ends, and transcribes asynchronously afterwards, instead of
claiming whole-call coverage completed at hangup; state the fixed en-US
language; document the recording (calling.call.transcribe), transcript
result and startup error events sent to status_url, with the recording
event fields; accept a SWML variable reference for status_url.

Audit keys:
- calling/transcribe::SWML-TRANSCRIBE-001
- calling/transcribe::SWML-TRANSCRIBE-002
- calling/transcribe::SWML-TRANSCRIBE-003
- calling/transcribe::SWML-TRANSCRIBE-004
…callback docs

Constrain rtp_ptime to multiples of 10 from 10 to 120 and show its
variable form; state that RTP destinations need a public IPv4 address and
port and replace the private address in the callback example; accept SWML
variable references for direction and codec; show the speak/PCMU defaults
and remove stray tokens; describe tap_ptime/tap_rate as the tapped call's
audio values; use full params.* paths for callback fields and note the
calling.error failure event; mention the answer attempt; document the
generated control_id in TypeSpec.

Audit keys:
- calling/tap::SWML-TAP-001
- calling/tap::SWML-TAP-002
- calling/tap::SWML-TAP-003
- calling/tap::SWML-TAP-004
- calling/tap::SWML-TAP-005
- calling/tap::SWML-TAP-006
- calling/tap::SWML-TAP-007
- calling/tap::SWML-TAP-008
- calling/tap::SWML-TAP-009
- calling/tap::SWML-TAP-010
…answer attempt

Accept SWML variable references for track, status_url and
status_url_method; state that calling.call.stream status events are
always sent with POST regardless of status_url_method; mention that the
method attempts to answer an unanswered inbound call first.

Audit keys:
- calling/stream::SWML-STREAM-001
- calling/stream::SWML-STREAM-002
- calling/stream::SWML-STREAM-003
Clarify that success means the stop request was accepted, not that the
stream has finished.

Audit keys:
- calling/stop_stream::SWML-STOP_STREAM-001
… the real connector/result contract

Customers can now see and validate the pay contract the engine actually runs:
security_code/postal_code take strings, payment_method accepts ach-debit with a
new bank_account_type, prompts use the singular attempt selector and may use a
play array instead of actions, the prompt targets/card types/error types match
what is recognized, payment_connector_url must be HTTPS, say_voice is
documented, and the connector request/response, status callback fields and
result variables (pay_payment_results is a JSON-encoded string, payment_token is
a token-type label, failed result) match deployed behaviour.

Audit keys:
- calling/pay::SWML-PAY-001 security_code string flag
- calling/pay::SWML-PAY-002 postal_code string input
- calling/pay::SWML-PAY-003 payment_method ach-debit
- calling/pay::SWML-PAY-004 bank_account_type
- calling/pay::SWML-PAY-005 prompts[].attempt
- calling/pay::SWML-PAY-006 prompts[].for targets
- calling/pay::SWML-PAY-007 card_type tokens
- calling/pay::SWML-PAY-008 error_type tokens
- calling/pay::SWML-PAY-009 prompts[].play / optional actions
- calling/pay::SWML-PAY-010 say_voice
- calling/pay::SWML-PAY-011 HTTPS payment_connector_url
- calling/pay::SWML-PAY-012 max_attempts semantics
- calling/pay::SWML-PAY-013 timeout minimum
- calling/pay::SWML-PAY-014 charge_amount default
- calling/pay::SWML-PAY-015 connector response charge_id
- calling/pay::SWML-PAY-016 payment_token meaning
- calling/pay::SWML-PAY-017 pay_payment_results JSON string
- calling/pay::SWML-PAY-018 decoded result fields
- calling/pay::SWML-PAY-019 connector_error presence
- calling/pay::SWML-PAY-020 pay_result failed
- calling/pay::SWML-PAY-021 connector parameters
- calling/pay::SWML-PAY-022 connector raw card values
- calling/pay::SWML-PAY-023 progress attempt/bank and ACH connector fields
- calling/pay::SWML-PAY-024 masked progress fields
- calling/pay::SWML-PAY-025 action type descriptions
- calling/pay::SWML-PAY-026 payment-completed/failed prompt semantics
… and status events

Adds the caller-side whisper_url input, lets status_url take a SWML variable,
limits queue_result to the outcomes the method actually returns, explains when
wait_time/entry_position/entry_size are set, and corrects the meaning of the
status event id and the dequeue event.

Audit keys:
- calling/enter_queue::SWML-ENTER_QUEUE-001 whisper_url
- calling/enter_queue::SWML-ENTER_QUEUE-004 status_url variable alternative
- calling/enter_queue::SWML-ENTER_QUEUE-005 queue_result outcomes
- calling/enter_queue::SWML-ENTER_QUEUE-006 result variables availability
- calling/enter_queue::SWML-ENTER_QUEUE-007 params.id is the queue id
- calling/enter_queue::SWML-ENTER_QUEUE-008 dequeue precedes the bridge

Not changed (legacy orphaned schema, no generation route):
- calling/enter_queue::SWML-ENTER_QUEUE-002
- calling/enter_queue::SWML-ENTER_QUEUE-003
…e, name length, and failure reason

Explains that join_room first tries to answer an unanswered inbound call, that
an automatically created room is set to be deleted after its room sessions end,
that the resolved room name can be at most 100 characters, and adds the
join_room_failed_reason variable.

Audit keys:
- calling/join_room::SWML-JOIN_ROOM-001 resolved name length (prose only)
- calling/join_room::SWML-JOIN_ROOM-002 join_room_failed_reason
- calling/join_room::SWML-JOIN_ROOM-003 auto-created room lifecycle
- calling/join_room::SWML-JOIN_ROOM-004 automatic answer attempt
@Devon-White Devon-White changed the title fix(swml): apply verified SWML method audit findings (410 confirmed: 397 fixed, 4 partial, 2 already fixed, 7 withheld) fix(swml): align the SWML method reference with platform behavior Oct 25, 2026
Devon-White and others added 3 commits October 26, 2026 08:30
…ptions

Follow-up to the SWML reference corrections, after a review against the
deployed behavior.

Corrections:
- join_conference: an invalid min_participants from a variable makes the join
  fail (a value above 100000 without max_participants falls back to 2); the
  blanket "falls back to 2" claim is removed.
- pay: payment_connector_url accepts a SWML variable again (literals must be
  HTTPS); max_attempts outside 1-3 uses the default; the unsupported ringtone
  "no time limit" claim is removed; TTS gender defaults to female.
- connect: from_name accepts text mixed with variables; the 1-70 character rule
  applies after expansion. An empty serial_parallel group is skipped.
- ai_sidecar: POM numbering flags and MCP resources accept variables again,
  without changing ai or amazon_bedrock.
- ai: context post_prompt no longer offers model; valid_steps, reset, data_map
  webhook fallback, settings action and form_param behavior stated precisely;
  transfer action documents the string form and required dest.
- request: separates responses too deep to store from responses that can't be
  parsed. stream: status_url_method has no effect on status callbacks.
- messaging: return in an executed document sets the caller's return_value;
  overview fetch methods, execute/transfer destinations and variable access
  corrected; errors page lists the execute fetch and document errors.
- Calling overview: transcribe and join_conference.wait_url entries corrected.

Descriptions across the changed specs and pages were also reworded to explain
usage in customer terms. Regenerated SWML JSON Schemas and REST OpenAPI.
- connect: document `call:` destinations, the leading `+` on E.164 numbers,
  `sips:`, `wss://` streams, prefix case rules, and the `from` formats (#547)
- ai.languages and Voices and languages: the `:model` suffix needs an engine
  prefix and overrides `languages[].model`; link the voice-unresolved
  warning (#523, #524)
- ai.SWAIG and Errors: say which failures produce SWML error codes and
  where to trace the rest (#524)
- ai and ai.params: link Conversation analytics from the post-prompt and
  debug webhook references (#519)
- ai.SWAIG and the webhook security guide: protect credentials in the SWML
  document with HTTPS, signature checks, and basic auth in the URL (#522)
@Devon-White

Copy link
Copy Markdown
Collaborator Author

SWML issues addressed in b6da4cb

Before writing anything, I checked each SWML issue on the tracker against the platform source (mod_infrastructure 268f330, mod_openai 21281c1, prime-rails 2bb2f88f2). This commit adds what the source confirmed.

#547: connect destinations

  • The call: destination is documented, with a new Connect to a call in progress example. The target call:

    • must be in the same project
    • can't be the call running connect
    • can't be controlled by cXML
    • must be the only destination

    call_state_url and call_state_events aren't allowed with it. When the call can't be bridged, connect_result is failed.

  • Phone numbers need the leading +; 15552345678 is rejected.

  • sips: is accepted.

  • stream: requires wss://; stream:ws:// is rejected.

  • queue:, call: and stream: are case-sensitive. sip: and sips: aren't.

  • from rejects queue:, call: and stream: values.

  • The queue:/stream rules in parallel, serial, serial_parallel, call_state_url and call_state_events now include call:.

  • These changes are in both the TypeSpec and the MDX, and the schemas and OpenAPI are regenerated.

  • transfer_after_bridge was already removed on main by Name the after-queue field what the engine reads, and drop the stale requirement #709. The boolean/string/array form of ringback was already in this PR.

#523: voice string format

  • ai.languages and Voices and languages now say the :model suffix is read only when the string starts with an engine. For example, rachel:eleven_flash_v2_5 is taken whole as the voice ID.
  • In ai, a suffix takes precedence over languages[].model.

#524: links to the Errors page

  • ai.languages, ai.SWAIG, Voices and languages and the Errors page now link to each other.
  • I limited what each link claims to what the source supports:
    • For ai, the only SWML error codes are an invalid ai block and relay_script_voice_unresolved.
    • A SWAIG function that fails during the call, or a TTS error mid-call, produces no code and doesn't set script_error. The pages point to swaig_log in the post-prompt report and to the debug webhook instead.

#519: AI observability

The guide and the payload reference already existed (Conversation analytics, post_prompt_url callback, debug_webhook_url callback). They weren't linked to each other, so this commit adds links to Conversation analytics from the ai post-prompt reference and the ai.params debug webhook reference.

#522: credentials in SWML documents

  • ai.SWAIG has a new Protect the SWML document warning: credentials in web_hook_url or web_hook_auth_password can be read by anyone who can fetch the document.
  • The webhook security guide now says to serve the document over HTTPS, because http:// is accepted but travels unencrypted. To add a password on top of the signature, put basic auth credentials in the URL. SignalWire strips them and sends an Authorization header.
  • Source:
    • Calls: the fetch honours URL basic auth when a password is present, and signs with both X-Signalwire-Signature and X-Signalwire-SHA256-Signature.
    • Messaging: also honours URL basic auth, and signs with SHA-1 only.
    • The guide already covered both signature schemes.

Already resolved

#521 was fixed on main before this PR and can be closed separately. #520 is about the Compatibility API and isn't in scope here.

Deliberately left out

  • ringback object form ({url, volume}). It passes validation, but create_play_inner in swml.c only plays string items, so an object ringback is silently skipped. I didn't document it until engineering fixes this.
  • SWML dial isn't documented because it's experimental.
  • What happens to a parked call: target after the bridge ends isn't documented because the source doesn't settle it.
  • languages[].engine deprecation. The engine still reads this field, so no deprecation notice was added.

Engineering bugs found

  • An object ringback validates but plays nothing (swml.c, create_play_inner).
  • relay_link_intercept_app (relay.c, around line 6470): when the call: target is on another node, a wrong-project or cXML target returns error instead of notFound or busy.

Unverified

  • Production FORCE_HTTPS_REQUESTS. I don't know the deployed value. If it's on, http:// fetches may be upgraded or rejected, and the "http:// is accepted" wording would need changing.
  • Deployed source revisions. I couldn't confirm that the revisions checked above are the ones in production.

Checks

yarn build:specs passes, and yarn fern-md-check reports all 2828 MDX files valid.

Pages to preview

  • /docs/swml/reference/calling/connect
  • /docs/swml/reference/calling/ai/languages
  • /docs/swml/reference/calling/ai/swaig
  • /docs/swml/reference/calling/ai
  • /docs/swml/reference/calling/ai/params
  • /docs/swml/reference/errors
  • /docs/swml/guides/webhook-security
  • /docs/platform/voice/tts

@Manny-r31

Copy link
Copy Markdown
Collaborator

Review verdict: Changes requested (one blocker, rest is minor)

Reviewed head b6da4cb0ae against mod_infrastructure main 268f330 (the revision the PR cites), mod_openai 21281c1 and prime-rails main. CI passes, both SWMLObject.json files parse and all patterns compile. The large majority of the rewrite matches source closely (cond/switch/goto/return semantics, the 62/63 depth limits in swml.h:154-161, request timeouts, connect validation, join_conference params, tap ptime, ringtone list, AI param ranges, errors.mdx).

Blocker

  1. calling/pay/index.mdx (Successful transaction tab ~L600-630, pay_payment_results.payment_token ~L690): tokenization contract contradicts source.
    The page says SignalWire reads only charge_id, error_code and error_message, that a non-empty charge_id is required for success even for tokenization, and that payment_token is only the requested token-type label. It also removes the token_id tokenization tabs.
    At 268f330 (#1921, "pay: accept a tokenizing connector's token_id and numeric pay params"), relay_pay.c reads token_id from the connector reply (L1919-1929). A 200 reply with token_id, no charge_id and no error on a zero-amount pay is a success (pay_connector_replied_token / pay_connector_created_token, L2238-2250 and L2340-2362), and payment_token then carries the connector's token_id (pay_payment_token_str, L2252-2259). token_id is only ignored, with a warning, when the amount is non-zero. Please restore the tokenization response tabs and describe payment_token accordingly.

Minor

  1. prompt/main.tsp say_voice keeps default: "Polly.Salli" (emitted in the calling SWMLObject.json). The description says the say_voice variable is used when unset, and the PR already removed this default from play. The engine has no such default; it passes a voice only if the param or the say_voice variable is set (swml.c:1233-1241, shared by play and prompt). Same pattern for say_gender "female".
  2. live_translate/main.tsp voice default: the fallback to azure.en-US-RyanMultilingualNeural also applies when the voice has no recognised engine prefix (translate_transcribe.c), so a bare voice name silently becomes the Azure voice. Worth one sentence.
  3. hangup/main.tsp reason enum is stricter than the engine, which accepts any string and treats anything other than hangup/busy as decline. Fine if intentional; the prose is already accurate.
  4. pay/main.tsp security_code is typed "true" | "false", but the engine evaluates it with switch_true, so "yes", "on", "1" also work. Also note this is schema-breaking for editors that previously accepted a YAML boolean.
  5. connect/index.mdx status_url: per-device stream status_url is https-only (swml_schema.c), top-level is http or https. Not stated for the single-stream-destination case.

Questions

  1. send_digits "at most 99 characters... a longer sequence isn't sent": SWML send_digits is validated by is_sendable_dtmf_digits (relay_apis.c, regex only, no length check). The 98-char cap in relay_apis.c belongs to a different validator (is_optional_sendable_dtmf_digits_with_pauses). Which source backs the 99 cap? If it is the FreeSWITCH core, a pointer would help.
  2. detect_machine initial_timeout / end_silence_timeout / machine_ready_timeout keep minValue(0) and are now documented "in seconds". The engine validates them with ks_json_check_number_is_positive; does that reject 0 and sub-1 values such as 0.5? If so, the min should be > 0.

Not cross-checked against source (read for internal consistency and valid examples only): receive_fax, send_fax, send_sms, user_event, denoise, ai prompt contexts/steps, ai_sidecar, bedrock SWAIG, and the messaging reply/request/switch/return/transfer pages.

Per Product's decision, the documented default is 250. The platform
currently falls back to 100000 when max_participants is unset; see the
PR notes.

Refs signalwire/docs-legacy#812.
With the documented max_participants default now 250, the note about a
100000 fallback no longer applies.
Match the spec bounds to the documented 250 limit (Product decision),
and state the maximum in the max_participants description.

Refs signalwire/docs-legacy#812.
busy maps to USER_BUSY (486 Busy Here) and decline to DECLINE
(603 Decline) in the FreeSWITCH fork's cause-to-SIP mapping.

Refs signalwire/docs-legacy#810.
from_lang and to_lang are recognized by speech_engine (Deepgram Nova-3
by default) as well as spoken by a voice, so link Deepgram's Nova-3
language table instead of only the Voices and languages page.

Refs signalwire/docs-legacy#778.
join_conference sets only join_conference_result (joined or failed); it
never sets return_value.

Refs #877.
prompt: show say_voice, say_language, and speech_language in use, and
stop naming auto_answer among play properties (it has no effect).
connect: show ringback with answer_on_bridge.

Refs signalwire/docs-legacy#483, signalwire/docs-legacy#689.
specs/swml/tsp-output/ is built by nothing, and the REST ai-agent
models/ai/ tree (including the LanguagesBase fork) is imported by
nothing; the REST AI agent imports the SWML models directly.

Refs #590.
…uble turns, ai_response

- multi works only on Deepgram and code-switches across a narrower set;
  auto fillers are skipped for multi (#597)
- filler picks never repeat the previous phrase (#597)
- a :0 turn_filler_sources suffix does not clear turn_filler_min_ms (#597)
- check_time, wait_for_user, wait_seconds, adjust_response_latency fillers
  apply only to native functions (#597)
- listen_language takes at most three codes (#597)
- codes Nova-3 lacks fall back to Nova-2, then Google (#597)
- fix two bare #anchor links in commented spec text (#608)
- document params.double_turns and languages[].double_turn_fillers (#589)
- document the ai_response variable (#618)

Refs #597, #608, #589, #618.
…ng/ai

Every link now uses the canonical path instead of relying on the
wildcard redirect, and the broken data-map #output anchor points at
#outputaction. Includes regenerated spec output.

Refs #599.
…ct_machine

- pay: restore the tokenization response tabs. A 200 reply with token_id,
  no charge_id and no error succeeds when charge_amount is zero or omitted,
  and payment_token then carries the token_id. Describe security_code and
  postal_code as the strings the platform reads, and widen security_code
  to string.
- prompt: drop the Polly.Salli default for say_voice, matching play.
- live_translate: a voice without a supported lowercase engine prefix is
  replaced by azure.en-US-RyanMultilingualNeural; lowercase the examples.
- connect: per-destination stream status_url must be https; the top-level
  status_url accepts http or https, including for a single stream.
- detect_machine: timing and threshold params must be at least 1.
- send_digits: a sequence over 99 characters still reports success.
- Regenerate the SWML schema and REST OpenAPI.
@Devon-White

Copy link
Copy Markdown
Collaborator Author

@Manny-r31 thanks for the review. I merged main into the branch (a3a9cbc) and addressed the items in 1e7a589. Item by item:

1. pay tokenization (blocker): fixed in 1e7a589. You're right. A 200 reply that has a token_id, no charge_id and no error counts as a success when charge_amount is omitted (it defaults to 0.00) or written only with zeros and at most one decimal point. payment_token then carries the connector's token_id. For any other amount, token_id is ignored and the reply needs a charge_id. The page now:

  • restores the "Successful tokenization" and "Failed tokenization" tabs beside "Successful charge" and "Failed charge"
  • says SignalWire reads charge_id, token_id, error_code and error_message
  • drops the "non-empty charge_id whatever the charge_amount" sentence
  • notes that a reply with both IDs is treated as a charge, so payment_token is then the token type
  • describes payment_token as the token_id for a successful tokenization and the requested token type otherwise. The token is also reported if the caller hangs up after the connector created it.

The .tsp didn't carry these claims, so the spec only changed for item 5.

2. prompt.say_voice default: fixed in 1e7a589. I removed the Polly.Salli default from the spec and the page. The wording now matches play: the say_voice variable is used when set, and otherwise a voice is picked from the language and gender. say_gender keeps its female default, because the platform falls back to female when neither the param nor the variable is set.

3. live_translate voice fallback: fixed in 1e7a589. from_voice and to_voice now say that a voice is replaced by azure.en-US-RyanMultilingualNeural unless it starts with a supported engine prefix (polly., gcloud., azure., elevenlabs. and so on), for example rachel or amazon.Joanna. The prefix check is case-sensitive, so Polly.Joanna falls back as well. The spec examples used Polly.Joanna and Polly.Lucia, so I changed them to polly..

4. hangup.reason enum: no change. The engine doesn't accept arbitrary strings. A SWML hangup sends an end request that accepts only hangup, cancel, busy, noAnswer, decline and error. Any other value fails, and hangup_result is set to failed. The six-value enum matches that.

5. pay.security_code: fixed in 1e7a589. The type is now string instead of "true" | "false". The description says to pass a quoted string (an unquoted YAML/JSON boolean is still rejected), and that true, t, yes, on, enabled, active, allow (any case) and non-zero whole numbers such as "1" turn the prompt on, while any other value turns it off. postal_code is its only sibling read the same way, and was already string. Its description now lists the same true values, the false values (false, f, no, off, disabled, inactive, disallow, "0"), and says that any other value is sent as the postal code.

Checking this turned up a gotcha: a postal code made only of digits is read as a number first. "94107" therefore counts as true, so the caller is prompted and the value isn't sent. The page used "94107" as its pass-through example. It now documents this and uses "K1A 0B1" as the example. I flagged the behavior in the PR notes for engineering.

On the schema break: rejecting a YAML boolean is deliberate, and the PR body already lists it under "Documents the schemas now reject". SWML itself rejects a boolean here.

6. connect status_url: fixed in 1e7a589. A stream status_url on a destination inside parallel, serial or serial_parallel now uses an https://-only type in the spec, and the page says http:// is rejected there. The top-level connect.status_url is described as HTTP or HTTPS in all four tabs. In the Single tab, it also says that http:// still works when to is one stream: destination.

7. send_digits 99-character cap: docs kept, one sentence added in 1e7a589. The cap is real, but the SWML validator doesn't enforce it. It's applied when the platform sends the DTMF string: anything longer than 99 characters is dropped without sending a tone. SWML doesn't see that failure, so send_digits_result is still success. The page and spec now say so, because the existing "success doesn't confirm the tones were transmitted" note didn't cover this case.

8. detect_machine minimums: fixed in 1e7a589. Yes. The check requires the whole-number part to be greater than zero, so 0 and 0.5 are rejected (and 1.25 passes). initial_timeout, end_silence_timeout, machine_ready_timeout, machine_voice_threshold and machine_words_threshold now have minValue(1) and say "at least 1" on the page. The PR notes say I couldn't confirm which revision of that check is deployed.

Checks

  • yarn build:specs passes. The SWML calling schema and the REST OpenAPI are regenerated and committed with the source.
  • yarn fern-md-check passes (2830 MDX files valid).
  • Every YAML and JSON example on the changed pages (connect, detect_machine, live_translate, pay, prompt, send_digits) validates against the regenerated calling schema.
  • I spot-checked the new constraints. The schema accepts security_code: "yes" and rejects security_code: true. It rejects an http:// per-destination stream status_url and accepts a top-level one. It rejects initial_timeout: 0.5 and accepts 1.

# Conflicts:
#	fern/apis/signalwire-rest/openapi.yaml
#	fern/products/swml/pages/reference/methods/calling/enter_queue.mdx
#	specs/swml/calling/Methods/enter_queue/main.tsp
#	specs/swml/calling/tsp-output/@typespec/json-schema/SWMLObject.json
@Devon-White
Devon-White merged commit eb1ce17 into main Oct 29, 2026
2 checks passed
@Devon-White
Devon-White deleted the devon/implement-validated-swml-audit-findings branch October 29, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment