Most voice assistants begin every call with a blank slate. The caller gives their name again. They explain why they called last time. They repeat their preferences. Any context gathered during the previous conversation is trapped in a transcript, CRM record, or database that the assistant cannot immediately use.
In this tutorial, we will build a different pattern: one durable intake dossier per caller. When someone calls, the assistant loads that caller’s history before the conversation begins. It can greet them by name, reference their provider, and include relevant account context. After the call, it files the new reason, summary, and next step back into the same dossier.
Sensitive credentials are encrypted before they reach the assistant, and the complete backend runs as a TypeScript application on Telnyx Edge Compute. The complete sample is available here:
View the per-caller intake code on GitHub
What we are building
The sample uses a fictional healthcare intake workflow, but the underlying architecture is useful anywhere conversations need durable customer context.
The flow looks like this:
Incoming call
|
v
Telnyx AI Assistant
|
| initialization webhook
v
Edge Compute router
|
| actor ID derived from caller number
v
One Stateful Actor per caller
|
| embedded SQL + durable state
v
Dynamic variables + encrypted credential
After the conversation:
Post-conversation webhook
|
v
Same caller actor
|
v
File visit reason, summary, and next step
The example exposes four routes:
POST /webhook/initialization
POST /webhook/post-conversation
GET /dossier/:phone
GET /health
The two webhook routes form the main lifecycle. The initialization webhook retrieves context before the call, and the post-conversation webhook saves what happened afterward.
Route each caller to one actor
The key design decision is how actors are identified.
The initialization payload includes telnyx_end_user_target, which represents the caller for this interaction. The application normalizes that value and uses it as the Stateful Actor ID:
const phoneDigits = normalizePhoneDigits(
event.telnyx_end_user_target
);
const actorId = env.DOSSIERS.idFromName(phoneDigits);
const dossier = env.DOSSIERS.get(actorId);
Every request associated with the same normalized phone number reaches the same actor.
That actor owns the caller’s:
- Identity information
- Provider preference
- Account balance
- Visit history
- Most recent activity
- Filing state
This removes the need for application code that repeatedly queries a shared database, reconstructs a profile, and coordinates simultaneous updates.
The routing key is also the ownership boundary.
A phone number is useful for routing, but it should not automatically be treated as verified identity. Production systems should apply their own authentication and trust policies before revealing sensitive information.
Store relational history inside the actor
Each actor has a private embedded SQL database. The sample creates three tables:
CREATE TABLE IF NOT EXISTS identity (
patient_name TEXT NOT NULL,
balance_due TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS prefs (
provider TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
reason TEXT NOT NULL,
summary TEXT NOT NULL,
next_step TEXT NOT NULL
);
Why SQL instead of one large JSON object?
Visit history is naturally relational. The application may need the most recent visit, a chronological timeline, or filtered records. SQL provides those operations without requiring the entire history to be loaded and rewritten for every update.
The sample seeds demonstration data for a fictional patient named Sarah with a preference for Dr. Lee. Replace this with your own onboarding, CRM, scheduling, or account-data integration.
The important part is that the data belongs to one actor. It is not a globally shared table containing every caller.
Verify the initialization webhook
The initialization endpoint receives a request from Telnyx before the assistant begins the conversation:
if (
request.method === "POST" &&
url.pathname === "/webhook/initialization"
) {
// Verify signature, parse payload, route to actor.
}
Before using the payload, the application verifies its Ed25519 signature using:
- The raw request body
- The Telnyx signature header
- The Telnyx timestamp header
- The public key stored in
TELNYX_PUBLIC_KEY
The endpoint fails closed if the public key is not configured or the signature cannot be verified.
That matters because the request determines which caller dossier will be loaded. An unsigned request should never be able to select an identity and retrieve its context.
After verification, the application confirms that the event is an assistant.initialization event and extracts telnyx_end_user_target.
You can learn more about Telnyx webhook signing in the documentation.
Give the assistant runtime context
Once the request reaches the correct actor, handleInitialization() reads the caller’s information and returns two groups of values:
{
"dynamic_variables": {
"patient_name": "Sarah",
"provider": "Dr. Lee",
"last_visit": "Annual wellness visit",
"balance_due": "$0.00"
},
"encrypted_dynamic_variables": {
"portal_token": "..."
}
}
Regular dynamic variables can be referenced by the assistant during the conversation:
Hello {{patient_name}}. I see your preferred provider is {{provider}}.
How can I help you today?
They can also provide concise context without dumping an entire conversation history into the prompt.
Telnyx sends initialization requests with a default timeout of 1.5 seconds. The timeout can be configured up to 10 seconds. This sample uses an 8-second timeout to leave room for initialization and cold starts.
See the Dynamic Variables guide for the payload format and assistant configuration.
Keep credentials out of the prompt
Not every runtime value should become a normal dynamic variable.
Regular dynamic variables can appear in prompts and logs. That makes them unsuitable for API keys, portal credentials, access tokens, or other sensitive values.
For the fictional portal integration, the actor generates a fresh random token and encrypts it using AES-256-GCM:
const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
},
encryptionKey,
tokenBytes
);
The nonce and ciphertext are combined and encoded as a base64url value. The encrypted result is returned under encrypted_dynamic_variables, not dynamic_variables.
The encryption key must decode to exactly 32 bytes and must be configured in two places:
- As the Edge Compute secret
PORTAL_ENC_KEY - As the matching Telnyx integration secret
portal_enc_key
This lets the assistant use an encrypted credential through the intended integration flow without adding its plaintext value to the conversational prompt.
Telnyx documents this pattern in its Per-Caller Credentials guide.
File the conversation afterward
Once the conversation ends, Telnyx calls the post-conversation endpoint:
POST /webhook/post-conversation
The request contains structured values such as:
{
"telnyx_end_user_target": "+15551234567",
"visit_reason": "Prescription refill",
"follow_up": "Confirm pharmacy details",
"next_step": "Send request to provider"
}
The endpoint first checks the bearer token stored in DOSSIER_WEBHOOK_AUTH. It then normalizes the caller number and routes the request to the same actor used during initialization.
Inside the actor, the record is written to the visits table:
INSERT INTO visits (
at,
reason,
summary,
next_step
) VALUES (?, ?, ?, ?)
The demo also checks for an existing record with the same reason and next step before inserting. If the webhook is retried, the actor can return the existing visit ID rather than filing an obvious duplicate.
For a production system, I would use a stable event or tool invocation ID as the idempotency key whenever one is available. Matching text fields is useful for a sample, but it is not a universal deduplication strategy.
Run the project locally
You will need:
- Node.js 20 or newer
- Telnyx Edge CLI 0.2.2 or newer
- A Telnyx API key
- A configured Telnyx AI Assistant
Clone the repository and enter the example:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/per-caller-intake
Install the dependencies:
npm install
Check the TypeScript:
npx tsc --noEmit
Then run the included smoke tests:
npm run smoke
The smoke test covers several security-sensitive paths:
- AES-GCM encryption and decryption
- Valid Ed25519 signatures
- Modified request bodies
- Stale timestamps
- Incorrect public keys
- Initialization payload parsing
Configure the secrets
Generate a 32-byte encryption key:
KEY=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
Store it in Edge Compute:
telnyx-edge secrets add PORTAL_ENC_KEY "$KEY"
Store the same value in the corresponding Telnyx integration secret as portal_enc_key.
Next, retrieve your Telnyx public key:
PUBLIC_KEY=$(curl -s \
-H "Authorization: Bearer $TELNYX_API_KEY" \
https://api.telnyx.com/v2/public_key \
| jq -r '.data.public')
Add it as another Edge secret:
telnyx-edge secrets add TELNYX_PUBLIC_KEY "$PUBLIC_KEY"
Finally, create a strong token for the post-conversation webhook and save matching copies as:
DOSSIER_WEBHOOK_AUTH
dossier_webhook_auth
The first belongs to your Edge function. The second belongs to the assistant integration that invokes the webhook.
Deploy and connect the assistant
Deploy the application:
telnyx-edge ship
After deployment, use the generated function URL for the assistant’s dynamic variables webhook:
https://your-function.telnyxcompute.com/webhook/initialization
Configure post-conversation processing to call:
https://your-function.telnyxcompute.com/webhook/post-conversation
The sample uses an 8-second initialization timeout:
{
"dynamic_variables_webhook_timeout_ms": 8000,
"post_conversation_settings": {
"enabled": true
}
}
You can inspect a demonstration dossier through:
GET /dossier/:phone
That route is convenient for development, but it should be authenticated or removed before production use.
Where this pattern fits
The example uses patient intake, but the architecture is not healthcare-specific.
A support line could keep one dossier per customer, including open cases, product ownership, and previous troubleshooting steps.
A property-management assistant could remember the caller’s unit, maintenance history, and access preferences.
A financial-services assistant could load account context after authentication and store structured outcomes from each conversation.
A delivery assistant could maintain one durable customer profile with preferred drop-off instructions and recent shipment issues.
The reusable pattern is:
- Derive a stable actor ID from the interaction.
- Load only the context needed for the current conversation.
- encrypt credentials that should not enter the prompt.
- Store the structured outcome after the conversation.
- Route the next interaction back to the same actor.
With that lifecycle in place, a voice assistant stops behaving like a stateless demo and starts acting like part of the application.
Production considerations
Before adapting the sample for real customer data:
- Authenticate callers before exposing sensitive context.
- Define retention and deletion policies.
- Collect only the data the assistant needs.
- Protect or remove diagnostic dossier routes.
- Use stable idempotency keys for webhook retries.
- Rotate both copies of shared encryption keys together.
- Review consent, privacy, and regulatory requirements for your industry.
- Decide whether a phone number is sufficient as an actor key for your threat model.
Top comments (0)