Skip to content
RasaGet a free licence
Menu
Guides for AI teams

Guide · Conversation designer / UX specialist

What a voice agent should say before verifying the caller

Caller ID tells your voice agent which number is calling, not who is holding the phone, so plan what it says before the caller confirms.

by Rod Rivera

About 12 minutes to read

Key takeaways (3)
  • A caller ID match names a number, not a person. Offer the first name as a question and nothing more.
  • Cutting a fact from the greeting does not cut it from the model if project memory still holds it.
  • A tool's memory interface cannot overwrite a set project field, so keep unconfirmed matches out of project memory.

Your voice agent looks up the calling number, finds a customer and greets them by name. That works when the account holder is calling. But phones get shared, handed to children and left on desks. Caller ID tells you which number is calling, not who is holding the phone.

The fix is to treat the match as a guess. Offer the first name as a question. Say nothing else from the profile until the caller confirms. Keep the unconfirmed profile out of the agent’s long-lived memory. That matters if your agent can read out account details, because a wrong match then tells a stranger about someone else’s money.

The last part is the one that is easy to miss. We ran the profile lookup from Rasa’s session-start personalisation pattern against Rasa’s own memory code. We set up the memory objects by hand, with no server, model bootstrap or model. Then we rendered the Project Memory section with Rasa’s own formatter and template, and tried to set the name again, to None. Output trimmed: Rasa’s debug log lines are left out.

### Project Memory
These facts are already set for this session and will remain so throughout this session.
- project.customer_name: Jordan
- project.customer_tier: Premier
- project.member_since: 2019
- project.time_of_day: morning
- project.default_account_id: acc_checking
- project.default_account_label: Everyday Checking
--- try to clear customer_name
ProjectMemoryAlreadySetError: Project memory field 'customer_name' is already set and cannot be overwritten.

Note the second line: it tells the model these facts “will remain so throughout this session”. In the Rasa Pro source, this section goes into the system prompt for routing and for the active skill.

So a shorter greeting is only half the job. If the lookup wrote the profile to project memory, the model still sees it, labelled as settled fact. In Rasa Pro 3.21.0.dev5, a tool that writes to a set project field through context.memory gets the error above, even when it writes None.

Why it happens

The pattern was written for a customer who has already signed in. The comment above its demo profile says a real deployment would take the customer “from the authenticated session / channel identity”. On a phone line, the channel identity is the calling number.

The session opener is a skill whose whole body is two steps. This excerpt is from skills/default_session_start/skill.md:

:::ordered_block id=main
steps:
  - id: identify
    execute_tool: get_customer_profile
  - id: greet
    action: utter_greet
:::

identify fetches the profile and greet speaks. Nothing between them asks whether the profile belongs to the caller. The README says the greeting fires “before the user types anything”, so the caller cannot correct anything first.

The greeting puts the name, tier and joining year in its first two sentences. This is the whole response file, skills/default_session_start/responses.yml, without its comment lines:

responses:
  utter_greet:
    - text: >-
        Good {session.project.time_of_day}, {session.project.customer_name}!
        Always great to see a {session.project.customer_tier} member who's been
        with us since {session.project.member_since}. How can I help you today?
      metadata:
        rephrase: true

Here is what that can sound like. This script is illustrative: Northgate Bank and the household are made up. The agent’s lines use the pattern’s greeting, the example offer in its transactions skill and its demo transaction rows. Jordan’s teenager is returning a missed call from Jordan’s phone.

Agent: Good afternoon, Jordan! Always great to see a Premier member who’s been with us since 2019. How can I help you today?

Caller: Er, what’s gone into the account recently?

Agent: Want your usual Everyday Checking, Jordan?

Caller: Yeah.

Agent: The only money in recently is a payroll deposit of $2,150.00 on 22 July.

Nothing here is a bug. The pattern’s guards are aimed at the model inventing a name, a tier or a payment. Every fact above was real and correctly looked up. It went to the wrong listener.

Where the profile goes after the greeting

Cutting the tier from the greeting does not remove it from the model. We read the Rasa Pro source to see where profile facts end up:

Where the fact goesDoes the model see it?
The spoken greetingYes, as the agent’s own turn in the conversation history
Project memoryYes, in the system prompt for routing and for the active skill
The call that rewords the greetingNo project memory; it is told “do not add information”

The middle row is the one that matters. Project memory holds facts that every skill in the agent can read. The pattern’s lookup tool writes six fields there, and Rasa lists them under the heading shown at the top of this page.

How to fix it

This guide recommends one approach: a number match earns a first name, asked as a question, and nothing else. The cost is one extra turn for the real customer. The first three steps are for whoever owns the words. The last is a requirement to hand to your engineer.

