Handling sensitive content

View as MarkdownOpen in Claude

Bayview Taxi’s dispatcher agent quotes fares, books rides, and takes payment for them. Only one of those three needs a card number, and the agent doesn’t have to be the thing that hears it.

That is the shape of most sensitive-content problems on a voice call. A value has to reach something during the conversation, but it rarely has to reach the language model. The platform gives you ways to collect it, speak it, and remember it while the model stays out of the loop, and content redaction for the cases where it genuinely can’t.

Decide what the agent needs to know

Before reaching for redaction, ask what the agent actually has to do with the value. Usually it needs an outcome, not the value itself. The dispatcher doesn’t need the card number; it needs to know whether the payment went through. It doesn’t need the caller’s full account number; it needs to know whether the account is valid.

What you needWhere to do itDoes the model see the value?
Take a card paymentThe pay methodNo
Collect digits, such as an account number or PINA prompt inside a SWML actionNo, unless you put them in the reply
Read a value back to the callerA SWML action with its own text-to-speechNo
Carry a verified value between functionsmeta_dataNo
Reason about the value in conversationThe conversation, plus redactionYes

Only the last row is a redaction problem. The rest are design choices.

Perform actions outside the agent’s context

The first four rows all work the same way. A SWAIG function returns an action, the platform carries out that action on the call, and the sensitive value moves between the caller, a SWML method, and your server without passing through the model. What the agent gets back is whatever outcome you choose to report.

Take a payment

The pay method collects card details through dual-tone multi-frequency (DTMF) keypad input, validates them, and hands them to your payment connector. The digits are never spoken, never transcribed, and never enter the conversation. Your agent triggers it from a SWAIG function and learns only what you choose to tell it afterward.

1def charge_fare(self, args, raw_data):
2 fare = raw_data.get("global_data", {}).get("fare")
3
4 return (
5 FunctionResult("Starting the payment now.")
6 .pay(
7 payment_connector_url="https://example.com/payment",
8 charge_amount=f"{fare:.2f}",
9 description="Bayview Taxi fare",
10 security_code=True,
11 postal_code=True,
12 ai_response=(
13 "The payment result was ${pay_result}. Tell the caller the outcome "
14 "and, if it succeeded, confirm the pickup."
15 ),
16 )
17 )

The ai_response variable is the whole control surface here. When the payment finishes, whatever you set it to is handed back to the agent as its next piece of context, and nothing else from the payment flow is. Write a status into it and the agent knows the status. Write ${pay_result} and it knows the result code. There is no field you could set that would leak the card number unless you put it there yourself.

The Server SDK’s pay() sets a sensible ai_response for you, describing the result and telling the agent not to discuss the collection itself. Override it when you want the agent to say something specific about what happens next.

Collect digits

The same pattern works for anything a caller can type: an account number, a PIN, a policy number, the last four digits of a card. The prompt method plays a message and collects DTMF digits into prompt_value, and request posts them to your server.

1def verify_account(self, args, raw_data):
2 swml = {
3 "version": "1.0.0",
4 "sections": {
5 "main": [
6 {
7 "prompt": {
8 "play": "say:Please enter your six digit account number, then press pound.",
9 "max_digits": 6,
10 "terminators": "#",
11 }
12 },
13 {
14 "request": {
15 "url": "https://example.com/verify-account",
16 "method": "POST",
17 "headers": {"Content-Type": "application/json"},
18 "body": {"account_number": "${prompt_value}"},
19 "save_variables": True,
20 }
21 },
22 {
23 "set": {
24 "ai_response": "Account verification came back ${status}. "
25 "Tell the caller and continue."
26 }
27 },
28 ]
29 },
30 }
31
32 return FunctionResult("Let me verify the account.").execute_swml(swml)

save_variables turns the JSON your server replies with into SWML variables, which is where ${status} comes from. The digits reach your server and your server alone.

ai_response is expanded against every variable in scope, prompt_value included. Writing "You entered ${prompt_value}" puts the digits straight into the model’s context and undoes the whole exercise. Report the verdict, not the input.

Speak a value

Sometimes the caller needs to hear a value read back. There are two ways to do that, and they differ in what ends up in the call’s records.

A say action hands text to the agent’s own voice, and the agent speaks it verbatim. It lands in the call’s conversation record, and redaction will not mask it there: redaction rewrites what the caller said and what the model generated, not text your handler supplied. Anything sensitive in a say action shows up in your records exactly as you wrote it.

