Skip to content
RasaGet a free licence
AI team casebook

AI product engineer · Telco & ISP (external)

How to verify a SIM swap request in a voice agent

Tie each SIM swap check to the line it approves and enforce it in the tool, so an approval for one line cannot move another.

by Rod Rivera

About 17 minutesBuild study

Key takeaways (3)
  • Tie each approval to the line it was given for. Without that, an approval for one line moved another.
  • A code texted to the number being moved cannot approve moving it, however correctly it is read back.
  • Allow only checks independent of the line, enforce them inside the tool, and refuse everything else.

You are building a voice agent for a mobile carrier. A caller wants their number moved to a new SIM, and you need to check it is really them first.

The obvious question is which proof to ask for. In our sample agent, the telling result came from a different check: tying each approval to the line it was given for. Of the three checks in the policy with their own refusal reason, it was the only one whose deletion let a swap through. We deleted it, two lines, in a scratch copy and called the tools directly, with no model. A push for the tablet line, 555-0187, was sent and its demo code confirmed. A request to move the main line, 555-0142, then came back queued:

{
  "ok": true,
  "reference": "SWP-2A30C9B4",
  "status": "queued",
  "active": false,
  "line_ending": "0142"
}

That is the last tool result of an offline run on 3 October 2026, on rasa-pro 3.21.0.dev5, trimmed: the receipt_proves sentence is left out. With the two lines in place, the same call returns target_changed and queues nothing.

The fix is to let only checks independent of the line approve a swap, tie each one to its line, and enforce both inside the tool that queues the swap. That matters if your agent can queue a SIM swap. In our reading, a swap approved for the wrong caller moves the number to their SIM, and with it any text-message codes sent to that number afterwards.

The more familiar failure is a code texted to the number being moved. Here is an illustrative script. The numbers and the code are made up.

Caller: I lost my phone this afternoon. I’ve got a new SIM from the shop and I need my number on it.

Assistant: For security, I’ve texted a six-digit code to your number ending 0142. Can you read it to me?

Caller: Four, one, nine, zero, two, seven.

Assistant: Thank you, that’s verified. Your number is queued to move to the new SIM.

This is a build study for a fictional carrier, Telecom of Rasa, and its fictional voice assistant, Telano. The policy is a teaching example, not any real carrier’s procedure. The code is in the mantle-voice-telco-skills example in RasaHQ/rasa-community-resources. Its tests need no model and no API key.

Why a code texted to the line proves nothing here

In the script, every step looks careful. But the code went to the number being moved. If the phone really is lost, the owner can read that text only on another device of theirs. Anyone who already receives texts for that number, for example through a forwarded number, can read it too.

Why would someone who already gets the texts want the swap? In our reading, a forwarding rule can be noticed and undone, while a swap puts the number on the attacker’s own SIM. The companion’s policy docstring names forwarding, an earlier port-out and a compromised voicemail as ways an attacker may already control the line.

The step-up pattern in the same repository, patterns/voice-auth-stepup, ranks proofs: a spoken passphrase earns medium trust and a one-time code earns high trust. Here the code reached a real phone and no one was tricked. What makes it worthless is where it was sent: to the thing the action changes. That ranking cannot see this, because it ranks the proof, not the action.

In our view, the rule reaches beyond telecoms: a proof delivered through the channel being changed cannot authorise changing it. A code sent to an account’s registered phone number cannot, on its own, approve replacing that number.

Tie the check to one line

The verification record stores the line it was issued for. The policy refuses a swap of any other line with target_changed, and the tool then discards the verification. Switching back does not bring it back.

The check is the TARGET_CHANGED branch in the evaluate_swap excerpt below: the two lines we deleted for the opening run. Without it, an attacker who talks the owner into approving a push “for the tablet line” could use it to move the main number. We also deleted it and ran the full suite on 3 October 2026, on rasa-pro 3.21.0.dev5. The suite reported three failures in two tests. In one of them, the tool wrote a queued swap of 555-0142 on a push issued for 555-0187.