Decide what each stage of the call may say

Write down what the agent may say at each stage. For the sample bank, it looks like this:

Stage of the callMay sayHolds back
Number matches onlyTime of day, the bank’s name, the first name as a questionTier, tenure, accounts, balances, activity
Caller says it is themThe name as settled; tier and tenure if your service allowsThe usual account, balances, transactions
Your verification passesWhat your policy allowsWhat your policy still withholds

The third row is left open on purpose. What counts as verification is a decision for your risk and compliance owners. The guide to approving agent actions covers the evidence they should ask for.

Ask a question that makes “no” easy

A question can still assume the answer. Compare these two opening lines, both illustrative:

Assumes yes

Agent: Good afternoon. Is this Jordan?

It is a question, but “yes” is the only easy reply. Saying “no” means contradicting the agent, and a teenager who wants to hear about a payment has every reason to say “yeah”.

Invites no (recommended)

Agent: Good afternoon, you’ve reached Northgate Bank. Am I speaking with Jordan, or someone else using Jordan’s phone?

It names the other possibility, so “no” is an ordinary answer. It uses only the first name. It says nothing about the account, so a wrong match leaks nothing yet.

If the caller says “Who’s asking?” or stays silent, repeat the bank’s name and the question. Never fill the pause with a profile fact.

Write the reply for a wrong match

Here is the recovery, again illustrative:

Caller: No, it’s their son.

Agent: Thanks for telling me. I can answer general questions about Northgate Bank. For anything about an account, the account holder needs to call us themselves.

Caller: Can you just tell me if the money came in?

Agent: I’m sorry, I can’t. For anything about an account, the account holder needs to call us themselves.

Thank the caller once. Repeat nothing from the profile, not even the name. Offer only what needs no identity. Give the same answer when asked again, because the second request is the real test.

Keep the unconfirmed match out of project memory

This step is for your engineer. In the pattern, the lookup tool writes the profile straight into project memory. This excerpt is from skills/default_session_start/tools.py:

        context.memory.set("customer_name", _CUSTOMER_PROFILE["preferred_name"])
        context.memory.set("customer_tier", _CUSTOMER_PROFILE["tier"])
        context.memory.set("member_since", _CUSTOMER_PROFILE["member_since"])
        context.memory.set("time_of_day", time_of_day)
        context.memory.set("default_account_id", _CUSTOMER_PROFILE["default_account_id"])
        context.memory.set(
            "default_account_label", _CUSTOMER_PROFILE["default_account_label"]
        )

The requirement to hand over: hold the unconfirmed match somewhere it can be withdrawn, and write only confirmed facts to project memory. In your own project, that means changing the session-start tool and the greeting template. The pattern does not do this yet.

We tried the storage half in a throwaway copy of the pattern. The copy gives the session-start skill its own private memory, which other skills cannot read. This is the new file, skills/default_session_start/memory.yml:

schema:
  private:
    candidate_name:
      type: text

In the copy’s tools.py, these two lines replace the six project writes. The name goes to the private field, and only the time of day goes to project memory. This excerpt shows the two lines:

        context.memory.set("candidate_name", _CUSTOMER_PROFILE["preferred_name"])
        context.memory.set("time_of_day", time_of_day)

We ran the same kind of offline check, then cleared the name the way a “no” would. The output of that run, trimmed: debug log lines are left out.

--- after lookup, session-start skill
### Project Memory
These facts are already set for this session and will remain so throughout this session.
- project.time_of_day: morning
skill memory lines: ['default_session_start.candidate_name: Jordan']
--- after lookup, view_transactions skill
### Project Memory
These facts are already set for this session and will remain so throughout this session.
- project.time_of_day: morning
skill memory lines: []
--- caller says "no": clear candidate_name
cleared
--- after the no, session-start skill
### Project Memory
These facts are already set for this session and will remain so throughout this session.
- project.time_of_day: morning
skill memory lines: []

The name is held only by the session-start skill, and the “no” removes it. In a second run, a “yes” copied the name into project memory once. Both answers were direct memory writes, not a caller. This checks the storage only: we did not build the confirmation step or run a conversation.

The copy is not a working agent as it stands. Its greeting still reads the customer’s name, tier and joining year from project memory, and the usual-account offer still reads the usual account, but the copy no longer writes them. That is why the greeting template has to change with the tool.

Where each answer leads, and what is still held back at the end
FigureWhere each answer leads, and what is still held back at the end

How to check the fix works

First, list what your project writes into memory. Run this from the project folder:

Any system
grep -rn 'context.memory.set' skills/

On the pattern, it printed these lines, unedited:

