Agentforce Voice: Let Callers Type Their Email Mid-Call
Build an Agentforce Voice agent that texts callers a one-field form link, so they type their email instead of spelling it. Full steps, commands and fixes.
Have you ever tried to spell your email address to a voice agent? “C as in cat, H, R…” and then it reads back something completely different. Email addresses, name spellings and booking codes are some of the hardest things for a voice agent to hear correctly.
Typeback is a pattern I built for Agentforce Voice to fix that: when the caller wants something emailed, the agent makes a link to a one-field form instead of asking them to spell it. The caller types the email on their phone, says “done”, and the agent reads back a masked hint (“starting with C, at example dot com”) on the same call. The agent’s language model never sees the full email.
In this tutorial I’ll walk you through every step to rebuild it in your own Salesforce org, in order, plus every problem I hit along the way and how I fixed it. You can follow it by hand, or hand it to an AI coding agent and do only the steps that need a person.
TL;DR
- You’ll build a phone number that reaches an Agentforce Voice agent (a resort concierge) that captures the caller’s email through a form link instead of speech.
- The code (Apex, a Lightning Web Component form, an Agent Script agent and setup scripts) is in the agentforce-typeback GitHub repo. Most steps are one script.
- The Setup click paths (site activation, phone number, Omni-Channel flow, voice channel) have to be done by a person.
- The full loop works on a live call from a mobile phone. What’s not done: texting the link automatically. For now the link reaches the phone by hand.
- Every gotcha I hit is in the troubleshooting tables at the end.
What you’ll build
Here’s how a call flows from the caller’s phone to the agent and back, one step at a time:
- The call reaches the agent. The caller dials the phone number. An Omni-Channel flow with a single Route Work element sends the call to
TypebackDemoVoice, the Agent Script agent. - The caller asks for an email. For example: “Can you email me my booking confirmation?”
- The agent makes a link.
TypebackRequestActioncreates a random token and saves only its hash on a newTypeback_Request__crecord with the status Pending. Then it hands the link to the sender. - The link reaches the caller’s phone. For now, the Log sender saves the link on the record (
Test_Link__c) andscripts/link.shprints it so you can open it by hand. An SMS sender would text it at this step instead. - The caller types their email. They open
https://<site>/typeback/?t=<token>, which loads thetypebackFormLightning Web Component on a guest site.TypebackFormControllersaves the email and sets the record to Submitted. - The caller says “done”. At the start of that turn, a router guard runs
TypebackCheckActionbefore any LLM pass. - The agent reads back a masked hint. “I have an email starting with C, at example dot com. Is that right?”
The design rules
These are the rules that make it work. Keep them in mind if you change the code:
- The agent actions never throw and never return nulls. Every Typeback line the agent says is written in Apex and spoken word for word.
- Only the token’s SHA-256 hash is stored. The link carries a 128-bit random token, so a record can’t be used to rebuild its link.
- The agent user can’t read the typed value. The
Typeback_Agentpermission set has no access toValue__c, and an Apex test proves it. - Send before saving. Apex can’t make a callout after DML in the same transaction.
- Links are tied to their call. The call Id comes from the agent’s
VoiceCallIdlinked variable, never from what the caller says. - Pull, not push. There’s no API to push an event into a live voice session, so the agent checks for the typed value on the caller’s next utterance.
Get the code
Everything in this tutorial runs from the agentforce-typeback repo on GitHub (Apache 2.0 license). Clone it and work from its root:
git clone https://github.com/ProstDev/agentforce-typeback.git
cd agentforce-typeback
Here’s what’s in it:
| Path | What it is |
|---|---|
force-app-agent/.../TypebackDemoVoice/ | The voice agent, in Agent Script. It’s the default package directory because sf agent publish authoring-bundle only looks there. |
force-app/.../objects/ | Typeback_Request__c (one row per link) and Typeback_Settings__c (a hierarchy custom setting). |
force-app/.../classes/ | The two agent actions (TypebackRequestAction, TypebackCheckAction), the form controller (TypebackFormController), helpers and tests. |
force-app/.../lwc/typebackForm/ | The one-field form the caller opens. |
force-app/.../permissionsets/ | Typeback_Agent (agent user, no access to the typed value), Typeback_Guest (site guest user), Typeback_Admin (you). |
scripts/ | Setup, configuration, preview and reporting scripts. Every scripts/setup.sh step is safe to re-run. |
How to follow this tutorial (humans and AI agents)
Every step has one tag:
| Tag | Meaning |
|---|---|
| [HUMAN] | A person has to do it: a Setup click path, something that needs a phone, or something public-facing or hard to undo. |
| [AI] | A command from the repo. An AI agent can run it, and a person can paste the same command into a terminal. Each one says what output means it worked. |
Doing it all by hand? Run the [AI] commands yourself, in order. Nothing in them needs an AI.
Handing it to an AI coding agent? Give it this page and a prompt like: “Follow this tutorial from Phase 0. Run the [AI] steps yourself. At each [HUMAN] step, stop, tell me exactly what to do, and wait until I say it’s done.” Also give it the rules below.
Rules for an AI agent
- Stop at every [HUMAN] step. Say what to click, then wait. Don’t look for a scripted way around it.
- Never do these without the person’s go-ahead at that moment: activate a site, get a phone number, register an SMS brand or campaign, send an email or SMS (even to the person), or delete anything in the org.
- Keep secrets out of output.
sf org displayprints an access token, so use--jsonwithjqand pick out only the fields you need. Never print tokens, session IDs or login URLs. Show phone numbers as their last four digits only. - Keep org details out of committed files: usernames, org IDs, domains and phone numbers.
.env.localandorg-reference/are gitignored, so those details can go there. - Before any commit, put the agent user placeholder back:
node scripts/setup-agent-user.js --username __AGENT_USER_PLACEHOLDER__ - Live form links are bearer links. Don’t paste one anywhere public. It works until it’s used or it expires.
- Run scripts by path, for example
/bin/bash scripts/report.sh. Each one moves to the repo root and reads.env.localitself. Pass--jsontosfcommands in non-interactive shells, because their progress display floods logs otherwise. - Describe before you query. Some fields you’d expect aren’t queryable (for example
MessagingChannel.SessionHandlerTypeandVoiceCall.IsVoiceMail). Check withsf sobject describe -o typeback -s <Object> --json | jq -r '.result.fields[].name'.
Prerequisites
The Salesforce org needs all of these (Phase 0 checks them):
- Agentforce, with Agentforce Service Agents
- Agentforce Voice (Agentforce Contact Center), with a way to get a phone number. My org’s entitlements included one 10-digit long-code number and inbound voice credits, so the number cost nothing.
- A free Einstein Agent User license, for the agent user
- Digital Experiences, for the guest site that hosts the form
- An admin login
I used an Enterprise Edition trial with these features on Summer ‘26 (API 67.0).
Warning
A free Salesforce Developer Edition org doesn’t include an Agentforce Voice license, so you can’t get a phone number or route calls to the agent there. You need an org that has Agentforce Voice.
Your machine:
- Salesforce CLI 2.152 or later (check with
sf --version), Node.js and jq - Optional:
qrencode(brew install qrencode), soscripts/link.shcan show the link as a QR code scripts/link.shuses the macOSdate -j. On Linux, change itsdateline to GNUdate -d.
For the test calls: a real mobile phone on cellular data, plus a laptop next to it.
Overview of the phases
| Phase | What | Who | Time it took me |
|---|---|---|---|
| 0 | Org, CLI login, recon | HUMAN, then AI | 15 min |
| 1 | Deploy the object, Apex and permission sets; run the tests | AI | 5 min |
| 2 | Guest site and form; phone test | AI, then HUMAN | 30 min |
| 3 | The agent; scripted preview of the whole loop | AI | 10 min |
| 4 | Phone number, routing flow, voice channel | HUMAN, then AI checks | 30 min, plus a few minutes for the number to go live |
| 5 | Live test call | HUMAN + AI | 10 min per call |
| 6 | Clean up | Both | 10 min |
Phase 0: Connect the Salesforce CLI to your org
0.1 [HUMAN] Get an org that meets the prerequisites.
0.2 [HUMAN] Log the CLI in to it. Replace <your-domain> with your org’s My Domain:
sf org login web -a typeback -r https://<your-domain>.my.salesforce.com
Approve in the browser straight away, because the CLI only waits a few minutes (you’ll get an AuthTimeoutError if you’re slow). Use a browser window that’s already logged in to the org. If you get a username and password page instead, paste that tab’s URL into the window where you’re logged in.
0.3 [AI] Create the local config:
cp .env.local.example .env.local # then set ORG if you used an alias other than typeback
0.4 [AI] Match the API version. Check the org’s API version without printing the access token:
sf org display -o typeback --json | jq -r '.result.apiVersion'
If it’s lower than sourceApiVersion in sfdx-project.json (67.0), set sourceApiVersion to the org’s version. Otherwise every retrieve fails with UNSUPPORTED_API_VERSION. A higher org version is fine.
0.5 [AI] Run the read-only recon:
scripts/recon.sh
It shows the edition, licenses, existing agents, routing flows, calls, sites and credentials, and it copies existing agents and routing flows into org-reference/. Look for:
- free Einstein Agent licenses
- the Omni-Channel Phone service channel
- whether Digital Experiences is on
Built-in routing flows show up by name but can’t be copied. That’s expected.
0.6 [AI] Check for a phone number entitlement:
sf data query -o typeback -q "SELECT MasterLabel, AmountUsed, CurrentAmountAllowed FROM TenantUsageEntitlement" --json |
jq -r '.result.records[] | select(.MasterLabel | test("Long Code|Voice"; "i")) | "\(.MasterLabel): \(.AmountUsed // 0) of \(.CurrentAmountAllowed)"'
Mine showed one ten-digit long-code number and inbound voice credits. If you see nothing like that, [HUMAN] find out how your org gets a number before Phase 4.
Phase 1: Deploy the Apex, object and permission sets
1.1 [AI] Deploy and test:
scripts/setup.sh core
This deploys force-app, then runs the five Typeback test classes itself, because some orgs accept a RunSpecifiedTests deploy without actually running any tests. It also assigns you Typeback_Admin.
It worked when it prints 0 failed (I had 49 passing) and every Typeback class is at 75% coverage or more. Mine ranged from 86% to 100%.
Phase 2: Publish the guest site and the form
2.1 [HUMAN] Turn on Digital Experiences, but only if recon said it’s off. Go to Setup → Digital Experiences → Settings → Enable Digital Experiences, pick a domain, and click Save. Treat the domain as permanent.
2.2 [AI] Create and publish the site:
scripts/setup.sh site
It creates an empty “Build Your Own (LWR)” site at /typeback, publishes it, and assigns the site’s guest user Typeback_Guest. Site creation runs in the background and can take a few minutes. If publish fails because the site is brand new, wait a minute and re-run it.
It worked when it prints SITE_URL=https://<host>/typeback. Put that line in .env.local.
2.3 [AI] Write the settings:
scripts/configure.sh Log 10 # Log sender, links expire after 10 minutes, at most 3 links per call
2.4 [HUMAN] Activate the site, with its welcome email off. Go to Setup → Digital Experiences → All Sites → Typeback → Workspaces → Administration:
- Under Emails, clear Send welcome email. Do this first: activating sends it to every site member.
- Under Settings, click Activate.
This is a person’s step because it makes the site public and can email people.
[AI] check: this query should show Live:
sf data query -o typeback -q "SELECT Status FROM Network WHERE Name = 'Typeback'"
2.5 [HUMAN] Place the form. Go to All Sites → Typeback → Builder → Components (+) → Custom Components, drag Typeback Form onto the Home page, and click Publish.
2.6 [AI] Make a test link (nothing is texted):
scripts/new-link.sh
It prints a request name and a link. Each link works once and lasts 10 minutes. Links to one number are capped at 3 per 30 minutes, so for more, pass another fictional number: scripts/new-link.sh '415 555 0124'.
2.7 [HUMAN] Test the form on your phone, with Wi-Fi and any VPN off:
| Test | Expected |
|---|---|
| Open a link, type an email, tap Continue, then Send | A Thanks page that tells you to go back to your call and say “done” |
| Open the same link again | ”This link has been used” |
Type abc@ | Blocked before it can be sent |
Run scripts/configure.sh Log 1, make a link, wait 70 seconds, open it | ”This link has expired”. Then run scripts/configure.sh Log 10 |
Note
I haven’t run the expiry test (the last row) myself yet. Let me know if it doesn’t behave as expected for you.
If the form shows “Something went wrong” as soon as you open a link, the guest user probably can’t run the controller. scripts/setup.sh tries to give the guest user Typeback_Guest (which grants it), but if that didn’t work: [HUMAN] In Builder → Settings → General, click the Guest User Profile link, then Enabled Apex Class Access → Edit, add TypebackFormController, and click Save.
Tip
After any later change to the form, run scripts/setup.sh core and then scripts/setup.sh site. The site serves its last publish, so your change doesn’t show until you republish.
Phase 3: Publish the Agentforce Voice agent
3.1 [AI] Set up, publish and activate the agent:
scripts/setup.sh agent
In order, it:
- Creates an Einstein Agent user, or reuses the one already in the
.agentfile, and writes it intodefault_agent_user. - Assigns that user
Typeback_Agent. - Validates, publishes and activates the newest version of
TypebackDemoVoice.
It worked when it prints TypebackDemoVoice version N is active.
Then put AGENT_USER=<the printed username> in .env.local, so later runs reuse that user instead of using up another license.
Important
Run this before any preview. Without Typeback_Agent, the actions are withheld, and the agent improvises instead of saying the Apex lines.
3.2 [AI] Run the whole loop in a preview (live actions, Log sender, nothing texted):
scripts/preview-loop.sh
It plays a caller who:
- Asks for an email, then gives a fictional number.
- Says “done” too early and asks a side question.
- Submits the form through the same controller the page calls.
- Says “done”, confirms, and asks for a second email.
A second session backs out at the number prompt. The script checks each reply and the request row, and checks that the full email never appears in the preview traces.
It worked when the last line is PASS. Each preview turn takes 3 to 5 seconds.
A preview has no call, so the agent asks for a mobile number. On a real call it uses caller ID instead.
How the agent works
If you want to change the agent, here’s the shape of TypebackDemoVoice.agent:
- A router guard runs first on every turn. It clears
tb_linefirst. Then, if a link is out (tb_waiting), it runs the check action and goes straight to theemail_capturesubagent, with no LLM routing pass. email_capturemakes the link on entry, without the LLM, then saystb_lineword for word.- Its tools are gated with
available whenontb_status:send_to_number,resend_link,skip_emailandconfirm_email. - The resort facts live in the system instructions (check-in, spa hours and so on). They’re made up for the demo.
This is the router guard. It’s the part that makes the pull model work:
start_agent agent_router:
reasoning:
instructions:->
set @variables.tb_line = ""
# A link is out: check it and go straight to email_capture, with no routing pass.
if @variables.tb_waiting:
run @actions.router_check
with voiceCallId=@variables.VoiceCallId
with requestId=@variables.tb_request_id
with cancel=False
set @variables.tb_status = @outputs.status
set @variables.tb_hint = @outputs.spokenHint
set @variables.tb_line = @outputs.agentLine
set @variables.tb_waiting = @outputs.waiting
transition to @subagent.email_capture
Phase 4: Route a phone number to the agent
The agent must be active (Phase 3) before you start, so it shows up in the routing picker. I did all of this in Setup. Deploying the flow as metadata wasn’t tried.
4.1 [HUMAN] Get a number. Go to Setup → Agentforce Voice Setup → Voice Channels with Agents → New Number. A wizard opens in a new window:
- Pick a country.
- Number type: 10-Digit Long Code.
- Pick a number.
- Click Review & Save, then Finish.
The number shows as Requested, then Live within minutes. Use the Phone Numbers tab’s Refresh Status to check. Mine was free because of the org’s entitlement. If your org has none, check with whoever owns the org’s contract before you go ahead.
4.2 [HUMAN] Build the routing flow. Go to Setup → Flows → New Flow → Omni-Channel Flow.
- The flow needs a Text variable named
recordId, available for input. Salesforce passes the VoiceCall Id in it. Create it if the flow doesn’t have one. - Add one Route Work element:
- Route a single record:
recordId - Service Channel: Phone
- Route To: Agentforce Service Agent → Typeback Demo Voice
- Fallback queue: any queue (I used the contact center’s default queue)
- Route a single record:
- Save it as Typeback Voice Routing, then click Activate.
The built-in voice routing templates are managed and can’t be retrieved to copy, so build this one from scratch. It’s only one element.
4.3 [HUMAN] Tie the number to the flow. Go to Setup → Agentforce Voice Setup → Voice Channels with Agents → New Channel. Choose PSTN Voice, your number and Typeback Voice Routing, then click Save.
Warning
The channel is created inactive. Open the channel by clicking its name, then click Activate. The channel list has no toggle, and calls won’t reach the agent until you do this.
4.4 [AI] Check the routing:
sf data query -o typeback -q "SELECT ApiName, IsActive FROM FlowDefinitionView WHERE ApiName = 'Typeback_Voice_Routing'"
sf data query -o typeback -q "SELECT DeveloperName, MessageType, IsActive FROM MessagingChannel"
It worked when the flow shows IsActive true, and there’s a PstnVoice channel with IsActive true.
Optionally, retrieve the flow read-only to check that it points at the agent. The output folder must be inside the project, and org-reference/ is gitignored:
sf project retrieve start -o typeback -m Flow:Typeback_Voice_Routing --target-metadata-dir org-reference/typeback-flow --unzip --json | jq -r '.status'
In the retrieved flow’s routeWork action, “Agentforce Service Agent” is saved like this:
| Parameter | Value |
|---|---|
routingType | Copilot |
copilotId | setupReference TypebackDemoVoice, type BotDefinition |
botId | empty |
serviceChannelDevName | sfdc_phone |
Phase 5: Make a live test call
5.1 [AI] Start the link watcher in its own terminal, just before the call:
scripts/link.sh 300 # waits up to 5 minutes for the agent to make a link on a live call
It watches for a call in progress. As soon as the agent makes a link on it, the script prints the link, its expiry time and, if qrencode is installed, a QR code. This stands in for the text message.
5.2 [HUMAN] Make the call. Turn Wi-Fi off and use speakerphone, so you can type while you talk. Then call the number from your mobile. The first ring can take a while: that’s carrier time before the call reaches Salesforce, which accepted mine 4 to 5 seconds after it arrived.
| You | The agent |
|---|---|
| ”Can you email me my booking confirmation?" | "I just texted a link to your phone ending in …. Open it, type your email, and say done when you’ve sent it.” |
| (scan the QR code or open the printed link on your phone, type a test email, tap Continue, then Send) | (silent) |
| “Done." | "Got it, thanks. I have an email starting with …, at … dot com. Is that right?" |
| "Yes.” | Thanks you and asks if there’s anything else |
| ”That’s all, thanks.” | Wraps up. Hang up when it has |
Note the time you called, so you can match the call to its record.
5.3 [AI] Check the call:
scripts/report.sh 3
It lists the newest requests and calls, with phone numbers cut to their last four digits. It never reads the typed value. For the timings, run these, replacing <VoiceCall Id> with the Id from the first query:
sf data query -o typeback -q "SELECT Id, CreatedDate, CallAcceptDateTime, CallEndDateTime, CallDurationInSeconds, CallDisposition, VendorType FROM VoiceCall ORDER BY CreatedDate DESC LIMIT 1"
sf data query -o typeback -q "SELECT Status, ServiceChannel.DeveloperName, CreatedDate, AcceptDateTime FROM AgentWork WHERE WorkItemId = '<VoiceCall Id>'"
sf data query -o typeback -q "SELECT Name, Status__c, CreatedDate, Submitted_At__c, Retrieved_At__c FROM Typeback_Request__c WHERE Voice_Call_Id__c = '<VoiceCall Id>'"
The call passed when all of these hold:
- The call completed with no hang-up and no transfer.
VendorTypeisVirtualVoiceAgentand the AgentWork is onsfdc_phone.- The call’s
FromPhoneNumberends in your mobile’s last four digits. - There’s one request row with that
Voice_Call_Id__c, its status is Submitted, andRetrieved_At__cis set. - The agent never said the full email.
Here are my timings from the second live call, counted from the start of the call record:
| Event | Time |
|---|---|
| Agent accepts the call | 5 s |
| Link made (after the greeting and the ask) | 21 s |
| Form sent (link to the phone by hand, then typing) | 59 s |
| Agent picks up the hint | 62 s, 3 s after the form was sent |
| Call ends | 84 s |
Phase 6: Clean up
- [AI] Before any commit, put the agent user placeholder back:
node scripts/setup-agent-user.js --username __AGENT_USER_PLACEHOLDER__ - [HUMAN] The Twilio remote site setting:
scripts/setup.sh coredeploys all offorce-app, which includes a remote site setting namedTypeback_Egress_Check(it allows callouts toapi.twilio.com). If you don’t plan to add a Twilio sender, delete it in Setup → Remote Site Settings → Typeback_Egress_Check → Del. Don’t usesf project delete sourcefor this, because it deletes your local copy too. - [HUMAN] To stop calls reaching the agent: deactivate the voice channel, or the Typeback Voice Routing flow. I found no way to release the number in Setup: the Phone Numbers tab only offers Refresh Status.
- [AI] To turn the agent off (after the person says so):
sf agent deactivate -o typeback -n TypebackDemoVoice - [HUMAN] The agent user: deactivate it in Setup → Users if you’re done with it.
Trial orgs expire. Nothing here depends on the org’s history, so in a new org you can start again from Phase 0.
What about texting the link by SMS?
Right now, the Log sender saves each link on its request row (Test_Link__c) instead of texting it, and scripts/link.sh prints it for you. I looked at two ways to send real texts. Neither is built yet.
- Native SMS on the same number. SMS is a separate messaging channel in Agentforce Contact Center (see “Create an SMS Channel in Agentforce Contact Center” in Salesforce Help). For a US 10-digit long code, you first register a 10DLC brand and campaign in Setup → Messaging → Regulatory Compliance. That’s where I stopped: Register Brand said “You need an active brand license to register a brand.” If your org has the license, the sending side would be the Send Conversation Messages action.
- Twilio. I confirmed the org can reach Twilio’s API, but didn’t build a sender. Keep in mind that a Twilio trial only texts verified numbers and adds a prefix to every message, toll-free numbers need verification before they can text US numbers, and US 10-digit numbers need A2P 10DLC registration.
To add a sender yourself:
- Implement
TypebackSms.Senderand return it fromTypebackSms.sender(name). - Let
scripts/configure.shaccept its name. - Keep the callout before any DML, and put credentials in a named credential, never in code or settings.
Troubleshooting: every problem I hit and the fix
“In the repo” means the fix is already in the scripts or code, so you’ll only hit it if you change them.
Salesforce CLI and project
| What happened | Why | Fix |
|---|---|---|
AuthTimeoutError on sf org login web | The CLI only waits a few minutes for you to click Allow | Approve straight away, in a browser window already logged in to the org |
Every retrieve failed with UNSUPPORTED_API_VERSION: Unknown version:68.0 | sourceApiVersion was newer than the org | Set it to the org’s API version (Phase 0.4). In the repo: 67.0 |
Entity of type 'Flow' named 'VoiceRouting' cannot be found | Built-in routing flows are listed but can’t be retrieved, and neither can the managed voice routing templates | Build the routing flow in Flow Builder (Phase 4.2) |
| A script log grew to 36 KB for one retrieve | Without a terminal, sf project retrieve start prints every frame of its progress display | Pass --json. In the repo |
The output directory must be inside the current project | Retrieves only write inside the SFDX project | Retrieve into org-reference/, which is gitignored |
CannotFindBundle: Cannot find an authoring bundle in …/force-app | sf agent publish authoring-bundle only looks in the default package directory, and has no flag to point elsewhere | Make force-app-agent the default package directory. The Apex deploy names -d force-app explicitly. In the repo |
The guest site
| What happened | Why | Fix |
|---|---|---|
| ”Your Site is being created”, then nothing | LWR sites reject AuthenticationType=UNAUTHENTICATED, and the error only appears on the background job | Use AUTHENTICATED_WITH_PUBLIC_ACCESS_ENABLED, which still lets guests in. In the repo |
| The script waited 10 minutes for a site that had already failed | Site creation is asynchronous | Check SELECT Status, Error FROM BackgroundOperation WHERE Id = '<jobId>'. In the repo |
Installing plugin community… | The first sf community command installs its plugin | Nothing to do |
| The form opened without its token | Publish returns the site’s <prefix>vforcesite address, and its redirect drops the query string | Links use https://<host>/typeback/?t=<token>. In the repo |
| Activating would email every site member | Send welcome email is on by default | Turn it off first, then activate (Phase 2.4) |
| A form change didn’t show | The site serves its last publish | Run scripts/setup.sh site after every form deploy |
| The form shows “Something went wrong” right away | The guest user can’t run the controller | Add TypebackFormController to the guest profile’s Apex class access (Phase 2.7) |
Apex and tests
| What happened | Why | Fix |
|---|---|---|
Method does not exist or incorrect signature: void matcher(String) from the type String | Apex names are case-insensitive, so a TOKEN constant and a token parameter clashed with no duplicate error | Give constants distinct names (TOKEN_PATTERN). In the repo |
Deploy said success, with Running Tests - Skipped and 0 tests | The org accepted a RunSpecifiedTests deploy without running the tests | Run sf apex run test … -c after the deploy. In the repo (setup.sh core) |
Operation failed due to fields being inaccessible on Sobject Typeback_Settings__c in tests | Tests updated the record that getOrgDefaults() returns | Update a new record holding only the Id and the changed field. In the repo |
A test comparing expiry to Datetime.now() failed now and then | Saved datetimes lose their milliseconds | Allow a second either side. In the repo |
node --check fails on the LWC | It can’t parse decorators such as @api | Expected. The org compiles the component on deploy |
The Agent Script agent
| What happened | Why | Fix |
|---|---|---|
| The agent paraphrased (“Would you like to continue?”) instead of saying the Apex line | The agent user didn’t have Typeback_Agent yet, so the actions were withheld with NO_USER_ACCESS (visible in the preview trace) | Assign it before the first preview. In the repo (setup.sh agent) |
| An invented “3 PM” check-in, then a fallback to repeating the link line | The model makes up facts, and the grounding check catches it | Put the facts in the system instructions. In the repo |
| A retry within the turn said an old line | tb_line was cleared in after_reasoning, so a retry saw it empty | Clear it at the start of the router. In the repo |
| The agent answered a side question again on the next turn | ”Answer their question first” also covered earlier messages | ”If the caller’s latest message also asks something…”. In the repo |
| ”Never mind, skip it” got “No problem”, but the link stayed open | The model agreed without calling the tool | ”You MUST call skip_email”. In the repo |
| Agent user licenses ran short across rebuilds | Each new agent user takes one | Pin AGENT_USER in .env.local. The script reuses it |
| The preview asked for a mobile number | A preview has no call, so there’s no caller ID | Expected. On a real call it uses caller ID |
Here’s what I confirmed works in Agent Script on Summer ‘26, since the docs didn’t say:
start_agentcan have anactions:block.- Inside
instructions:->,run @actions.X with … set …followed by a baretransition toworks. available whenacceptsand,notand!=.TrueandFalseliterals work in bindings, and boolean Apex outputs map to booleans.- The
VoiceCallId: linked stringvariable withsource: @VoiceCall.Idcompiles, and it’s filled in on a real phone call. - Preview with
--use-live-actionsruns the actions as the agent user.
Phone number, routing and calls
| What happened | Why | Fix |
|---|---|---|
| The first call didn’t reach the agent | New voice channels are created inactive | Open the channel’s own page and click Activate. The list has no toggle |
The flow’s metadata says Copilot, with botId empty | That’s how “Agentforce Service Agent” is saved | Expected. copilotId points at the agent’s BotDefinition |
| The call took a while to connect | It’s carrier time, before the call reaches Salesforce | Nothing to fix. Salesforce accepted the call 4 to 5 seconds after it arrived |
No transcript on the call record (ConversationEntry empty) | Live calls don’t save one there | Not solved. Agentforce session tracing could show the agent’s side, but I didn’t turn it on |
scripts/link.sh fails on Linux | It uses the macOS date -j | Change its date line to GNU date -d |
What’s not done yet
- Caller ID withheld. If the caller hides their number (
*67in the US), the agent should ask for a mobile number. That works in preview but I haven’t tried it on a live call. - Edge cases on a live call: saying “done” too early, a side question, resends up to the cap, and a long silence while typing. The first two pass in preview and the cap passes in the Apex tests. Silence hasn’t been tried.
- A baseline: timing the same request with the email spelled out, to compare against Typeback.
- The expiry test on the form (Phase 2.7, last row).
- Automatic SMS: see texting the link by SMS.
- A transcript of the agent’s side of a live call.
If you rebuild Typeback in your own org and get further than I did (especially on SMS!), I’d love to hear about it :-)
FAQs
Frequently asked questions about this post.
-
What is Typeback?
Typeback is a pattern for Agentforce Voice agents. When the caller needs to give a value that's hard to say out loud, like an email address, the agent makes a link to a one-field form instead of asking them to spell it. The caller types the value on their phone, says "done", and the agent reads back a masked hint on the same call.
-
Does the agent's language model ever see the full email address?
No. The typed value is saved on a Salesforce record that the agent user can't read, because the
Typeback_Agentpermission set has no access to theValue__cfield (and an Apex test proves it). The agent only gets a masked hint such as "starting with C, at example dot com". -
Why does the agent check for the email on the caller's next turn instead of being notified?
There's no API to push an event into a live voice session. So Typeback is pull-based: a router guard in the agent runs the check action at the start of every turn while a link is out, before any LLM routing pass.
-
Does Typeback send the link by SMS?
Not yet. The org I built it in couldn't send SMS (registering a 10DLC brand needs a brand license), so the Log sender saves the link on the request record and
scripts/link.shprints it during the call. The code has aTypebackSms.Senderinterface for adding a real SMS sender later. -
Can an AI coding agent follow this tutorial?
Yes. Every step is tagged [HUMAN] or [AI]. An AI agent can run the [AI] commands and should stop at every [HUMAN] step, tell you what to do, and wait. The tutorial includes a list of rules to give the agent, like never printing access tokens and never activating a site without your go-ahead.
-
How long does a Typeback capture take on a live call?
On my second live call, the agent made the link 21 seconds into the call, the form was sent at 59 seconds, and the agent read back the hint at 62 seconds, 3 seconds after the form was sent. The whole call ended at 84 seconds.