The binding stops a mislabelled approval. It does not stop an owner who is talked into approving a push for the main line itself. So we recommend that the push tell the owner which line it approves and what it will do. The demo sends no real push, so the example does not show this.

The record never stores the code, and it is read strictly. This excerpt from coerce_verification in lib/sim_swap.py rejects anything that is not a real boolean:

# `type(...) is bool` rather than isinstance: True is also an int, and 1 is
# not a verification outcome.
if type(passed) is not bool or type(registered) is not bool:
    return None

So the string "true", an unknown channel or a missing field all count as no verification.

Rasa keeps conversation state in memory fields. A field marked llm_settable is one the model may fill in. Here is an excerpt of skills/sim_swap/memory.yml, with the other fields left out:

schema:
  public:
    swap_target_line:
      type: text
      description: >
        The customer's mobile line they want moved to a new SIM, such as
        555-0142. Change it only when the customer names a different line.
      llm_settable: true
    swap_verification:
      type: text

The model may set the target line. It cannot set swap_verification: in the rasa-pro 3.21.0.dev5 source, llm_settable defaults to false. Only the two verification tools write a record there, and request_sim_swap only clears it, after a queued swap or certain refusals.

Allow two checks and refuse the rest

This guide recommends a short list of allowed checks, enforced inside the tool, with a refusal for everything else. Keep the named rules for circular codes and PINs as well, so each refusal gives a clear reason. Telano allows two checks:

  • An app push: The agent sends a code to the Telecom of Rasa app on a device that was registered to the account before the call started. The push goes to a device, not to the phone number being moved.
  • A store visit with photo ID: A voice agent cannot do this one. The store’s system records it, and the agent tells the caller nothing is activated from the call.

The cost is real. A customer with no device registered before the call cannot swap over the phone. They go to a store, or the agent hands off to the identity team.

A device registered during the call does not count, because it was added on the word of the same unverified caller. Two wrong codes lock the call, and asking for another push does not reset the count.

The policy runs its checks in a fixed order, ending in a refusal:

Request to move a line Record missing or malformed, or no target line? PIN, date of birth or security answer? Verification issued for a different line? no knowledge_only yes Code sent to the line being moved? no target_changed yes Challenge passed? no circular_verification yes App push to a device registered before the call, or store ID check? yes no_verification no no_verification (default refusal) no allowed yes no no_verification yes
FigureThe order of checks in evaluate_swap, ending in a default refusal

Here is the same order in code. This is an excerpt from the middle of evaluate_swap in lib/sim_swap.py, so it does not run on its own:

if record.channel in KNOWLEDGE_CHANNELS:
    return refuse(KNOWLEDGE_ONLY)

if line_digits(record.issued_for_line) != line_digits(target):
    return refuse(TARGET_CHANGED)

if record.channel in LINE_CHANNELS and line_digits(record.destination) == line_digits(
    target
):
    return refuse(CIRCULAR_VERIFICATION)

if not record.passed:
    return refuse(NO_VERIFICATION)

if record.channel == APP_PUSH and record.registered_before_call:
    return SwapDecision(True, ALLOWED, target, record.channel)

if record.channel == STORE_ID_CHECK:
    return SwapDecision(True, ALLOWED, target, record.channel)

# A code to some other number, or a push to a device enrolled during this
# call. Not circular, not knowledge, and not independent either.
return refuse(NO_VERIFICATION)

Only two branches return True. Every other path refuses: some earlier with a named reason, and the rest at the last line.

What each kind of check gets

We called evaluate_swap on ten records, each asking to move 555-0142, then again with the circular and knowledge rules switched off in memory. This was an offline run on 3 October 2026, with no model. The reason codes are as printed:

What the caller didShipped policyNamed rules switched offSwap allowed?
Text code to the line being movedcircular_verificationno_verificationNo
Voice-call code to the line being movedcircular_verificationno_verificationNo
Correct account PINknowledge_onlyno_verificationNo
Correct date of birthknowledge_onlyno_verificationNo
Text code to a different numberno_verificationno_verificationNo
App push, device registered during the callno_verificationno_verificationNo
App push approved for 555-0187target_changedtarget_changedNo
App push, wrong codeno_verificationno_verificationNo
App push, device registered before the callallowedallowedYes
Store ID checkallowedallowedYes