A SWML action playing say: text uses a separate text-to-speech pass outside the AI session entirely. Nothing about it reaches the model, and nothing is added to the conversation unless you set ai_response. Reach for this one when the value must stay out of both the model’s context and the conversation record.

1def confirm_charge(self, args, raw_data):
2 last_four = raw_data.get("meta_data", {}).get("card_last_four")
3 spoken = " ".join(last_four)
4
5 swml = {
6 "version": "1.0.0",
7 "sections": {
8 "main": [
9 {
10 "play": {
11 "url": f"say:The card ending in {spoken} has been charged."
12 }
13 },
14 {
15 "set": {
16 "ai_response": "The caller has been told their card was charged. "
17 "Confirm the pickup time."
18 }
19 },
20 ]
21 },
22 }
23
24 return FunctionResult("Reading the card back now.").execute_swml(swml)

Remember a value

An agent that has to hold a value across several turns is an agent that has the value in its context. Store it beside the conversation instead, and let your handlers read it back.

Two stores are available, and the difference between them matters here.

meta_data is a keyed store rather than one shared bag. Each function carries a meta_data_token, and every function carrying the same token reads and writes the same store, while a function with a different token sees nothing of it. Leave the token off and SignalWire derives one from that function’s web_hook_url together with the credentials you set for it, so two functions share a store by default only when their handler URL and its credentials both match. Nothing in meta_data is interpolated into the prompt, so nothing you put there reaches the model. This is the right place for a payment token, a verified account ID, or anything else your handlers need and the conversation does not.

global_data is call-scoped state that your handlers also receive, but it is additionally made available to the prompt for interpolation. A value in global_data reaches the model if, and only if, your prompt references it by name. That makes it a good fit for things the agent genuinely should know about, such as the caller’s first name or the fare it just quoted, and a poor fit for a card number.

1def record_payment(self, args, raw_data):
2 return (
3 FunctionResult("The payment is confirmed. Offer to text the receipt.")
4 .set_metadata({"payment_token": "tok_9f42", "card_last_four": "4242"})
5 .update_global_data({"fare_paid": True})
6 )

A later function reads the token out of raw_data["meta_data"] and takes no arguments of its own, which means there is no argument for the agent to get wrong or a caller to talk it out of. Tool calling covers that pattern in full, and state management covers the lifecycle of both stores.

Redact conversation records

Some conversations leave you no choice about the model hearing the value. A caller reads their card number aloud before the agent can offer the keypad. A health intake line has to take a date of birth in conversation. An agent has to confirm a value it was told earlier.

For those, content redaction rewrites the conversation text — what the caller says and what the agent generates — in the completed turns that reach AI events, webhook payloads, and the post-conversation call log. The conversation itself is untouched. The caller hears the agent normally and the agent understands the caller perfectly. Only the recorded text changes.

Recorded text is the limit of it: redaction reaches the text of a turn and not the structured fields the platform records beside that turn. Coverage is narrower than every record of the call, and what gets masked sets out every surface, masked and unmasked.

What redaction changes: the caller speaks a real card number, the AI agent receives the real value and replies normally, the caller hears the reply in full, and the recorded conversation text shows the number masked as four dashes while the structured fields recorded beside that turn still hold the real value.

Enable redaction

Turn redaction on with a single parameter, redact_prompt, in the ai method’s params block:

1version: 1.0.0
2sections:
3 main:
4 - answer: {}
5 - ai:
6 prompt:
7 text: You are the dispatcher for Bayview Taxi. Book rides and take payment for them.
8 params:
9 redact_prompt: credit card numbers, CVVs, social security numbers, and full names

The value of redact_prompt does two jobs: it switches redaction on, and it describes in plain language what counts as sensitive. Matching text is replaced with ---- in the conversation turns the platform records and delivers, and only there — what gets masked has the full list of surfaces.

Redaction rewrites the conversation text the platform records and transmits, not what the model processes. The agent still receives the caller’s real words on every turn, which is what keeps the conversation working. If your requirement is to keep a value away from the model, use one of the patterns above instead. Redaction is the fallback for when you can’t.

What gets masked

Redaction covers both sides of the conversation. Your redact_prompt description guides the agent to treat matching content as sensitive whenever it speaks: the first time it says it, when it repeats it back, and when it confirms it. What the caller says is masked separately, once the turn is complete and before that turn is stored or delivered. Text your handler supplies is neither of those, so a say action’s text is recorded as you wrote it.