skills/default_session_start/tools.py:50:        context.memory.set("customer_name", _CUSTOMER_PROFILE["preferred_name"])
skills/default_session_start/tools.py:51:        context.memory.set("customer_tier", _CUSTOMER_PROFILE["tier"])
skills/default_session_start/tools.py:52:        context.memory.set("member_since", _CUSTOMER_PROFILE["member_since"])
skills/default_session_start/tools.py:53:        context.memory.set("time_of_day", time_of_day)
skills/default_session_start/tools.py:54:        context.memory.set("default_account_id", _CUSTOMER_PROFILE["default_account_id"])
skills/default_session_start/tools.py:55:        context.memory.set(
skills/view_transactions/tools.py:74:    context.memory.set("selected_account_label", account["label"])

The six session-start names are declared in the root memory.yml, so they are project memory. The grep can miss writes made through a helper or a name built at run time, so read memory.yml alongside it.

Then rehearse the wrong listener with a colleague, once your engineer has a build with a confirmation step. This is a proposed test, not a result we have:

StepWhat the colleague doesIt passes when
1Calls from a number linked to a test profile and answers “no”The agent gives the general reply and no name
2Asks about recent payments, then asks again in other wordsBoth replies are the general refusal, with no account name
3A few turns later, asks something that would trigger the usual-account offerThe agent neither uses the name nor offers the account
4Asks the engineer for the system prompt of that last turnThe name, tier, tenure and usual account are not under Project Memory

Passing shows one conversation went right. It does not show every call will, and it does not test a caller who lies.

Trade-offs

The real Jordan hears a less warm opening and answers one extra question. Whether that is worth it is your call; the other side of it is a stranger hearing the customer’s pay.

Even the name has a cost. Saying “Jordan” tells whoever holds the phone that Jordan banks with you. For a service where the relationship itself is sensitive, greet without the name.

A “yes” is not verification either. Anyone willing to lie, including someone calling from a spoofed number, gets past it. If tier and tenure are too much to risk on that, move them to the third row of the table.

Limits

  • This is a design argument, not a measurement. We have no figures for spoofed numbers or shared phones.
  • Both memory runs used Rasa’s memory code, wired by hand, with no server, model bootstrap or model. We did not run the pattern on a phone channel, and the pattern has no tests.
  • The prompt table comes from reading the Rasa Pro 3.20.0rc1 source, compared with 3.21.0.dev5, not from a live call. It shows what goes into each prompt, not what a model does with it.
  • The write and reset runs were offline, outside a running server, and we did not search the engine for other reset paths.
  • It does not define verification. It covers calls the customer places, not calls the agent makes.

Questions a designer will ask

What if the caller is a joint account holder, or allowed to act for Jordan?

Then “the account holder needs to call us themselves” is the wrong line. The pattern cannot tell, because its demo profile has one customer and no field for anyone else. Treat them as a new caller to identify through your service’s own process, and write that line with whoever owns it.

Show the script behind the memory run

The script loads the pattern’s skills and memory.yml and runs the pattern’s own lookup tool against Rasa’s memory code. It prints the Project Memory section and then tries to clear the name. It sets up the memory objects by hand instead of starting a server. It changes no files, apart from Python’s usual bytecode cache. We ran it in an export of the companion with no .env file, in an environment holding only HOME and PATH.

import asyncio, importlib.util, sys
from pathlib import Path

from rasa.shared.core.slots import AnySlot
from rasa.shared.core.trackers import DialogueStateTracker
from rasa.mantle.memory.manager import MemoryManager
from rasa.mantle.memory.schema import ProjectMemorySchema
from rasa.mantle.skills.catalog import load_skill_catalog, load_default_skill_catalog
from rasa.mantle.state.tracker_handle import TrackerHandle
from rasa.mantle.tools.decorator import ToolContext
from rasa.mantle.prompts.memory_lines import partition_prompt_memory_entries, format_memory_prompt_lines
from rasa.mantle.prompts import system_prompt as sp

ROOT = Path(sys.argv[1])
project = ProjectMemorySchema.from_yaml(ROOT / "memory.yml")
catalog = load_skill_catalog(ROOT, defaults=load_default_skill_catalog())
slots = [AnySlot(f"project.{n}", mappings=[]) for n in project.fields]
tracker = DialogueStateTracker("probe", slots)
handle = TrackerHandle(tracker)
handle._memory_manager = MemoryManager(catalog, project, handle)
scope = ("default_session_start", "default_session_start")

spec = importlib.util.spec_from_file_location("ss_tools", ROOT / "skills/default_session_start/tools.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
fn = getattr(mod.get_customer_profile, "__wrapped__", None) or getattr(mod.get_customer_profile, "fn", None) or mod.get_customer_profile
ctx = ToolContext(handle, memory_scope=scope)
asyncio.run(fn(context=ctx))

project_entries, _ = partition_prompt_memory_entries(handle.memory.read_all(*scope))
print(sp.render_template(sp.PROJECT_TEMPLATE, project_lines=format_memory_prompt_lines(project_entries)))
print("--- try to clear customer_name")
try:
    ctx.memory.set("customer_name", None)
    print("cleared")
except Exception as e:
    print(f"{type(e).__name__}: {e}")

The script’s module docstring is left out here. Save the script as probe.py at the root of a companion checkout. Then run it with the Python of an environment that has Rasa Pro 3.21.0.dev5 installed:

env -i HOME=$HOME PATH=/usr/bin:/bin .venv/bin/python probe.py patterns/session-start-personalization 2>/dev/null

Change .venv/bin/python to the Python in your own environment. The 2>/dev/null discards error output. Rasa prints debug log lines on standard output before the result. The time_of_day value follows your own clock, so you may see afternoon or evening.

Show the script behind the variant run

Work in a throwaway copy of the companion checkout, because these changes stop the pattern’s greeting and usual-account offer from working. In the copy’s patterns/session-start-personalization, add the memory.yml shown in step 4. In its skills/default_session_start/tools.py, replace the six context.memory.set lines with the two shown in step 4, and replace the tool’s return statement with this line:

    return ToolResult(llm_response={"ok": True, "time_of_day": time_of_day})

Then save this script as variant_probe.py at the root of the copy. It changes no files, apart from Python’s usual bytecode cache:

import asyncio, importlib.util, sys
from pathlib import Path

from rasa.shared.core.slots import AnySlot
from rasa.shared.core.trackers import DialogueStateTracker
from rasa.mantle.memory.manager import MemoryManager
from rasa.mantle.memory.schema import ProjectMemorySchema
from rasa.mantle.skills.catalog import load_skill_catalog, load_default_skill_catalog
from rasa.mantle.state.tracker_handle import TrackerHandle
from rasa.mantle.tools.decorator import ToolContext
from rasa.mantle.prompts.memory_lines import partition_prompt_memory_entries, format_memory_prompt_lines
from rasa.mantle.prompts import system_prompt as sp

ROOT = Path(sys.argv[1])
project = ProjectMemorySchema.from_yaml(ROOT / "memory.yml")
catalog = load_skill_catalog(ROOT, defaults=load_default_skill_catalog())
names = [f"project.{n}" for n in project.fields] + ["default_session_start.candidate_name"]
tracker = DialogueStateTracker("probe", [AnySlot(n, mappings=[]) for n in names])
handle = TrackerHandle(tracker)
handle._memory_manager = MemoryManager(catalog, project, handle)
start = ("default_session_start", "default_session_start")

spec = importlib.util.spec_from_file_location("ss_tools", ROOT / "skills/default_session_start/tools.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
fn = getattr(mod.get_customer_profile, "__wrapped__", None) or getattr(mod.get_customer_profile, "fn", None) or mod.get_customer_profile
ctx = ToolContext(handle, memory_scope=start)
asyncio.run(fn(context=ctx))

def show(label, scope):
    project_entries, skill_entries = partition_prompt_memory_entries(handle.memory.read_all(*scope))
    print(f"--- {label}")
    print(sp.render_template(sp.PROJECT_TEMPLATE, project_lines=format_memory_prompt_lines(project_entries)))
    print("skill memory lines:", list(format_memory_prompt_lines(skill_entries)))

show("after lookup, session-start skill", start)
show("after lookup, view_transactions skill", ("view_transactions", "view_transactions"))
if sys.argv[2:] == ["yes"]:
    print('--- caller says "yes": write the confirmed name to project memory')
    name = ctx.memory.get("candidate_name")
    ctx.memory.set("customer_name", name)
    ctx.memory.set("candidate_name", None)
    show("after the yes, session-start skill", start)
else:
    print('--- caller says "no": clear candidate_name')
    ctx.memory.set("candidate_name", None)
    print("cleared")
    show("after the no, session-start skill", start)

The script’s module docstring is left out here. The printed label “write the confirmed name” is ours: no caller confirmed anything, and the script makes the write directly. Run it once with no and once with yes:

env -i HOME=$HOME PATH=/usr/bin:/bin .venv/bin/python variant_probe.py patterns/session-start-personalization no 2>/dev/null
env -i HOME=$HOME PATH=/usr/bin:/bin .venv/bin/python variant_probe.py patterns/session-start-personalization yes 2>/dev/null

As before, change .venv/bin/python to your own Python, and expect debug log lines on standard output.