Scheduling a follow-up call sounds simple until the process must survive a restart, a reschedule, or a cancellation without losing the promise made to the caller.

Consider an imaging clinic. During a voice conversation, an assistant tells a patient, "We'll call you Thursday at 10 about your results." The model handled the conversation, but something still has to remember the callback, schedule it, personalize it, and keep it correct if the patient changes the time.

Concierge Diary is an open-source TypeScript sample for that workflow. It combines:

  • AI Assistant post-conversation processing to decide whether a follow-up is needed.
  • A Telnyx Edge Stateful Actor to own the durable diary entry.
  • Scheduled Events to place the future call.
  • Programmable Messaging to confirm the booking by SMS.
  • SQL to keep an inspectable record of the callback lifecycle.

The important design choice is to separate the decision from the promise.

How the follow-up workflow works

After the original call ends, post-conversation processing gives the assistant an additional model turn. That turn can use an HTTP tool to send a structured callback intent to the Edge application:

{ "conversationId": "conv_example_123", "patientName": "Maya", "reason": "results follow-up", "callbackAt": "2026-11-12T10:00:00-08:00", "opener": "Maya, your results are ready to discuss.", "endUserTarget": "<PATIENT_E164>", "agentTarget": "<ASSISTANT_E164>" } 

Replace the target placeholders with E.164 numbers from your own test setup.

The request reaches POST /diary/entries. The router derives one actor from the conversation ID:

const id = env.DIARY.idFromName(conversationId); const actor = env.DIARY.get(id); 

That actor owns the callback from creation through completion. Its state includes the callback time, personalized opener, scheduled-event ID, current status, and a pending-create guard.

Post-call model turn | v POST /diary/entries | v DiaryActor for conversationId |-- writes diary row |-- schedules recovery sweep |-- creates scheduled event `-- sends confirmation SMS | v Future phone call 

The model decides whether, when, and what to say. The actor is responsible for remembering and executing that decision.

Creating the scheduled callback

Before calling the Scheduled Events API, the actor writes the booking and records that an event creation is pending:

await this.replaceState({ ...state, conversationId: intent.conversationId, patientName: intent.patientName, topic: intent.reason, callbackAt: intent.callbackAt, opener: intent.opener, eventId: null, status: "booked", createPending: true, pendingBody: intent, }); await this.writeRow("booked", null); await this.schedule(await this.sweepSeconds(), "onSweep", { conversationId: intent.conversationId, }); await this.createPendingEvent("booked"); 

Writing the intent first means a process restart does not erase the callback request. If the application stops before event creation begins, the scheduled sweep can wake the actor and continue the pending work.

The Scheduled Events request uses the existing assistant, a fixed future time, retry settings, and dynamic variables for the personalized greeting:

{ telnyx_conversation_channel: "phone_call", scheduled_at_fixed_datetime: intent.callbackAt, dynamic_variables: { patient_name: intent.patientName, topic: intent.reason, opener: intent.opener }, max_retries_client_errors: 2, retry_interval_secs: 300 } 

When the event is accepted, the actor stores its ID, clears the pending guard, updates SQL, and sends the confirmation SMS.

Rescheduling without losing context

A diary entry has to evolve after it is created. Concierge Diary exposes a small API for the complete lifecycle:

Route Purpose
POST /diary/entries Create or safely return an existing same-time booking
GET /diary?conversationId=... Inspect actor state and the SQL row
POST /diary/reschedule Delete the pending event and create its replacement
POST /diary/cancel Delete the event and close the diary row
POST /diary/event-status Mark the callback delivered or failed
POST /diary/sweep Manually trigger pending-create recovery

On reschedule, the actor first deletes the old scheduled event and then creates a replacement with the updated time. A 404 from the delete is treated as success because the old event may already have fired or been removed.

Cancellation follows the same principle: remove the pending event, clear the pending state, and retain a canceled diary row instead of silently deleting the history.

Status callbacks are also scoped to the stored event ID. A callback for an older, superseded event cannot accidentally change the current booking.

A precise note about recovery and idempotency

The sample is replay-safe when the same booked callback is submitted again: if the actor already has a booked event for the same time, it returns the existing state instead of creating another event.

The createPending guard and durable sweep also recover when the process stops before the scheduled-event request is completed.

There is still an important production boundary: if the upstream create succeeds but the process stops before the returned event ID is persisted, retrying may create another event unless the upstream operation supports an idempotency key or the application reconciles existing events first. For production use, add that reconciliation strategy, alerting for entries that remain pending, and application-specific retention rules.

Durability should describe the failure modes the system actually handles, not hide the ones it still needs to address.

Authentication and safe demo mode

Live booking routes require a shared DIARY_WEBHOOK_SECRET. Configure the assistant's webhook tool to send it as either:

Authorization: Bearer <DIARY_WEBHOOK_SECRET> 

or:

X-Diary-Token: <DIARY_WEBHOOK_SECRET> 

The project starts with DEMO_MODE=true. In demo mode, actor state and SQL transitions are real, but scheduled-event creation, deletion, and SMS delivery are logged instead of sent. This lets you exercise the workflow without placing calls.

To run the sample:

git clone https://github.com/team-telnyx/telnyx-code-examples.git cd telnyx-code-examples/concierge-diary cp .env.example .env npm install npm run smoke 

Then configure the bindings and deploy:

telnyx-edge auth api-key set "$TELNYX_API_KEY" telnyx-edge secrets add TELNYX_API_KEY "$TELNYX_API_KEY" telnyx-edge types telnyx-edge ship 

Keep DEMO_MODE=true while testing. Before switching to live mode, configure the assistant ID, an account-owned sending number, the clinic name, and the diary webhook secret.

Why this pattern is reusable

The clinic is only one example. The same architecture works whenever an assistant makes a promise that must survive beyond the current conversation:

  • A property manager scheduling a maintenance callback.
  • A dealership arranging a follow-up with a buyer.
  • A service desk promising an incident update.
  • A travel concierge calling when an itinerary changes.

The reusable pattern is consistent: let the model produce structured intent, then hand responsibility to a durable entity that can be inspected, rescheduled, canceled, and recovered.

The quality of an AI assistant is not limited to what it says during the call. It also depends on whether the system keeps the promise after the call ends.

Resources