SurfaceWhat appears
Audio the caller hearsThe real content, spoken in full
Text the model receivesThe real content, every turn
Conversation text in completed-turn AI events and webhook payloadsMasked as ----
Conversation text in the post-conversation call_log and raw_call_logMasked as ----
Structured fields recorded beside a turn, such as a recognized entity or a tool result’s original_resultThe real content
Turn entries on the call timelineThose same structured fields, unmasked
Interim events sent while the caller is still speaking, including partial transcripts and transparent barge-inThe real content
Timeline entries recording a text transformation, such as transcription cleanup, text normalization, or pronunciationThe real content
Arguments your SWAIG functions receiveThe real content

The structured-field row is the one to design around. Alongside each turn’s text, the platform records what it worked out about that turn, and none of that is rewritten. A turn where the caller read out a phone number, a card number, or a social security number can carry an entity field holding that value in canonical form, and a tool result that was shortened before the model saw it carries the full original in original_result. Both travel with the turn into your webhook payloads and the call log. Turn entries on the call timeline are built from these fields rather than from the turn’s own text, so masking the text leaves them unchanged.

Interim events fire while a turn is still in progress, before there is a finished turn to mask, so an application that receives them sees the caller’s raw words. Where no unmasked text may leave the platform, keep those events out of your own systems and turn transparent_barge off.

Timeline entries that record a transformation carry both the text before and the text after, so a transcription-cleanup, normalization, or pronunciation entry can hold the original alongside the masked version.

The SWAIG row is deliberate. Your handler is the code that has to act on the value, so the arguments the agent extracted arrive intact. What is masked in a SWAIG payload is the conversation text carried alongside them. Treat your own handler as a place where sensitive data lands, and log accordingly.

Redaction is performed by AI, not by a fixed pattern-matcher, which is why it catches a card number read back one digit at a time. Within the conversation text it errs on the side of masking too much rather than too little. It is not a control for keeping a value out of every record: where that is the requirement, keep the value out of the conversation using one of the patterns above.

Keep it fast

Redaction runs inline on the turn, not on a thread of its own. Masking a turn is a separate model call that has to finish before the turn does, so its latency lands in the pause the caller hears. Two companion parameters keep that work quick.

utility_model selects the model used for supporting passes like redaction and transcription cleanup. It defaults to the agent’s main model, which is usually larger and slower than these passes need.

Set utility_model to a small, fast model so redaction doesn’t add noticeable latency to the agent’s responses. The utility_model reference names the values it accepts.

auto_correct cleans up the transcription of the caller’s speech, converting spoken numbers to digits, formatting addresses and phone numbers, and fixing obvious mishearings. When used alongside redact_prompt, cleanup and redaction happen together in a single step instead of two.

auto_correct only takes effect when enable_text_normalization, which is on by default, is set to "off". The example below includes both settings.

A complete configuration:

1version: 1.0.0
2sections:
3 main:
4 - answer: {}
5 - ai:
6 prompt:
7 text: You are the dispatcher for Bayview Taxi. Book rides and take payment for them.
8 params:
9 redact_prompt: credit card numbers, CVVs, social security numbers, and full names
10 utility_model: gpt-4o-mini
11 auto_correct: true
12 enable_text_normalization: "off"

Verify redaction

1

Place a test call

Call your agent and read out a fake card number, such as 4242 4242 4242 4242, then let the conversation run on for a few more turns.

2

Check the call records

Open the call in your Dashboard and review the logs. In the call_log and raw_call_log, the conversation turns where the number was said, by you or by the agent, should read ----. Timeline entries recording a text transformation can still show the original, as What gets masked describes.

3

Check your webhook payloads

If your application receives SWAIG function calls, debug webhooks, or the post-conversation call log, confirm the conversation text arrives masked there too.

4

Sharpen the description if something leaks

If one category keeps slipping through, name it explicitly in redact_prompt. For a card number the caller spells out slowly, that might be “including partial card numbers read back one digit at a time”.

Limitations

Redaction is best-effort. It is AI-driven and biased toward over-masking, but a value can slip through, so treat it as a strong safeguard for your logs and integrations rather than a guarantee.

It also has nothing to say about audio. If you record calls, the recording still holds the real spoken words, and so does anything downstream that transcribes it.

Where a value lives is a separate decision from what gets logged. Anything that must outlive the call belongs on your server. Anything that only matters during the call can go in meta_data, which is gone when the session ends.

Next steps