With the named rules off, every refused row was still refused. The reason changed only for the first four rows. The named rules make a refusal explain itself. The short list does the refusing.

Show the script that produced the table

Save this as check_table.py in examples/mantle-voice-telco-skills and run uv run python check_table.py. It changes no files; the switch-off happens in memory.

# Save as check_table.py in examples/mantle-voice-telco-skills, then run:
#   uv run python check_table.py
import lib.sim_swap as policy
from lib.sim_swap import SwapVerification, evaluate_swap

ROWS = [  # what the caller did: channel, destination, issued for, passed, registered before call
    ("Text code to the line being moved", "sms", "555-0142", "555-0142", True, False),
    ("Voice-call code to the line being moved", "voice_call", "555-0142", "555-0142", True, False),
    ("Correct account PIN", "pin", "caller", "555-0142", True, False),
    ("Correct date of birth", "dob", "caller", "555-0142", True, False),
    ("Text code to a different number", "sms", "555-0199", "555-0142", True, False),
    ("App push, device registered during the call", "app_push", "registered_device", "555-0142", True, False),
    ("App push approved for 555-0187", "app_push", "registered_device", "555-0187", True, True),
    ("App push, wrong code", "app_push", "registered_device", "555-0142", False, True),
    ("App push, device registered before the call", "app_push", "registered_device", "555-0142", True, True),
    ("Store ID check", "store_id_check", "store", "555-0142", True, False),
]

def reasons():
    return [evaluate_swap("555-0142", SwapVerification(*row[1:])).reason for row in ROWS]

shipped = reasons()
policy.LINE_CHANNELS = frozenset()       # switch off the circular rule
policy.KNOWLEDGE_CHANNELS = frozenset()  # switch off the knowledge rule
switched_off = reasons()
for row, a, b in zip(ROWS, shipped, switched_off):
    print(f"{row[0]:<46} {a:<22} {b}")

Our output on rasa-pro 3.21.0.dev5, unedited:

Text code to the line being moved              circular_verification  no_verification
Voice-call code to the line being moved        circular_verification  no_verification
Correct account PIN                            knowledge_only         no_verification
Correct date of birth                          knowledge_only         no_verification
Text code to a different number                no_verification        no_verification
App push, device registered during the call    no_verification        no_verification
App push approved for 555-0187                 target_changed         target_changed
App push, wrong code                           no_verification        no_verification
App push, device registered before the call    allowed                allowed
Store ID check                                 allowed                allowed

Put the check inside the tool

A skill in Rasa is a task the agent can do, written as instructions in a skill.md file. Its frontmatter can limit when the model is offered each tool. This is the frontmatter of skills/sim_swap/skill.md, trimmed to the tool_constraints key:

tool_constraints:
  - send_swap_verification:
      requires: session.sim_swap.swap_target_line
  - request_sim_swap:
      requires: session.sim_swap.swap_target_line
      requires_confirmation:
        enabled: true
        utter_for_confirmation: utter_confirm_sim_swap
        utter_on_user_denial: utter_sim_swap_cancelled

That keeps the conversation in order, but it only shapes which tool the model is offered. The step-up pattern’s authpolicy/guard.py calls this a routing control, not an execution control.

So the check that binds is the first statement of the tool. This excerpt from skills/sim_swap/tools.py leaves out the docstring and the rest of the function:

async def request_sim_swap(
    line: str = "",
    new_iccid: str = "",
    context: ToolContext = None,
) -> ToolResult:
    # (docstring omitted)
    try:
        require_independent_verification(line, context)
    except SwapRefused as exc:
        return _refuse(exc, context)

If someone edits the skill text, swaps the model or mistypes the YAML, the function still refuses. Here is its result in our offline run when memory held a passed text code sent to 555-0142. Unedited:

{
  "ok": false,
  "refused": true,
  "reason": "circular_verification",
  "hint": "The SIM swap did NOT happen and nothing was queued. A code sent to the line being replaced cannot authorise replacing it. Do not offer an SMS or call to that line. Offer an app push or a store visit with photo ID."
}

It carries no reference, status or SIM number, and no swap row was written.

In your own project, the same pieces map to three places. Put the decision in a plain function you can test without Rasa. Call it on the first line of the tool that has the side effect. Keep the verification record in a memory field the model cannot set.

Run the tests without a model

The tests call the policy and tools directly with a fake context and a throwaway SQLite database. You need git, Python and uv. The first run creates a virtual environment in .venv and installs the pinned rasa-pro.

Get the example at the pinned revision

Any system
git clone https://github.com/RasaHQ/rasa-community-resources.git && cd rasa-community-resources && git checkout 4aa0c4419dc193fef7a969c12d59edcf720f2606 && cd examples/mantle-voice-telco-skills

At this revision the example pins rasa-pro 3.21.0.dev5.

Run the tests

Any system
make test

Our run on 3 October 2026, on rasa-pro 3.21.0.dev5, trimmed to the last lines. The uv setup lines and the eleven per-test lines are left out, and the timing varies between machines:

----------------------------------------------------------------------
Ran 11 tests in 0.054s

OK

Remove the check and run them again

Use a throwaway copy for this. Delete the try block at the top of request_sim_swap and run make test again. In our rerun on 3 October 2026, on rasa-pro 3.21.0.dev5, 8 of the 11 tests failed. Each failure was a swap reference returned, or a queued row written, for a caller who had not passed an allowed check.

The author first ran these deletion checks on the earlier 3.20.0rc1 pin, and the three we reran matched. The docstring in tests/test_sim_swap.py lists them all.

Say queued, not active

An accepted request returns status: "queued" and active: false. A second tool, check_swap_status, reads the real state from the store. The skill tells the agent to say the request is queued, and to call the new SIM active only when that tool reports it.

Write the refusals the rule allows

The skill tells the agent never to offer a text or call to the line being moved, never to fall back to a PIN or security question, and to hand off when there is no independent path. An illustrative script:

Caller: I lost my phone this afternoon. I’ve got a new SIM and I need my number on it.

Assistant: I can start that for the number ending 0142. A code sent to that number can’t approve moving it, so I can send one to the Telecom of Rasa app on a device already registered to your account, or you can visit a store with photo ID.

Caller: Can’t you just text me?

Assistant: A text would go to the number we’re moving, so it can’t approve moving it. If neither the app nor a store works for you, I’ll pass you to our identity team.

The send tool says only that the push went to a registered device, never which one, so the agent has nothing to reveal. Whether a model follows the script in a live call is for your own conversation tests. The tool refuses either way.

Limits

  • This is a fictional carrier and a teaching policy. It is not any real carrier’s procedure, and it says nothing about fraud rates.
  • The proofs are offline tests and direct function calls. We made no live call and did not test how a model behaves on the phone.
  • An app push is independent of the line. It is not proof against an owner who is talked into reading the code to someone else.
  • The example does not transfer to a person, send a real push or provision a SIM. Its handoff tool only creates a ticket id in memory, and the push and the swap queue are stand-ins too.

Questions you will hit while building it

What if the registered device is the lost phone?

It may be. If the owner lost it, the push never reaches them and the store is their path. If a thief holds it unlocked with the app signed in, the push reaches the thief. This rule proves the approval came from a device registered before the call. It does not prove the device is still with the owner. One option is to require the app’s own unlock before it shows the code. The example does not build or test that. Treat a stolen handset as a separate risk in your review.

What if no device was registered before the call and the customer cannot reach a store?

Then the swap is not requested on this call. send_swap_verification reports that no independent path exists and asks for a handoff. Some real customers will be inconvenienced. That cost belongs in the product decision and in the permission review, not in a fallback the model makes up.