<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sonam Gupta</title>
    <description>The latest articles on DEV Community by Sonam Gupta (@sonam_gupta_dba00ec9473e5).</description>
    <link>https://dev.to/sonam_gupta_dba00ec9473e5</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4158484%2F743f0f2c-050d-43ad-8a3a-73d94d8311bd.jpg</url>
      <title>DEV Community: Sonam Gupta</title>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sonam_gupta_dba00ec9473e5"/>
    <language>en</language>
    <item>
      <title>Build a Schema-First AI Call QA Pipeline in Python</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Wed, 21 Oct 2026 20:26:29 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-schema-first-ai-call-qa-pipeline-in-python-4ole</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-schema-first-ai-call-qa-pipeline-in-python-4ole</guid>
      <description>&lt;p&gt;Free-text call summaries are useful for people, but awkward for software. A dashboard cannot reliably group a paragraph by sentiment, count missing disclosures, or identify why callers escalated without another parsing step.&lt;/p&gt;

&lt;p&gt;This tutorial builds a schema-first AI call QA pipeline in Python. Each completed conversation becomes a set of typed records for sentiment, compliance, and escalation. Those records can move directly into a scorecard because the expected fields, enums, and required values are declared before analysis begins.&lt;/p&gt;

&lt;p&gt;The complete sample is available in the &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/insights-pipeline" rel="noopener noreferrer"&gt;Telnyx Code Examples repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pipeline does
&lt;/h2&gt;

&lt;p&gt;The example has four layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JSON-schema insight templates
        |
        v
Insight group: qa-scorecard-v1
        |
        v
AI Assistant insight_settings
        |
        v
Conversation insights -&amp;gt; validation -&amp;gt; QA scorecard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It creates three versioned insight templates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sentiment-v1&lt;/code&gt; returns positivity, frustration, overall sentiment, and key emotions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compliance-v1&lt;/code&gt; returns an identity-verification result and a constrained list of compliance flags.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;escalation-v1&lt;/code&gt; returns a controlled escalation reason and whether the agent handled it well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The templates are assigned to one insight group. That group is attached to an AI Assistant once, so future assistant conversations can be analyzed against all three contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why start with JSON Schema?
&lt;/h2&gt;

&lt;p&gt;Without a contract, an analysis model might describe the same issue as "caller asked for a manager," "requested escalation," or "wanted a supervisor." Those phrases may be equivalent to a reader, but they create inconsistent analytics.&lt;/p&gt;

&lt;p&gt;The escalation template constrains the result instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ESCALATION_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalation_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caller_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compliance_violation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poor_call_quality&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system_error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_performed_well&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boolean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalation_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_performed_well&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample sends &lt;code&gt;json_schema&lt;/code&gt; as a JSON string, which is the canonical wire format used by &lt;code&gt;create_templates.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalation-v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Determine whether the caller escalated and why.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ESCALATION_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/ai/conversations/insights&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API also accepts the schema as an object. See the &lt;a href="https://developers.telnyx.com/docs/inference/ai-insights/structured-insights" rel="noopener noreferrer"&gt;structured insights documentation&lt;/a&gt; for the concept and request shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create and verify the insight group
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;create_group.py&lt;/code&gt; finds or creates &lt;code&gt;qa-scorecard-v1&lt;/code&gt;, assigns the three templates, and then reads the group back. The script exits if any expected template is missing from the returned membership.&lt;/p&gt;

&lt;p&gt;This read-after-write check is important. A successful request only proves that the API accepted it; reading the resource back proves that the configuration the assistant will use is present.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TEMPLATE_NAMES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;_assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;templates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;membership&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_read_membership&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;assigned_names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TEMPLATE_NAMES&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;assigned_names&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Assignment verification failed - missing &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Telnyx &lt;a href="https://developers.telnyx.com/docs/inference/ai-insights/insight-groups" rel="noopener noreferrer"&gt;insight groups&lt;/a&gt; let you apply a reusable bundle of analysis templates to an assistant instead of managing each template separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attach the group to an AI Assistant
&lt;/h2&gt;

&lt;p&gt;The assistant is connected to the group through &lt;code&gt;insight_settings&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insight_settings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insight_group_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;group_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/ai/assistants/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;assistant_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wire_assistant.py&lt;/code&gt; follows that update with a &lt;code&gt;GET&lt;/code&gt; request and verifies that the returned &lt;code&gt;insight_group_id&lt;/code&gt; matches the expected group. If &lt;code&gt;ASSISTANT_ID&lt;/code&gt; is not set, the script first creates an assistant using the platform-default model and voice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receive completed insights without depending on one delivery path
&lt;/h2&gt;

&lt;p&gt;The sample supports two ways to discover completed analysis:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;call.conversation.insights_generated&lt;/code&gt; webhook provides a completion signal for real-time workflows.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /ai/conversations/{conversation_id}/conversations-insights&lt;/code&gt; remains the polling source of truth and works even when the webhook receiver is not running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Flask receiver verifies Telnyx's Ed25519 signature before accepting an event. &lt;code&gt;TELNYX_PUBLIC_KEY&lt;/code&gt; is required, and there is no unverified fallback path. Missing or invalid signature data returns &lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Polling also makes the lifecycle explicit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Pipeline behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Count the record as pending.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;in_progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep waiting; analysis is not complete.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;completed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Validate the result against its declared schema.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Report the failed record and stop the live-call workflow with a nonzero exit.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That distinction prevents a slow record from being mislabeled as bad data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate before aggregating
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scorecard.py&lt;/code&gt; maps each returned template ID back to its template name, parses the result, and validates required fields and enum values. Only valid completed records contribute to the scorecard.&lt;/p&gt;

&lt;p&gt;The output aggregates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sentiment distribution;&lt;/li&gt;
&lt;li&gt;compliance-flag counts; and&lt;/li&gt;
&lt;li&gt;escalation reasons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A completed result that violates its schema is printed with its conversation ID and excluded from the totals. You can test this behavior locally with &lt;code&gt;--strict&lt;/code&gt;, which injects a synthetic sentiment record missing &lt;code&gt;positivity_score&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python scorecard.py &lt;span class="nt"&gt;--strict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the pipeline two separate failure categories: records that are not ready yet and records that are complete but structurally invalid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the sample
&lt;/h2&gt;

&lt;p&gt;Clone the repository and install the Python dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/insights-pipeline

python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure &lt;code&gt;.env&lt;/code&gt; with your Telnyx API key and placeholders for the assistant and destination numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TELNYX_API_KEY=YOUR_TELNYX_API_KEY
ASSISTANT_PHONE_NUMBER=&amp;lt;E164_ASSISTANT_NUMBER&amp;gt;
DESTINATION_NUMBER=&amp;lt;E164_TEST_NUMBER&amp;gt;
ASSISTANT_ID=&amp;lt;OPTIONAL_ASSISTANT_ID&amp;gt;
PUBLIC_WEBHOOK_BASE_URL=&amp;lt;OPTIONAL_HTTPS_BASE_URL&amp;gt;
TELNYX_PUBLIC_KEY=&amp;lt;REQUIRED_ONLY_FOR_WEBHOOK_RECEIVER&amp;gt;
DEMO_MODE=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep API keys and private credentials in environment variables or a secret manager. Do not place them in source control, prompts, or screenshots.&lt;/p&gt;

&lt;p&gt;Run the scripts in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python create_templates.py
python create_group.py
python wire_assistant.py
python place_call.py
python scorecard.py &lt;span class="nt"&gt;--date&lt;/span&gt; YYYY-MM-DD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DEMO_MODE=true&lt;/code&gt; is the default. In that mode, &lt;code&gt;place_call.py&lt;/code&gt; prints the scheduled-event payload but does not dial a real number or write a fake conversation ID. Set &lt;code&gt;DEMO_MODE=false&lt;/code&gt; only when you are ready to place the configured test call.&lt;/p&gt;

&lt;p&gt;The webhook receiver is optional because the polling path can complete the workflow without it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python webhook_receiver.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a manual scorecard run, you can bypass the local conversation store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python scorecard.py &lt;span class="nt"&gt;--conversation-id&lt;/span&gt; &amp;lt;CONVERSATION_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where to take it next
&lt;/h2&gt;

&lt;p&gt;The schema-first pattern extends beyond support QA. Add templates for appointment outcomes, sales qualification, cancellation reasons, accessibility needs, or any other result that downstream software must count and filter reliably.&lt;/p&gt;

&lt;p&gt;For production, replace the local &lt;code&gt;conversations.json&lt;/code&gt; ledger with durable storage, send invalid records to an operational review queue, and persist the template version beside each result. The central contract remains the same: define the record first, analyze the conversation second, and aggregate only data that passes validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/insights-pipeline" rel="noopener noreferrer"&gt;Source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-insights/structured-insights" rel="noopener noreferrer"&gt;Structured data insights&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-insights/insight-groups" rel="noopener noreferrer"&gt;Insight groups&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/api-reference/conversations/create-insight-template" rel="noopener noreferrer"&gt;Create an insight template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/api-reference/conversations/get-insights-for-a-conversation" rel="noopener noreferrer"&gt;Get insights for a conversation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/api-reference/callbacks/call-conversation-insights-generated" rel="noopener noreferrer"&gt;&lt;code&gt;call.conversation.insights_generated&lt;/code&gt; webhook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>telnyx</category>
    </item>
    <item>
      <title>Build an Auditable AI Phone Workflow with Telnyx Missions</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Wed, 21 Oct 2026 18:26:34 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-an-auditable-ai-phone-workflow-with-telnyx-missions-1g7e</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-an-auditable-ai-phone-workflow-with-telnyx-missions-1g7e</guid>
      <description>&lt;p&gt;An AI phone workflow can return a successful response and still leave a difficult question unanswered: &lt;strong&gt;what actually happened?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a clinic says an assistant called a patient, confirmed an appointment, and completed the outreach plan, a reviewer may need more than application logs. They need the objective, the approved plan, every observed state transition, the assistant associated with the run, and the final result in one durable record.&lt;/p&gt;

&lt;p&gt;The open-source &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/missions-audit" rel="noopener noreferrer"&gt;&lt;code&gt;missions-audit&lt;/code&gt;&lt;/a&gt; sample shows one way to build that record with Python and Telnyx AI Missions. It creates a mission, starts a run, submits a plan, advances step statuses, logs typed events, optionally schedules a real AI-assisted phone call, and renders the resulting ledger as a timestamped audit report.&lt;/p&gt;

&lt;p&gt;The important idea is simple: &lt;strong&gt;the audit trail is part of the workflow's API contract, not a story reconstructed later from process logs.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an AI mission audit trail?
&lt;/h2&gt;

&lt;p&gt;A mission is the durable objective. A run is one execution of that objective. Each run has an ordered plan, and each plan contains steps whose statuses change as work progresses.&lt;/p&gt;

&lt;p&gt;The sample combines five related resources:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mission&lt;/td&gt;
&lt;td&gt;Stores the durable objective&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run&lt;/td&gt;
&lt;td&gt;Represents one execution and its final result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan and steps&lt;/td&gt;
&lt;td&gt;Describe the intended work and its progress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed events&lt;/td&gt;
&lt;td&gt;Record the chronological audit timeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent link&lt;/td&gt;
&lt;td&gt;Identifies the Telnyx AI assistant associated with the run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This produces a queryable record of what was planned, what the worker observed, and how the run ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-script workflow
&lt;/h2&gt;

&lt;p&gt;The project separates the workflow into five small programs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;create_mission.py&lt;/code&gt; creates the durable mission.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;start_run.py&lt;/code&gt; starts one execution of that mission.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;plan_steps.py&lt;/code&gt; submits an ordered three-step plan.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;execute.py&lt;/code&gt; advances the run, steps, and event timeline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;audit.py&lt;/code&gt; reads fresh server state and renders the report.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The default plan is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;provision_assistant
schedule_outreach_call
verify_outcome
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those names describe the live workflow, but the project is safe by default. More on that distinction shortly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a mission, run, and plan
&lt;/h2&gt;

&lt;p&gt;The sample uses raw HTTP requests against &lt;code&gt;https://api.telnyx.com/v2&lt;/code&gt;. Missions responses are wrapped in a &lt;code&gt;data&lt;/code&gt; envelope and use identifiers such as &lt;code&gt;mission_id&lt;/code&gt; and &lt;code&gt;run_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Creating the durable objective starts with &lt;code&gt;POST /ai/missions&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;mission&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ai/missions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Clinic outreach audit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Verify clinic contacts can be reached&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Verify the contact is reachable, confirm the appointment &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slot briefly, and stay polite.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execution_mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker is external, so the application controls execution while the Missions API keeps the durable record.&lt;/p&gt;

&lt;p&gt;One execution is created with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ai/missions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mission_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/runs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run begins in &lt;code&gt;pending&lt;/code&gt;. The worker can advance it through &lt;code&gt;running&lt;/code&gt; to &lt;code&gt;succeeded&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;. The API also supports &lt;code&gt;paused&lt;/code&gt; and &lt;code&gt;cancelled&lt;/code&gt; states for operator intervention.&lt;/p&gt;

&lt;p&gt;The plan uses client-selected &lt;code&gt;step_id&lt;/code&gt; values, descriptions, and one-based sequence numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provision_assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Create or select the outreach assistant.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schedule_outreach_call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Create one scheduled phone-call event.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify_outcome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Confirm the event reached a terminal status.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sequence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ai/missions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mission_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/runs/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;steps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps move from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;in_progress&lt;/code&gt;, then to &lt;code&gt;completed&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;. A &lt;code&gt;skipped&lt;/code&gt; state is available for intentional no-ops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worker's honesty contract
&lt;/h2&gt;

&lt;p&gt;The most useful pattern in the sample is not an endpoint. It is a rule enforced by &lt;code&gt;execute.py&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Record every state change the worker observes, and never invent a platform outcome.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When a step starts, the worker updates the step and writes a &lt;code&gt;step_started&lt;/code&gt; event. When it completes, the worker writes the corresponding status and &lt;code&gt;step_completed&lt;/code&gt; event. Failures become both a failed step and a &lt;code&gt;step_failed&lt;/code&gt; event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;update_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;log_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_started&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; started&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;work&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;update_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;log_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;

    &lt;span class="nf"&gt;update_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;log_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step_completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In live mode, the worker polls the scheduled-event API every five seconds, with a hard ten-minute cap. Each newly observed event status becomes a &lt;code&gt;status_change&lt;/code&gt; mission event. A request failure or timeout becomes an explicit &lt;code&gt;error&lt;/code&gt; event and causes the verification step to fail.&lt;/p&gt;

&lt;p&gt;That means the ledger never reports that a call completed merely because the worker expected it to complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo mode and live-call mode are deliberately different
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DEMO_MODE=true&lt;/code&gt; is the default.&lt;/p&gt;

&lt;p&gt;In demo mode, the sample exercises the mission, run, plan, step, and event APIs without creating an assistant, creating a scheduled event, placing a call, or linking an agent. It records a custom event with this meaning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"demo_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no assistant created, no call placed"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run's result summary says the same thing. The simulated walk is useful for learning the ledger mechanics, but it cannot be mistaken for evidence of a real phone call.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;DEMO_MODE=false&lt;/code&gt; to exercise the live path. Live mode requires placeholder-backed environment variables for a destination you control and a phone number attached to the assistant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TELNYX_API_KEY=YOUR_TELNYX_API_KEY
DEMO_MODE=false
DESTINATION_NUMBER=&amp;lt;E164_TEST_NUMBER&amp;gt;
ASSISTANT_PHONE_NUMBER=&amp;lt;E164_ASSISTANT_NUMBER&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live worker can create or reuse an assistant, schedule the phone-call event, poll its status, record the latest call attempt, and link the assistant to the mission run. The Assistants and scheduled-events APIs return bare JSON objects rather than the Missions API's &lt;code&gt;data&lt;/code&gt; envelope, so the sample handles those response shapes separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering the report from fresh state
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;audit.py&lt;/code&gt; waits for the run to reach &lt;code&gt;succeeded&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, or &lt;code&gt;cancelled&lt;/code&gt;, with the same ten-minute safety cap. It then performs fresh reads of the mission, run, plan, events, and linked agents.&lt;/p&gt;

&lt;p&gt;The report is therefore built from the server's current view rather than from the worker's in-memory state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MISSIONS AUDIT REPORT

Mission
  mission_id: ...
  name: Clinic outreach audit

Run
  status: succeeded
  result_summary: ...

Steps
  provision_assistant     completed
  schedule_outreach_call  completed
  verify_outcome          completed

Event timeline
  step_started
  step_completed
  status_change
  custom

Linked Telnyx agents
  run_id=... telnyx_agent_id=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the report reaches its polling cap before the run becomes terminal, it prints a warning, performs a final fresh read, renders the current state, and exits unsuccessfully. It does not silently present an unfinished run as complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the sample
&lt;/h2&gt;

&lt;p&gt;Clone the repository and install its dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/missions-audit

python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your Telnyx API key to &lt;code&gt;.env&lt;/code&gt;. Keep &lt;code&gt;DEMO_MODE=true&lt;/code&gt; for the side-effect-free walkthrough.&lt;/p&gt;

&lt;p&gt;Run the included smoke test first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest smoke_test.py &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the workflow, passing the identifiers printed by each step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python create_mission.py
python start_run.py &lt;span class="nt"&gt;--mission-id&lt;/span&gt; &amp;lt;MISSION_ID&amp;gt;
python plan_steps.py &lt;span class="nt"&gt;--mission-id&lt;/span&gt; &amp;lt;MISSION_ID&amp;gt; &lt;span class="nt"&gt;--run-id&lt;/span&gt; &amp;lt;RUN_ID&amp;gt;
python execute.py &lt;span class="nt"&gt;--mission-id&lt;/span&gt; &amp;lt;MISSION_ID&amp;gt; &lt;span class="nt"&gt;--run-id&lt;/span&gt; &amp;lt;RUN_ID&amp;gt;
python audit.py &lt;span class="nt"&gt;--mission-id&lt;/span&gt; &amp;lt;MISSION_ID&amp;gt; &lt;span class="nt"&gt;--run-id&lt;/span&gt; &amp;lt;RUN_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For live mode, use only test numbers you control and configure the required assistant phone number before running &lt;code&gt;execute.py&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern fits
&lt;/h2&gt;

&lt;p&gt;The clinic example is only one use case. The same structure works anywhere an automated workflow must be replayable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Collections outreach where each contact attempt needs a durable record&lt;/li&gt;
&lt;li&gt;Appointment reminders that require proof of execution and outcome&lt;/li&gt;
&lt;li&gt;Incident-response agents whose plans and tool calls need review&lt;/li&gt;
&lt;li&gt;Sales workflows where a human must approve or audit each stage&lt;/li&gt;
&lt;li&gt;Operational automations that can pause, resume, fail, or be cancelled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reusable design is a durable objective, an explicit plan, typed events, independently queryable state, and a final report assembled from fresh reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/missions-audit" rel="noopener noreferrer"&gt;Missions Audit Desk source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/api-reference/missions" rel="noopener noreferrer"&gt;Telnyx Missions API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants" rel="noopener noreferrer"&gt;Telnyx AI Assistants documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com" rel="noopener noreferrer"&gt;Telnyx developer documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An audit trail is most useful when it is designed before the first action runs. With Missions, plans, typed events, and agent linkage treated as API resources, "what happened?" becomes a query instead of an investigation.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>telnyx</category>
    </item>
    <item>
      <title>Build Observable AI Model Fallbacks with Telnyx AI Gateway</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Tue, 20 Oct 2026 02:08:30 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-observable-ai-model-fallbacks-with-telnyx-ai-gateway-3ken</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-observable-ai-model-fallbacks-with-telnyx-ai-gateway-3ken</guid>
      <description>&lt;p&gt;An AI model fallback is only useful if your application can answer two questions afterward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which model actually served the request?&lt;/li&gt;
&lt;li&gt;How many attempts did the gateway make?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A client-side retry loop can hide both answers. The application eventually receives a successful response, but the path to that response is difficult to reconstruct. SDK retries can also stack on top of gateway retries, creating extra requests and misleading attempt counts.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/gateway-fallbacks" rel="noopener noreferrer"&gt;&lt;code&gt;gateway-fallbacks&lt;/code&gt;&lt;/a&gt; Python sample moves that resilience policy into Telnyx AI Gateway. It provisions a primary and fallback model, forces a timeout on the primary, and proves the resulting failover twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;response headers show the model and attempt count on the live request&lt;/li&gt;
&lt;li&gt;the spend ledger records the requested model, serving model, and attempts together&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The example separates management, inference, and observation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Management plane                         Inference plane
api.telnyx.com/v2/llm_token_gateway     llm.telnyx.com/v1
Telnyx account API key                  scoped token key

POST /token_groups                      GET /models
POST /token_keys                        POST /chat/completions
GET  /spend/events                             |
GET  /usage/summary                            v
        |                               gateway retries and fallbacks
        +----------------------+----------------+
                               |
                               v
Observation: x-ltg-served-model, x-ltg-attempts,
             requested_model, model, attempts, cache_hit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The management plane owns token groups, token keys, budgets, and usage reporting. It uses the Telnyx account API key.&lt;/p&gt;

&lt;p&gt;The inference plane is OpenAI-compatible. It uses only the token key issued for the group, so application code does not need the account credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the fallback policy in the token group
&lt;/h2&gt;

&lt;p&gt;The sample provisions two allowed model aliases and one fallback relationship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;allowed_models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Kimi-K3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GLM-5.3-Flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;fallbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Kimi-K3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GLM-5.3-Flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;retries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_delay_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backoff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exponential&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_token_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token_groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gateway-fallbacks-demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;allowed_models&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;allowed_models&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fallbacks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fallbacks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_budget&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;budget_duration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gateway-fallbacks-group-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The group constrains which models its key can call and defines how failures move through the chain. The sample validates the same policy limits locally before making the API request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fallback-chain depth cannot exceed three&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_attempts&lt;/code&gt; must be between one and five&lt;/li&gt;
&lt;li&gt;a chain cannot reference its own primary or contain duplicates&lt;/li&gt;
&lt;li&gt;every fallback must appear in &lt;code&gt;allowed_models&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_attempts × (1 + longest chain)&lt;/code&gt; cannot exceed eight&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gateway also enforces these rules. &lt;code&gt;policy.py&lt;/code&gt; deliberately sends an invalid &lt;code&gt;max_attempts=9&lt;/code&gt; update and expects an HTTP &lt;code&gt;400&lt;/code&gt; without mutating the group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep retries in one layer
&lt;/h2&gt;

&lt;p&gt;The gateway owns retries and model fallbacks, so the OpenAI client disables its own retry behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://llm.telnyx.com/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because an SDK retry is a new request layered on top of the gateway's attempts. Disabling it keeps the response headers, ledger records, and billing aligned around one retry policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the gateway treats failures
&lt;/h2&gt;

&lt;p&gt;The sample distinguishes failures where no model work occurred from failures where processing may already have started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Definite no-work failures&lt;/strong&gt; such as pre-dispatch errors, &lt;code&gt;429&lt;/code&gt;, and &lt;code&gt;529&lt;/code&gt; can retry the same model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uncertain failures&lt;/strong&gt; such as timeouts, &lt;code&gt;408&lt;/code&gt;, &lt;code&gt;500&lt;/code&gt;, &lt;code&gt;502&lt;/code&gt;, &lt;code&gt;503&lt;/code&gt;, and &lt;code&gt;504&lt;/code&gt; move to the next fallback because the original request may already have reached the provider.&lt;/p&gt;

&lt;p&gt;A context-length rejection is terminal because sending the same oversized context to another attempt does not fix the input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Force a model fallback for the demo
&lt;/h2&gt;

&lt;p&gt;Waiting for a provider incident is not a useful test plan. The sample creates a controlled race instead.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inference.py&lt;/code&gt; sends a streaming request to &lt;code&gt;Kimi-K3&lt;/code&gt; with a one-second first-event deadline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chat_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;token_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Kimi-K3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;build_forced_failure_prompt&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;extra_headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ltg-request-timeout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ltg-max-attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated input is intentionally heavy, approximately 22,000 tokens, and includes a unique nonce so a repeated run cannot rely on provider KV caching to shorten prefill. The goal is for the primary's first event to miss the one-second deadline while &lt;code&gt;GLM-5.3-Flash&lt;/code&gt; completes as the fallback.&lt;/p&gt;

&lt;p&gt;Provider latency changes over time, so this is not presented as an absolute guarantee. The script tries up to three times and reports honestly if the primary answers within the deadline or if both attempts time out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the live request through headers
&lt;/h2&gt;

&lt;p&gt;The client reads two headers from the raw response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;served_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ltg-served-model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ltg-attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful failover should show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requested model:   Kimi-K3
served model:      GLM-5.3-Flash
x-ltg-attempts:    2 or more
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK response model is useful, but the gateway headers make the routing decision explicit on the live path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the same event in the spend ledger
&lt;/h2&gt;

&lt;p&gt;The next step reads the gateway spend events for the issued token key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_token_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;end_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token_key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token_key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;page_number&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requested_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempts:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_hit:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_hit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;requested_model&lt;/code&gt; and &lt;code&gt;model&lt;/code&gt; differ, the ledger provides the durable counterpart to the response header. The sample uses a half-open UTC date range and snapshot pagination so the report remains reproducible as the event set changes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /usage/summary&lt;/code&gt; adds totals and per-model rollups when the account has the &lt;code&gt;llm_token_gateway.usage.read&lt;/code&gt; permission. A &lt;code&gt;403&lt;/code&gt; from that endpoint is treated as a nonfatal warning because the spend-events ledger still contains the request-level proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-request controls, caching, and mutation safety
&lt;/h2&gt;

&lt;p&gt;The gateway supports request-level overlays for attempts, delay, backoff, and timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;extra_headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ltg-max-attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ltg-retry-delay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ltg-backoff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exponential&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These controls adjust one request without rewriting the token-group policy.&lt;/p&gt;

&lt;p&gt;The sample also updates &lt;code&gt;cache_ttl&lt;/code&gt; to 60 seconds and sends the same prompt twice. Gateway caching is best effort, so the script does not invent a cache hit: it reports &lt;code&gt;x-ltg-attempts: 0&lt;/code&gt; when the repeat is served from cache and otherwise directs you to the ledger's &lt;code&gt;cache_hit&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;Every update and delete follows the mutation-safety pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retrieve a fresh ETag&lt;/li&gt;
&lt;li&gt;send it through &lt;code&gt;If-Match&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;include an &lt;code&gt;Idempotency-Key&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A stale ETag returns &lt;code&gt;412&lt;/code&gt;, preventing one process from overwriting another process's newer configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the sample
&lt;/h2&gt;

&lt;p&gt;Clone the repository and install the pinned dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/gateway-fallbacks

python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your Telnyx account API key to &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TELNYX_API_KEY=your_telnyx_api_key_here
PRIMARY_MODEL=Kimi-K3
FALLBACK_MODEL=GLM-5.3-Flash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the local smoke tests before making network requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pytest smoke_test.py &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the five-step demonstration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Creates the group and key. The key secret is printed once.&lt;/span&gt;
python provision.py

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GATEWAY_TOKEN_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;paste the secret&amp;gt;"&lt;/span&gt;

python inference.py
python ledger.py
python policy.py
python teardown.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live demonstration incurs real inference spend. The sample configures a maximum budget of USD 10 over one day, but you should review the policy before running it in your account.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;provision.py&lt;/code&gt; stores only the group and key IDs in &lt;code&gt;.gateway_state.json&lt;/code&gt;; it never writes the token secret to disk. &lt;code&gt;teardown.py&lt;/code&gt; revokes the token key, deletes the group, verifies that the revoked key receives HTTP &lt;code&gt;401&lt;/code&gt;, and removes the local state file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reusable pattern
&lt;/h2&gt;

&lt;p&gt;The useful pattern is not limited to these two models:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put model access, retry rules, fallbacks, and budgets in gateway policy.&lt;/li&gt;
&lt;li&gt;Give applications a scoped token key instead of the account credential.&lt;/li&gt;
&lt;li&gt;Disable overlapping client retries.&lt;/li&gt;
&lt;li&gt;Read routing evidence from the response headers.&lt;/li&gt;
&lt;li&gt;Confirm the same event in the spend ledger.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That turns model failover from an assumption into something operators can inspect during the request and audit afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/gateway-fallbacks" rel="noopener noreferrer"&gt;Source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway" rel="noopener noreferrer"&gt;Telnyx AI Gateway overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway/quickstart" rel="noopener noreferrer"&gt;AI Gateway quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway/controls" rel="noopener noreferrer"&gt;Fallback and retry controls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway/usage" rel="noopener noreferrer"&gt;AI Gateway usage reporting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-python" rel="noopener noreferrer"&gt;Telnyx Python SDK&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>llm</category>
      <category>telnyx</category>
    </item>
    <item>
      <title>Build a WhatsApp Coexistence Sync Monitor in Python</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Fri, 16 Oct 2026 23:30:28 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-whatsapp-coexistence-sync-monitor-in-python-40pp</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-whatsapp-coexistence-sync-monitor-in-python-40pp</guid>
      <description>&lt;p&gt;WhatsApp coexistence lets a team use the WhatsApp Business app and the WhatsApp Business API on the same phone number.&lt;/p&gt;

&lt;p&gt;That is useful when human agents handle sensitive or unusual conversations in the app while automated systems send reminders and updates through the API. It also creates a synchronization problem: when a human sends a message in the Business app, the API-side application needs a trustworthy way to learn what happened.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/whatsapp-coexistence-sync-monitor" rel="noopener noreferrer"&gt;&lt;code&gt;whatsapp-coexistence-sync-monitor&lt;/code&gt;&lt;/a&gt; example is a small Flask service that closes that gap. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;verifies Telnyx webhook signatures before parsing an event&lt;/li&gt;
&lt;li&gt;receives &lt;code&gt;message.echo&lt;/code&gt; events created by Business-app activity&lt;/li&gt;
&lt;li&gt;checks whether the number is currently in active coexistence state&lt;/li&gt;
&lt;li&gt;acknowledges but skips events while the number is not active&lt;/li&gt;
&lt;li&gt;writes accepted events to a PII-minimized JSONL audit trail&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The sample has three components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WhatsApp Business app
        |
        | message.echo
        v
Flask webhook receiver
        |
        +-- Ed25519 signature and timestamp check
        +-- coexistence lifecycle-state gate
        +-- append-only JSONL audit log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webhook route is &lt;code&gt;POST /webhooks/whatsapp/echo&lt;/code&gt;. Before the body is decoded as JSON, the service verifies the &lt;code&gt;telnyx-signature-ed25519&lt;/code&gt; and &lt;code&gt;telnyx-timestamp&lt;/code&gt; headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the webhook before parsing it
&lt;/h2&gt;

&lt;p&gt;The sample uses the current Telnyx Python SDK client and its webhook unwrapping helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TELNYX_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TELNYX_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_webhook_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;telnyx-signature-ed25519&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;telnyx-timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_TIMESTAMP_SKEW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unwrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;telnyx-signature-ed25519&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;telnyx-timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TELNYX_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bounded timestamp window helps reject replayed requests. A missing, malformed, stale, or invalid signature returns &lt;code&gt;False&lt;/code&gt;, and the route responds with &lt;code&gt;401&lt;/code&gt; without processing the payload.&lt;/p&gt;

&lt;p&gt;One SDK detail matters here: the &lt;code&gt;unwrap&lt;/code&gt; argument is &lt;code&gt;key&lt;/code&gt;, not &lt;code&gt;public_key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the number's coexistence state
&lt;/h2&gt;

&lt;p&gt;A valid webhook does not automatically mean the echo stream is ready to use. The monitor also checks two lifecycle fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;is_on_biz_app&lt;/code&gt; confirms that the number is registered on the WhatsApp Business app.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;coexistence_state&lt;/code&gt; must be &lt;code&gt;active&lt;/code&gt; before the event is written to the audit trail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In live mode, &lt;code&gt;StateChecker&lt;/code&gt; retrieves the configured number from the WhatsApp Business Account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whatsapp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone_numbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waba_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone_number&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_on_biz_app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_on_biz_app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coexistence_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coexistence_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is cached for 60 seconds by default, so a burst of echo webhooks does not trigger the same lifecycle request repeatedly. At startup, the service also cross-checks the number through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;whatsapp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone_numbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve_phone_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone_number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DEMO_MODE=true&lt;/code&gt; replaces those live requests with simulated active state for local exploration. It does not disable webhook signature verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acknowledge mid-sync events without logging them
&lt;/h2&gt;

&lt;p&gt;If the number is not active, the monitor returns &lt;code&gt;200&lt;/code&gt; but skips the audit write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state_checker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number_not_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an intentional processing decision, not a server failure. Returning a success response prevents repeated webhook delivery for an event the application has already evaluated.&lt;/p&gt;

&lt;p&gt;For active numbers, the handler records two additional verdicts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;origin_matches_biz_app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whatsapp_business_app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;no_charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="nf"&gt;elif &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;no_charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;no_charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unexpected values are preserved as audit signals rather than silently discarded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the audit trail useful without storing phone numbers
&lt;/h2&gt;

&lt;p&gt;The sample writes one JSON object per line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;log_entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;origin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;origin_matches_biz_app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;origin_matches_biz_app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_charge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no_charge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;message_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record intentionally omits &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;to&lt;/code&gt;, and monetary values. The trail still shows what the application received and how it evaluated the event without becoming a store of customer phone numbers.&lt;/p&gt;

&lt;p&gt;JSONL keeps the example easy to run and inspect. A production implementation can retain the same interface while replacing the file with a database or queue and applying the organization's own retention and access policies to message text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/whatsapp-coexistence-sync-monitor

python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure the environment with placeholders or your own credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TELNYX_API_KEY=your_telnyx_api_key_here
TELNYX_PUBLIC_KEY=your_telnyx_public_key_here
WABA_ID=your_whatsapp_business_account_id
WHATSAPP_NUMBER=+1555XXXXXXXX
ECHO_LOG_PATH=echo_log.jsonl
DEMO_MODE=true
STATE_CHECK_TTL_SECONDS=60
MAX_TIMESTAMP_SKEW_SECONDS=300
PORT=5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the Flask service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The health endpoint is available at &lt;code&gt;GET /health&lt;/code&gt;. To receive real webhooks, expose the Flask service through a public HTTPS URL, configure that URL in Telnyx, set &lt;code&gt;DEMO_MODE=false&lt;/code&gt;, and provide the WhatsApp and Telnyx credentials for your account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take it next
&lt;/h2&gt;

&lt;p&gt;The example deliberately keeps persistence simple. A production sync monitor could add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a database-backed audit ledger&lt;/li&gt;
&lt;li&gt;alerts when an echo fails origin or cost checks&lt;/li&gt;
&lt;li&gt;drift detection between manual echoes and API-side conversation state&lt;/li&gt;
&lt;li&gt;per-number dashboards for lifecycle state and skipped events&lt;/li&gt;
&lt;li&gt;retention controls and redaction for message content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key pattern is small: authenticate the event, verify the channel is in a usable state, record only what the application needs, and acknowledge every event according to an explicit policy.&lt;/p&gt;

&lt;p&gt;You can inspect the complete implementation in the &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/whatsapp-coexistence-sync-monitor" rel="noopener noreferrer"&gt;Telnyx code examples repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com" rel="noopener noreferrer"&gt;Telnyx developer documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-python" rel="noopener noreferrer"&gt;Telnyx Python SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telnyx.com/products/whatsapp-api" rel="noopener noreferrer"&gt;Telnyx WhatsApp&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>whatsapp</category>
      <category>webhooks</category>
      <category>telnyx</category>
    </item>
    <item>
      <title>Build a Crash-Resistant AI Podcast Production Pipeline on Telnyx Edge</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Thu, 15 Oct 2026 22:36:03 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-crash-resistant-ai-podcast-production-pipeline-on-telnyx-edge-1c40</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-crash-resistant-ai-podcast-production-pipeline-on-telnyx-edge-1c40</guid>
      <description>&lt;p&gt;Podcast post-production is rarely one operation. Before an episode can be published, someone must transcribe the recording, identify chapters, write show notes, create promotional copy, and produce a trailer. Each step depends on the one before it, which makes the workflow vulnerable to interruptions.&lt;/p&gt;

&lt;p&gt;If processing fails after transcription, should the application transcribe the entire recording again?&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/show-wrap-desk" rel="noopener noreferrer"&gt;Show Wrap Desk&lt;/a&gt; to explore a more durable approach.&lt;/p&gt;

&lt;p&gt;It is a TypeScript application running on Telnyx Edge Compute that turns one podcast recording into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A speaker-labeled transcript&lt;/li&gt;
&lt;li&gt;Timestamped chapters&lt;/li&gt;
&lt;li&gt;AI-generated show notes&lt;/li&gt;
&lt;li&gt;A short trailer script&lt;/li&gt;
&lt;li&gt;An MP3 trailer generated with Text-to-Speech&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow is owned by one Stateful Actor per show, so completed stages remain recorded when later work fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;A producer starts the workflow with one request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;your-worker&amp;gt;/wrap &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "show": "engineering-notes",
    "audioUrl": "https://example.com/episode.mp3"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint immediately returns an episode ID and a &lt;code&gt;202 Accepted&lt;/code&gt; response while the actor continues processing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;transcribing → chapterizing → notes → wrapped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can inspect progress at any time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://&amp;lt;your-worker&amp;gt;/wrapstatus/&amp;lt;episodeId&amp;gt;?show=engineering-notes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One Stateful Actor per show
&lt;/h2&gt;

&lt;p&gt;The worker derives a deterministic actor name from the show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DESKS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;idFromName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`show:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;desk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onWrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audioUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every episode submitted for the same show reaches the same &lt;code&gt;WrapDesk&lt;/code&gt; actor.&lt;/p&gt;

&lt;p&gt;The actor stores its workflow data in embedded SQL tables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;episodes&lt;/code&gt; tracks status, errors, and completion times.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chapters&lt;/code&gt; stores titles and timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;assets&lt;/code&gt; stores transcripts, show notes, and trailer audio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives the workflow a durable ledger without requiring a separate orchestration database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stable tasks prevent duplicate pipelines
&lt;/h2&gt;

&lt;p&gt;Each episode is processed by a scheduled task with a stable ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wrapTick&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;episodeId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`wrap:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;episodeId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scheduling the same ID again replaces the pending task instead of creating another copy.&lt;/p&gt;

&lt;p&gt;After each successful stage, the actor updates the episode status and re-arms that task for the next stage. If the same audio URL is submitted again while processing is incomplete, the actor finds the existing episode and resumes it.&lt;/p&gt;

&lt;p&gt;The individual stages also check for persisted output before doing work. A stored transcript is not transcribed again, existing chapters are preserved, and generated notes or trailer audio are reused.&lt;/p&gt;

&lt;p&gt;A failure can repeat the currently executing external operation if it occurred before the result was saved. However, completed and persisted stages are not restarted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speaker-labeled transcription
&lt;/h2&gt;

&lt;p&gt;The first stage downloads the recording and submits it to the Telnyx Speech-to-Text API using Deepgram Nova-3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;episode.mp3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;model&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deepgram/nova-3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;response_format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verbose_json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;model_config&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;diarize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;smart_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;punctuate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes word-level timestamps and speaker labels. The actor normalizes those words and stores them as the transcript asset before advancing the episode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating chapters
&lt;/h2&gt;

&lt;p&gt;The application groups consecutive words from the same speaker into turns. A silence longer than 60 seconds begins a new chapter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildTurns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chapters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;splitIntoChapters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boundary detection happens locally, so the application does not need an AI request to determine every timestamp.&lt;/p&gt;

&lt;p&gt;Telnyx AI Inference then receives short summaries of the resulting sections and returns chapter titles. The sample uses &lt;code&gt;zai-org/GLM-5.3-Flash&lt;/code&gt; through the Edge Compute &lt;code&gt;TELNYX&lt;/code&gt; binding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing show notes
&lt;/h2&gt;

&lt;p&gt;The next inference request creates Markdown show notes containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A summary of the episode&lt;/li&gt;
&lt;li&gt;Key takeaways&lt;/li&gt;
&lt;li&gt;A pull quote&lt;/li&gt;
&lt;li&gt;The generated chapter list&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The transcript sent to the model is bounded to keep the example inside a manageable context window. A production implementation should introduce chunking or hierarchical summarization for long recordings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating the trailer
&lt;/h2&gt;

&lt;p&gt;The model selects a promising chapter and writes a trailer script capped at 400 characters.&lt;/p&gt;

&lt;p&gt;That script is sent to the Telnyx Text-to-Speech WebSocket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actor collects base64 audio frames until the service sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isFinal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample defaults to &lt;code&gt;azure.en-US-AvaMultilingualNeural&lt;/code&gt;, but &lt;code&gt;HOST_VOICE&lt;/code&gt; can be changed to another supported voice.&lt;/p&gt;

&lt;p&gt;The project does not automatically clone the podcast host’s voice from the submitted recording. A custom or cloned voice must be configured separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/show-wrap-desk

npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your Telnyx API key, then generate the bindings and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run types
npm run deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the generated worker URL as &lt;code&gt;DEPLOY_URL&lt;/code&gt; and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run bootstrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The included demo uses a public-domain LibriVox recording.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking the pattern further
&lt;/h2&gt;

&lt;p&gt;Before using this architecture in production, add authentication, audio-size limits, URL validation, retention controls, workflow telemetry, and human review for generated promotional content.&lt;/p&gt;

&lt;p&gt;Large transcripts and audio files should also move to durable object storage instead of being kept directly in actor SQL.&lt;/p&gt;

&lt;p&gt;The reusable idea is bigger than podcasting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Assign each workload a durable actor.&lt;/li&gt;
&lt;li&gt;Store the output of every stage.&lt;/li&gt;
&lt;li&gt;Track progress with explicit statuses.&lt;/li&gt;
&lt;li&gt;Schedule work using stable IDs.&lt;/li&gt;
&lt;li&gt;Make each stage safe to attempt again.&lt;/li&gt;
&lt;li&gt;Expose status through the same durable ledger.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That pattern can support transcription batches, media processing, document enrichment, report generation, and other long-running AI workflows.&lt;/p&gt;

&lt;p&gt;The full source is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/show-wrap-desk" rel="noopener noreferrer"&gt;https://github.com/team-telnyx/telnyx-code-examples/tree/main/show-wrap-desk&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  ai #typescript #podcasting #edgecomputing
&lt;/h1&gt;

</description>
      <category>ai</category>
      <category>backend</category>
      <category>podcast</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Build a Self-Refreshing Company Handbook Assistant with TypeScript</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Wed, 14 Oct 2026 00:03:55 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-self-refreshing-company-handbook-assistant-with-typescript-52ga</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-self-refreshing-company-handbook-assistant-with-typescript-52ga</guid>
      <description>&lt;p&gt;A company handbook assistant is easy to prototype.&lt;/p&gt;

&lt;p&gt;Embed a document, run a similarity search, pass the closest result to an LLM, and return the answer.&lt;/p&gt;

&lt;p&gt;The harder questions appear after the demo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who checks whether the asynchronous embedding job finished?&lt;/li&gt;
&lt;li&gt;What happens if the worker restarts during indexing?&lt;/li&gt;
&lt;li&gt;How does the index stay current when the handbook changes?&lt;/li&gt;
&lt;li&gt;Where do you record the answers employees received?&lt;/li&gt;
&lt;li&gt;How do you stop the model from answering when retrieval finds nothing relevant?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/policy-recall-line" rel="noopener noreferrer"&gt;&lt;code&gt;policy-recall-line&lt;/code&gt;&lt;/a&gt; sample handles that entire lifecycle with one durable Stateful Actor per company.&lt;/p&gt;

&lt;p&gt;It crawls and embeds a handbook URL, polls the asynchronous indexing job, retries failures, refreshes the source every 24 hours, answers grounded questions, and stores every exchange in an embedded SQL ledger.&lt;/p&gt;

&lt;p&gt;No separate vector database, task queue, cron server, or state database is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;The application exposes these primary routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /company/{company}/source
POST /company/{company}/ask
POST /company/{company}/rpc/audit
POST /company/{company}/rpc/reindexNow
GET  /health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each company gets its own &lt;code&gt;PolicyLine&lt;/code&gt; actor.&lt;/p&gt;

&lt;p&gt;That actor owns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The registered handbook URL&lt;/li&gt;
&lt;li&gt;The Telnyx Storage bucket containing its embeddings&lt;/li&gt;
&lt;li&gt;Current indexing status&lt;/li&gt;
&lt;li&gt;Retry counters&lt;/li&gt;
&lt;li&gt;Scheduled polling and reindexing tasks&lt;/li&gt;
&lt;li&gt;A SQL ledger containing embedding jobs and policy questions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architecture looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Employee question
      |
      v
Edge Compute fetch handler
      |
      | company slug
      v
PolicyLine Stateful Actor
      |
      +--&amp;gt; Telnyx similarity search
      |
      +--&amp;gt; Telnyx-hosted LLM
      |
      +--&amp;gt; Embedded SQL audit ledger
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actor is not only the request handler. It is the lifecycle owner for that company’s policy knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route every company to one actor
&lt;/h2&gt;

&lt;p&gt;The public fetch handler extracts the company slug from the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;company&lt;/span&gt;&lt;span class="se"&gt;\/([^/]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)(\/&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;company&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then derives a deterministic actor ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LIB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;idFromName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`company:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/company/acme/ask
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;always reaches the actor named:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;company:acme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request for another company reaches a different actor with separate state and SQL storage.&lt;/p&gt;

&lt;p&gt;The remaining path is passed to the selected actor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;innerReq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://internal&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;innerReq&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the outer router small. Company-specific behavior and data remain inside the actor that owns them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register and embed a handbook
&lt;/h2&gt;

&lt;p&gt;A company registers its handbook through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /company/acme/source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  https://your-worker.telnyxcompute.com/company/acme/source &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "company": "acme",
    "url": "https://acme.example.com/handbook"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actor first checks whether that company already has a registered source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`company:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Source already registered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then starts an asynchronous embedding job using the Telnyx URL Embeddings API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.telnyx.com/v2/ai/embeddings/url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint crawls the URL and stores its embeddings in the configured Telnyx Storage bucket.&lt;/p&gt;

&lt;p&gt;The response contains a task ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actor records that task in its embedded SQL ledger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;embed_tasks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;finished_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also saves the source in durable actor state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;indexing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;lastIndexedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The registration endpoint returns immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"company:acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"taskId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"task_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"indexing"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Poll the asynchronous job without a separate queue
&lt;/h2&gt;

&lt;p&gt;The embedding job does not finish during the registration request, so the actor schedules a polling task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;POLL_INTERVAL_S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;onPollTask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`poll:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;POLL_INTERVAL_S&lt;/code&gt; is set to 60 seconds in the sample.&lt;/p&gt;

&lt;p&gt;The stable ID is important:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;poll:&amp;lt;taskId&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the task is still queued or processing, the actor schedules another check using that same ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;POLL_INTERVAL_S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;onPollTask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`poll:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes scheduling an upsert rather than an ever-growing collection of duplicate timers.&lt;/p&gt;

&lt;p&gt;The poll handler checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /v2/ai/embeddings/{taskId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If indexing succeeds, it updates both actor state and SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastIndexedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`UPDATE embed_tasks
   SET finished_at = ?, status = 'success'
   WHERE task_id = ?`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the task fails, the actor schedules a retry after 600 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;RETRY_DELAY_S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;onRetryTask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`retry:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxRetries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo allows up to three retry attempts before marking the source as failed.&lt;/p&gt;

&lt;p&gt;This is where the actor architecture earns its place. The indexing lifecycle is stored alongside the source that owns it, and scheduled tasks are not tied to one short-lived request handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer a policy question with retrieval
&lt;/h2&gt;

&lt;p&gt;Once the source is ready, an employee can ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  https://your-worker.telnyxcompute.com/company/acme/ask &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "company": "acme",
    "question": "What is the PTO carry-over cap?"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the source is still indexing, the actor returns &lt;code&gt;202&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Source is still indexing. Try again shortly."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the source is ready, the actor sends the question to the similarity-search endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.telnyx.com/v2/ai/embeddings/similarity-search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;num_of_docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application selects the result with the lowest distance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;selectTopHit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SearchHit&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;SearchHit&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SIMILARITY_FLOOR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample sets &lt;code&gt;SIMILARITY_FLOOR&lt;/code&gt; to &lt;code&gt;0.9&lt;/code&gt;. A lower distance means a closer match.&lt;/p&gt;

&lt;p&gt;If no result passes the threshold, the actor returns a controlled response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I couldn't find a matching policy in the handbook.
Please contact HR for clarification.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not ask the model to invent an answer.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;0.9&lt;/code&gt; threshold is a demonstration default, not a universal production setting. Test it against a representative set of questions and tune it for your content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compose the answer with Telnyx AI Inference
&lt;/h2&gt;

&lt;p&gt;When retrieval finds an acceptable chunk, the actor passes only that excerpt and the employee’s question to the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;completion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELNYX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createCompletion&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are the company librarian. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Answer the employee's question using ONLY &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the provided handbook excerpt. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Be concise and cite the excerpt.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="s2"&gt;`Question: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="s2"&gt;`Handbook excerpt:\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;moonshotai/Kimi-K3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the primary model fails or returns empty content, the sample retries with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;zai-org/GLM-5.3-Flash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inference client is supplied through the Telnyx Edge binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELNYX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createCompletion&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application does not place a separate inference provider key in its source code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record every answer in SQL
&lt;/h2&gt;

&lt;p&gt;Whether retrieval succeeds or produces the controlled no-hit response, the exchange is written to &lt;code&gt;qa_log&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`INSERT INTO qa_log (
     asked_at,
     question,
     answer,
     top_distance,
     source_slug,
     answered
   )
   VALUES (?, ?, ?, ?, ?, ?)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;topHit&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;topHit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;answered&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table becomes a lightweight audit ledger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;qa_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;AUTOINCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;asked_at&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;question&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;answer&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;top_distance&lt;/span&gt; &lt;span class="nb"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_slug&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;answered&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can retrieve the 20 most recent rows through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  https://your-worker.telnyxcompute.com/company/acme/rpc/audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes questions such as “What did the assistant say about travel expenses?” answerable from structured records rather than scattered chat logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh the handbook every night
&lt;/h2&gt;

&lt;p&gt;A policy assistant becomes risky when the source changes but the index does not.&lt;/p&gt;

&lt;p&gt;When the handbook is first registered, the actor schedules a second stable task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;REINDEX_INTERVAL_S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;onReindexTask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`reindex:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;REINDEX_INTERVAL_S&lt;/code&gt; is &lt;code&gt;86,400&lt;/code&gt; seconds, or 24 hours.&lt;/p&gt;

&lt;p&gt;When the task runs, it starts a fresh embedding job, resets the retry count, schedules polling for the new task, and re-arms the next refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startEmbed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;indexing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During reindexing, &lt;code&gt;/ask&lt;/code&gt; returns &lt;code&gt;202&lt;/code&gt; because the source status is no longer &lt;code&gt;ready&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The project also exposes an RPC for immediate reindexing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  https://your-worker.telnyxcompute.com/company/acme/rpc/reindexNow &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"slug": "company:acme"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Run the project
&lt;/h2&gt;

&lt;p&gt;Clone the code and install its dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/team-telnyx/telnyx-code-examples.git

&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/policy-recall-line
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create your local configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set your Telnyx API key and create a Telnyx Storage bucket for the handbook.&lt;/p&gt;

&lt;p&gt;Authenticate the Edge CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge auth api-key &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TELNYX_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before deployment, run the project checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run typecheck
npm run smoke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the Edge types and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge types
telnyx-edge ship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the deployed worker URL to &lt;code&gt;.env&lt;/code&gt; as &lt;code&gt;BASE_URL&lt;/code&gt;, then register the configured handbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run bootstrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full setup and API documentation are included in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production considerations
&lt;/h2&gt;

&lt;p&gt;This is a focused sample, so several controls should be added before using the pattern with real company policies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authenticate &lt;code&gt;/source&lt;/code&gt;, &lt;code&gt;/ask&lt;/code&gt;, &lt;code&gt;/rpc/audit&lt;/code&gt;, and &lt;code&gt;/rpc/reindexNow&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Authorize users against the requested company actor.&lt;/li&gt;
&lt;li&gt;Validate handbook URLs before allowing the service to crawl them.&lt;/li&gt;
&lt;li&gt;Tune the similarity threshold with an evaluation dataset.&lt;/li&gt;
&lt;li&gt;Define retention and deletion policies for the Q&amp;amp;A ledger.&lt;/li&gt;
&lt;li&gt;Add monitoring for sources that remain in &lt;code&gt;indexing&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decide whether questions or answers may contain sensitive employee data.&lt;/li&gt;
&lt;li&gt;Add citations that identify the source page or section more precisely.&lt;/li&gt;
&lt;li&gt;Consider serving the existing index while a refresh is in progress.&lt;/li&gt;
&lt;li&gt;Expand the source model if each company needs more than one handbook URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current sample permits one registered source per company and returns &lt;code&gt;409&lt;/code&gt; when that source already exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is more than a RAG demo
&lt;/h2&gt;

&lt;p&gt;The retrieval pipeline is only one part of the application.&lt;/p&gt;

&lt;p&gt;The durable actor also owns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Source registration&lt;/li&gt;
&lt;li&gt;Asynchronous task state&lt;/li&gt;
&lt;li&gt;Polling&lt;/li&gt;
&lt;li&gt;Retry limits&lt;/li&gt;
&lt;li&gt;Nightly refreshes&lt;/li&gt;
&lt;li&gt;Inference fallback&lt;/li&gt;
&lt;li&gt;The Q&amp;amp;A audit ledger&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the difference between a document-question endpoint and a service that can keep operating after the first request finishes.&lt;/p&gt;

&lt;p&gt;The model answers the question. The actor makes sure there is a current, searchable handbook available when the next question arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/policy-recall-line" rel="noopener noreferrer"&gt;Policy Recall Line source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/api-reference/embeddings/embed-url-content" rel="noopener noreferrer"&gt;Telnyx Embeddings API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/edge-compute/stateful-actors" rel="noopener noreferrer"&gt;Telnyx Stateful Actors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-sdk" rel="noopener noreferrer"&gt;Telnyx Agent SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/ai/inference" rel="noopener noreferrer"&gt;Telnyx AI Inference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/cloud-storage" rel="noopener noreferrer"&gt;Telnyx Storage&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>rag</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Build a Voice Assistant That Remembers Every Caller</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Fri, 09 Oct 2026 17:20:53 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-voice-assistant-that-remembers-every-caller-af0</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-voice-assistant-that-remembers-every-caller-af0</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/per-caller-intake" rel="noopener noreferrer"&gt;View the per-caller intake code on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;The sample uses a fictional healthcare intake workflow, but the underlying architecture is useful anywhere conversations need durable customer context.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the conversation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Post-conversation webhook
    |
    v
Same caller actor
    |
    v
File visit reason, summary, and next step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example exposes four routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /webhook/initialization
POST /webhook/post-conversation
GET  /dossier/:phone
GET  /health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route each caller to one actor
&lt;/h2&gt;

&lt;p&gt;The key design decision is how actors are identified.&lt;/p&gt;

&lt;p&gt;The initialization payload includes &lt;code&gt;telnyx_end_user_target&lt;/code&gt;, which represents the caller for this interaction. The application normalizes that value and uses it as the Stateful Actor ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;phoneDigits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizePhoneDigits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telnyx_end_user_target&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actorId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DOSSIERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;idFromName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phoneDigits&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dossier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DOSSIERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actorId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request associated with the same normalized phone number reaches the same actor.&lt;/p&gt;

&lt;p&gt;That actor owns the caller’s:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Identity information&lt;/li&gt;
&lt;li&gt;Provider preference&lt;/li&gt;
&lt;li&gt;Account balance&lt;/li&gt;
&lt;li&gt;Visit history&lt;/li&gt;
&lt;li&gt;Most recent activity&lt;/li&gt;
&lt;li&gt;Filing state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This removes the need for application code that repeatedly queries a shared database, reconstructs a profile, and coordinates simultaneous updates.&lt;/p&gt;

&lt;p&gt;The routing key is also the ownership boundary.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Store relational history inside the actor
&lt;/h2&gt;

&lt;p&gt;Each actor has a private embedded SQL database. The sample creates three tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;patient_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance_due&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;visits&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;AUTOINCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;next_step&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why SQL instead of one large JSON object?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The important part is that the data belongs to one actor. It is not a globally shared table containing every caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the initialization webhook
&lt;/h2&gt;

&lt;p&gt;The initialization endpoint receives a request from Telnyx before the assistant begins the conversation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook/initialization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Verify signature, parse payload, route to actor.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before using the payload, the application verifies its Ed25519 signature using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The raw request body&lt;/li&gt;
&lt;li&gt;The Telnyx signature header&lt;/li&gt;
&lt;li&gt;The Telnyx timestamp header&lt;/li&gt;
&lt;li&gt;The public key stored in &lt;code&gt;TELNYX_PUBLIC_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The endpoint fails closed if the public key is not configured or the signature cannot be verified.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;After verification, the application confirms that the event is an &lt;code&gt;assistant.initialization&lt;/code&gt; event and extracts &lt;code&gt;telnyx_end_user_target&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can learn more about &lt;a href="https://developers.telnyx.com/docs/development/api-fundamentals/webhooks/receiving-webhooks#webhook-signing" rel="noopener noreferrer"&gt;Telnyx webhook signing in the documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the assistant runtime context
&lt;/h2&gt;

&lt;p&gt;Once the request reaches the correct actor, &lt;code&gt;handleInitialization()&lt;/code&gt; reads the caller’s information and returns two groups of values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dynamic_variables"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"patient_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sarah"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dr. Lee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"last_visit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Annual wellness visit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"balance_due"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$0.00"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"encrypted_dynamic_variables"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"portal_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regular dynamic variables can be referenced by the assistant during the conversation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hello {{patient_name}}. I see your preferred provider is {{provider}}.
How can I help you today?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They can also provide concise context without dumping an entire conversation history into the prompt.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;See the &lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/dynamic-variables" rel="noopener noreferrer"&gt;Dynamic Variables guide&lt;/a&gt; for the payload format and assistant configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep credentials out of the prompt
&lt;/h2&gt;

&lt;p&gt;Not every runtime value should become a normal dynamic variable.&lt;/p&gt;

&lt;p&gt;Regular dynamic variables can appear in prompts and logs. That makes them unsuitable for API keys, portal credentials, access tokens, or other sensitive values.&lt;/p&gt;

&lt;p&gt;For the fictional portal integration, the actor generates a fresh random token and encrypts it using AES-256-GCM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;encryptionKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tokenBytes&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nonce and ciphertext are combined and encoded as a base64url value. The encrypted result is returned under &lt;code&gt;encrypted_dynamic_variables&lt;/code&gt;, not &lt;code&gt;dynamic_variables&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The encryption key must decode to exactly 32 bytes and must be configured in two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;As the Edge Compute secret &lt;code&gt;PORTAL_ENC_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;As the matching Telnyx integration secret &lt;code&gt;portal_enc_key&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This lets the assistant use an encrypted credential through the intended integration flow without adding its plaintext value to the conversational prompt.&lt;/p&gt;

&lt;p&gt;Telnyx documents this pattern in its &lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/per-caller-credentials" rel="noopener noreferrer"&gt;Per-Caller Credentials guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  File the conversation afterward
&lt;/h2&gt;

&lt;p&gt;Once the conversation ends, Telnyx calls the post-conversation endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /webhook/post-conversation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request contains structured values such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"telnyx_end_user_target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+15551234567"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"visit_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Prescription refill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"follow_up"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Confirm pharmacy details"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_step"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Send request to provider"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint first checks the bearer token stored in &lt;code&gt;DOSSIER_WEBHOOK_AUTH&lt;/code&gt;. It then normalizes the caller number and routes the request to the same actor used during initialization.&lt;/p&gt;

&lt;p&gt;Inside the actor, the record is written to the &lt;code&gt;visits&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;visits&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;next_step&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the project locally
&lt;/h2&gt;

&lt;p&gt;You will need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 20 or newer&lt;/li&gt;
&lt;li&gt;Telnyx Edge CLI 0.2.2 or newer&lt;/li&gt;
&lt;li&gt;A Telnyx API key&lt;/li&gt;
&lt;li&gt;A configured Telnyx AI Assistant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clone the repository and enter the example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/per-caller-intake
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the TypeScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the included smoke tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run smoke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The smoke test covers several security-sensitive paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AES-GCM encryption and decryption&lt;/li&gt;
&lt;li&gt;Valid Ed25519 signatures&lt;/li&gt;
&lt;li&gt;Modified request bodies&lt;/li&gt;
&lt;li&gt;Stale timestamps&lt;/li&gt;
&lt;li&gt;Incorrect public keys&lt;/li&gt;
&lt;li&gt;Initialization payload parsing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Configure the secrets
&lt;/h2&gt;

&lt;p&gt;Generate a 32-byte encryption key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32 | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'+/'&lt;/span&gt; &lt;span class="s1"&gt;'-_'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store it in Edge Compute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge secrets add PORTAL_ENC_KEY &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the same value in the corresponding Telnyx integration secret as &lt;code&gt;portal_enc_key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next, retrieve your Telnyx public key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PUBLIC_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TELNYX_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://api.telnyx.com/v2/public_key &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.public'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add it as another Edge secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge secrets add TELNYX_PUBLIC_KEY &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PUBLIC_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, create a strong token for the post-conversation webhook and save matching copies as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DOSSIER_WEBHOOK_AUTH
dossier_webhook_auth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first belongs to your Edge function. The second belongs to the assistant integration that invokes the webhook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy and connect the assistant
&lt;/h2&gt;

&lt;p&gt;Deploy the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge ship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After deployment, use the generated function URL for the assistant’s dynamic variables webhook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-function.telnyxcompute.com/webhook/initialization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure post-conversation processing to call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-function.telnyxcompute.com/webhook/post-conversation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample uses an 8-second initialization timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dynamic_variables_webhook_timeout_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"post_conversation_settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can inspect a demonstration dossier through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /dossier/:phone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That route is convenient for development, but it should be authenticated or removed before production use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern fits
&lt;/h2&gt;

&lt;p&gt;The example uses patient intake, but the architecture is not healthcare-specific.&lt;/p&gt;

&lt;p&gt;A support line could keep one dossier per customer, including open cases, product ownership, and previous troubleshooting steps.&lt;/p&gt;

&lt;p&gt;A property-management assistant could remember the caller’s unit, maintenance history, and access preferences.&lt;/p&gt;

&lt;p&gt;A financial-services assistant could load account context after authentication and store structured outcomes from each conversation.&lt;/p&gt;

&lt;p&gt;A delivery assistant could maintain one durable customer profile with preferred drop-off instructions and recent shipment issues.&lt;/p&gt;

&lt;p&gt;The reusable pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Derive a stable actor ID from the interaction.&lt;/li&gt;
&lt;li&gt;Load only the context needed for the current conversation.&lt;/li&gt;
&lt;li&gt;encrypt credentials that should not enter the prompt.&lt;/li&gt;
&lt;li&gt;Store the structured outcome after the conversation.&lt;/li&gt;
&lt;li&gt;Route the next interaction back to the same actor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With that lifecycle in place, a voice assistant stops behaving like a stateless demo and starts acting like part of the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production considerations
&lt;/h2&gt;

&lt;p&gt;Before adapting the sample for real customer data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authenticate callers before exposing sensitive context.&lt;/li&gt;
&lt;li&gt;Define retention and deletion policies.&lt;/li&gt;
&lt;li&gt;Collect only the data the assistant needs.&lt;/li&gt;
&lt;li&gt;Protect or remove diagnostic dossier routes.&lt;/li&gt;
&lt;li&gt;Use stable idempotency keys for webhook retries.&lt;/li&gt;
&lt;li&gt;Rotate both copies of shared encryption keys together.&lt;/li&gt;
&lt;li&gt;Review consent, privacy, and regulatory requirements for your industry.&lt;/li&gt;
&lt;li&gt;Decide whether a phone number is sufficient as an actor key for your threat model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/per-caller-intake" rel="noopener noreferrer"&gt;Per-caller intake source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/dynamic-variables" rel="noopener noreferrer"&gt;Dynamic Variables&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/per-caller-credentials" rel="noopener noreferrer"&gt;Per-Caller Credentials&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/post-conversation-processing" rel="noopener noreferrer"&gt;Post-Conversation Processing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/edge-compute/stateful-actors" rel="noopener noreferrer"&gt;Stateful Actors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-sdk/sql" rel="noopener noreferrer"&gt;Agent SDK SQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/development/api-fundamentals/webhooks/receiving-webhooks#webhook-signing" rel="noopener noreferrer"&gt;Webhook Signing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Build a Live Supervisor Dashboard for AI Support Calls</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Fri, 09 Oct 2026 16:56:54 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-live-supervisor-dashboard-for-ai-support-calls-22bp</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-live-supervisor-dashboard-for-ai-support-calls-22bp</guid>
      <description>&lt;p&gt;Most voice AI quality checks happen after the call ends. A transcript is stored, someone reviews a small sample later, and any mistake has already reached the customer.&lt;/p&gt;

&lt;p&gt;What if a supervisor could watch an AI support call while it was happening, send the assistant a private coaching instruction, and join the same conversation when human help was needed?&lt;/p&gt;

&lt;p&gt;That is what the &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/live-support-coach-room" rel="noopener noreferrer"&gt;Live Support Coach Room&lt;/a&gt; example demonstrates. It is a TypeScript application running on Telnyx Edge Compute with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a live supervisor dashboard&lt;/li&gt;
&lt;li&gt;one durable room for each AI conversation&lt;/li&gt;
&lt;li&gt;policy-based coaching nudges&lt;/li&gt;
&lt;li&gt;a WebRTC softphone for human escalation&lt;/li&gt;
&lt;li&gt;a simulator that exercises the workflow without placing a real call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This tutorial focuses on the architecture and the fastest way to explore it locally and on Telnyx Edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the finished flow looks like
&lt;/h2&gt;

&lt;p&gt;The application uses two browser tabs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;caller simulator&lt;/strong&gt; creates a conversation and submits caller or assistant turns.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;supervisor dashboard&lt;/strong&gt; lists active rooms, streams the transcript, shows policy flags, and exposes an escalation button.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Try entering the same account number twice in the simulator. The room detects that the caller has repeated sensitive account information without identity verification and injects a coaching instruction telling the assistant to verify the caller's date of birth.&lt;/p&gt;

&lt;p&gt;The same room can then dial a supervisor and join that person to the active AI conversation.&lt;/p&gt;

&lt;p&gt;The high-level flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI Assistant event stream
          |
          v
     AssistRelay
          |
          v
CoachRoom for this conversation
   |          |           |
   |          |           +--&amp;gt; Call Control + WebRTC escalation
   |          +--------------&amp;gt; coaching policy + injected nudge
   +-------------------------&amp;gt; supervisor dashboard WebSocket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why the event stream is a side channel
&lt;/h2&gt;

&lt;p&gt;The assistant's &lt;code&gt;websocket_settings&lt;/code&gt; points to the application's &lt;code&gt;/agents/assist&lt;/code&gt; endpoint. Telnyx opens one WebSocket for each conversation and sends events such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;session.created
conversation.item.created
response.text.delta
telnyx.call.answered
telnyx.call.hangup
session.ended
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This socket observes the conversation and can inject text turns, but it does not carry the call media and does not replace the model.&lt;/p&gt;

&lt;p&gt;That distinction matters. If the coaching application becomes unavailable, the call continues. The caller does not lose audio because the monitoring surface crashed.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/conversation-event-stream" rel="noopener noreferrer"&gt;conversation event stream documentation&lt;/a&gt; describes it as a live side channel. It is currently beta, and clients should ignore event types they do not recognize because new types may be added.&lt;/p&gt;

&lt;p&gt;The stream also is not a guaranteed transcript archive. Events that occur while the socket is unavailable are not replayed. Use it for live supervision, and use the Conversations API after the call when you need a complete system-of-record transcript.&lt;/p&gt;

&lt;p&gt;Configure the assistant with the deployed WebSocket URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.telnyx.com/v2/ai/assistants/&lt;/span&gt;&lt;span class="nv"&gt;$ASSISTANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TELNYX_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "websocket_settings": {
      "enabled": true,
      "url": "wss://live-support-coach-room-&amp;lt;id&amp;gt;.telnyxcompute.com/agents/assist",
      "auth_ref": "&amp;lt;your-integration-secret-reference&amp;gt;"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth_ref&lt;/code&gt; points to an integration secret. Telnyx resolves it and sends the value as a bearer token when opening the WebSocket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route every conversation to its own durable room
&lt;/h2&gt;

&lt;p&gt;The application mounts two Agent SDK WebSocket surfaces under &lt;code&gt;/agents&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleAgents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mountAgents&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;assist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RELAY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;coach-room&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COACHROOMS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/agents/assist&lt;/code&gt; receives the assistant event stream. On &lt;code&gt;session.created&lt;/code&gt;, the relay uses the conversation ID to locate that conversation's &lt;code&gt;CoachRoom&lt;/code&gt; actor.&lt;/p&gt;

&lt;p&gt;Each room owns the state for exactly one conversation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transcript turns&lt;/li&gt;
&lt;li&gt;policy flags&lt;/li&gt;
&lt;li&gt;nudge count&lt;/li&gt;
&lt;li&gt;stream status&lt;/li&gt;
&lt;li&gt;escalation state&lt;/li&gt;
&lt;li&gt;a durable silence timer&lt;/li&gt;
&lt;li&gt;the final coach log&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a good fit for a &lt;a href="https://developers.telnyx.com/docs/edge-compute/stateful-actors" rel="noopener noreferrer"&gt;stateful actor&lt;/a&gt;: the actor identity is the conversation identity, and related events are handled by the same durable instance.&lt;/p&gt;

&lt;p&gt;A separate &lt;code&gt;CoachRegistry&lt;/code&gt; actor tracks active and ended rooms for the dashboard picker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stream snapshots and live changes to the dashboard
&lt;/h2&gt;

&lt;p&gt;The supervisor connects to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wss://&amp;lt;function-host&amp;gt;/agents/coach-room/{conversation_id}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a supervisor opens a room, the dashboard receives its current state and then follows live updates. This means a supervisor joining halfway through a call can see what happened before opening the dashboard instead of starting with an empty screen.&lt;/p&gt;

&lt;p&gt;The application also exposes regular HTTP routes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /rooms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;List active and ended rooms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /rooms/{id}/snapshot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read the current room state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /rooms/{id}/log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read the room's coach log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /rooms/{id}/join&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Escalate to a human supervisor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /health/liveness&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Basic liveness check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /health/readiness&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Configuration readiness check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Inject a coaching nudge
&lt;/h2&gt;

&lt;p&gt;The room watches conversation events and evaluates a deliberately small policy. When a rule fires, it sends a &lt;code&gt;conversation.item.create&lt;/code&gt; frame back over the assistant stream.&lt;/p&gt;

&lt;p&gt;Conceptually, the frame looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"conversation.item.create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Verify the caller's date of birth before sharing account details."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instruction becomes part of the live conversation context, allowing the assistant to change course without ending or restarting the call.&lt;/p&gt;

&lt;p&gt;Two settings constrain the policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NUDGE_MAX_PER_CALL=3
SILENCE_SECS=90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first prevents an overactive policy from flooding one conversation. The second schedules a check-in after extended silence. The silence watcher is cancelled when &lt;code&gt;session.ended&lt;/code&gt; arrives.&lt;/p&gt;

&lt;p&gt;For a production deployment, the simple rule can be replaced with policies for compliance language, authentication, escalation risk, or objection handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring a human into the AI conversation
&lt;/h2&gt;

&lt;p&gt;When the supervisor clicks &lt;strong&gt;Escalate to supervisor&lt;/strong&gt;, the room:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;creates an outbound call to the configured supervisor device&lt;/li&gt;
&lt;li&gt;joins that call leg to the active AI conversation with &lt;code&gt;ai_assistant_join&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;answers the call in the browser through the Telnyx WebRTC SDK&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The softphone answers muted and unmutes after the join succeeds. This avoids exposing setup noise to the caller.&lt;/p&gt;

&lt;p&gt;The human joins the existing conversation as another participant rather than receiving a cold transfer. Telnyx's &lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/multi-participant-calls" rel="noopener noreferrer"&gt;multi-participant call documentation&lt;/a&gt; covers the broader pattern for adding people to active AI calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the simulator first
&lt;/h2&gt;

&lt;p&gt;The included simulator is the quickest way to understand the state flow because it does not require telephony.&lt;/p&gt;

&lt;p&gt;Clone and install the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/live-support-coach-room
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create your environment file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authenticate and prepare the Edge function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge auth api-key &lt;span class="nb"&gt;set&lt;/span&gt; &amp;lt;your_telnyx_api_key&amp;gt;
telnyx-edge new-func &lt;span class="nt"&gt;-l&lt;/span&gt; ts &lt;span class="nt"&gt;-n&lt;/span&gt; live-support-coach-room &lt;span class="nt"&gt;--from-dir&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
telnyx-edge types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the smoke test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsx smoke_test.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge ship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open these pages from the deployed function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;function-host&amp;gt;/caller
https://&amp;lt;function-host&amp;gt;/dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start a simulated call in the caller tab. Enter an account number twice, then watch the policy flag and coaching instruction appear in the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure the live escalation path
&lt;/h2&gt;

&lt;p&gt;The simulated coaching flow works without a call, but live escalation needs the following Edge secrets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge secrets add TELNYX_API_KEY &lt;span class="s2"&gt;"&amp;lt;your-key&amp;gt;"&lt;/span&gt;
telnyx-edge secrets add COACH_AUTH &lt;span class="s2"&gt;"&amp;lt;integration-secret-value&amp;gt;"&lt;/span&gt;
telnyx-edge secrets add CALL_CONTROL_CONNECTION_ID &lt;span class="s2"&gt;"&amp;lt;connection-id&amp;gt;"&lt;/span&gt;
telnyx-edge secrets add TELNYX_NUMBER &lt;span class="s2"&gt;"+1555XXXXXXXX"&lt;/span&gt;
telnyx-edge secrets add SUPERVISOR_DEVICE &lt;span class="s2"&gt;"+1555XXXXXXXX"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use placeholders in source control and store the real values as secrets. The browser softphone also needs a WebRTC login token supplied by the supervisor at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production considerations
&lt;/h2&gt;

&lt;p&gt;This sample demonstrates the architecture, but a production coach room should add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;role-based access for supervisors&lt;/li&gt;
&lt;li&gt;short-lived browser credentials&lt;/li&gt;
&lt;li&gt;policy versioning and approval workflows&lt;/li&gt;
&lt;li&gt;audit retention and deletion rules&lt;/li&gt;
&lt;li&gt;metrics for stream gaps and reconnects&lt;/li&gt;
&lt;li&gt;explicit consent and transcript handling policies&lt;/li&gt;
&lt;li&gt;a post-call source of truth from the Conversations API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central design still holds: keep observability outside the media path, give each conversation a durable owner, and make human intervention a first-class call participant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take it next
&lt;/h2&gt;

&lt;p&gt;The same structure can support more than customer service coaching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;compliance monitoring for regulated calls&lt;/li&gt;
&lt;li&gt;live sales enablement and objection handling&lt;/li&gt;
&lt;li&gt;interpreter or specialist escalation&lt;/li&gt;
&lt;li&gt;healthcare scheduling supervision&lt;/li&gt;
&lt;li&gt;fraud-review assistance&lt;/li&gt;
&lt;li&gt;training dashboards for new agents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can keep the plumbing and replace the policy inside &lt;code&gt;CoachRoom&lt;/code&gt; with the rules that matter to your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/live-support-coach-room" rel="noopener noreferrer"&gt;Live Support Coach Room source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/conversation-event-stream" rel="noopener noreferrer"&gt;Conversation event stream&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-assistants/multi-participant-calls" rel="noopener noreferrer"&gt;Multi-participant Voice AI calls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/edge-compute/stateful-actors" rel="noopener noreferrer"&gt;Stateful Actors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-sdk/websockets" rel="noopener noreferrer"&gt;Agent SDK WebSockets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/api-reference/call-commands/join-ai-assistant-conversation" rel="noopener noreferrer"&gt;Join an AI assistant conversation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/development/webrtc/js-sdk/tutorials/make-your-first-call" rel="noopener noreferrer"&gt;WebRTC JavaScript SDK&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>telnyx</category>
    </item>
    <item>
      <title>Build Persistent Memory for an AI Agent with Python</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Wed, 07 Oct 2026 22:14:03 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-persistent-memory-for-an-ai-agent-with-python-3nbc</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-persistent-memory-for-an-ai-agent-with-python-3nbc</guid>
      <description>&lt;p&gt;AI agents are good at responding to the conversation in front of them. The harder problem is remembering something after that conversation ends.&lt;/p&gt;

&lt;p&gt;A user might tell a support agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;their preferred contact method&lt;/li&gt;
&lt;li&gt;an account identifier&lt;/li&gt;
&lt;li&gt;a product configuration&lt;/li&gt;
&lt;li&gt;the issue they previously reported&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without persistent memory, the next session begins from zero and the agent asks for the same information again.&lt;/p&gt;

&lt;p&gt;In this tutorial, we’ll build a small Python CLI that sends a conversation to &lt;a href="https://developers.telnyx.com/docs/agent-memory" rel="noopener noreferrer"&gt;Telnyx Agent Memory&lt;/a&gt;, waits for the facts to be extracted, and then recalls those facts with a natural-language query.&lt;/p&gt;

&lt;p&gt;The complete example is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/persistent-ai-agent-memory" rel="noopener noreferrer"&gt;github.com/team-telnyx/telnyx-code-examples/tree/main/persistent-ai-agent-memory&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we’re building
&lt;/h2&gt;

&lt;p&gt;The example follows a simple three-step workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Conversation transcript
        |
        v
1. Ingest transcript
        |
        v
2. Poll async operation
        |
        v
3. Recall ranked facts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Submits a support conversation for a profile.&lt;/li&gt;
&lt;li&gt;Receives an asynchronous operation ID.&lt;/li&gt;
&lt;li&gt;Polls until fact extraction finishes.&lt;/li&gt;
&lt;li&gt;Asks what the user’s preferred contact method is.&lt;/li&gt;
&lt;li&gt;Prints the matching memories in relevance order.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This pattern gives an agent continuity without placing every previous conversation into its prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Agent Memory model
&lt;/h2&gt;

&lt;p&gt;Agent Memory organizes information using a few core concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namespace:&lt;/strong&gt; an isolation boundary for an application or environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile:&lt;/strong&gt; the person or entity the memories describe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; the original session or fact submitted to the API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; an individual fact extracted from a source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operation:&lt;/strong&gt; an asynchronous write job that can be tracked to completion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The example uses the &lt;code&gt;default&lt;/code&gt; namespace and a profile named &lt;code&gt;user_123&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A real application could use an existing customer, account, or caller identifier as the profile ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three API calls
&lt;/h2&gt;

&lt;p&gt;Every endpoint is relative to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.telnyx.com/v2/ai/memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example uses these calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /namespaces/{namespace}/profiles/{profile_id}/ingest
GET  /namespaces/{namespace}/operations/{operation_id}
POST /namespaces/{namespace}/profiles/{profile_id}/recall
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s walk through each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Ingest a conversation
&lt;/h2&gt;

&lt;p&gt;First, load the API key and prepare the authentication headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TELNYX_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.telnyx.com/v2/ai/memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a shortened version of the support transcript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Messages are failing for some international destinations.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Let me check your account configuration.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My preferred contact method is email at user@example.com.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Submit the messages to a profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;profile_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo-session-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/namespaces/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/profiles/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/ingest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;operation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operation_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ingest accepted: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;operation_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API returns &lt;code&gt;202 Accepted&lt;/code&gt;, not a completed memory.&lt;/p&gt;

&lt;p&gt;A typical response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"operation_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"op_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"profile_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"demo-session-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"source_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src_xyz789"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fact extraction happens asynchronously. The &lt;code&gt;operation_id&lt;/code&gt; is the handle we use to track it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Poll until the write finishes
&lt;/h2&gt;

&lt;p&gt;A memory cannot be recalled until its write operation has completed.&lt;/p&gt;

&lt;p&gt;The sample checks the operation every two seconds and stops after reaching a terminal state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;terminal_statuses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cancelled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/namespaces/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/operations/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;operation_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Operation status: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;terminal_statuses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The normal progression is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pending -&amp;gt; processing -&amp;gt; completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production code should also enforce a timeout. The complete example stops polling after 60 seconds and raises an error rather than waiting forever.&lt;/p&gt;

&lt;p&gt;This explicit operation lifecycle is useful because your application can distinguish between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a write that is still processing&lt;/li&gt;
&lt;li&gt;a write that completed&lt;/li&gt;
&lt;li&gt;a write that failed&lt;/li&gt;
&lt;li&gt;a write that was cancelled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the client restarts while polling, it can continue checking the same operation instead of submitting the entire conversation again.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Recall relevant facts
&lt;/h2&gt;

&lt;p&gt;Once ingestion completes, query the profile using natural language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/namespaces/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/profiles/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/recall&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What is the user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s preferred contact method?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;facts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results are ranked by relevance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[score=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A result may look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mem_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The user's preferred contact method is email at user@example.com."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recorded_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-05T12:00:05Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response contains the extracted fact rather than requiring the application to search the original transcript itself.&lt;/p&gt;

&lt;p&gt;That fact can now be added to an agent’s context when a new conversation begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the complete example
&lt;/h2&gt;

&lt;p&gt;Clone the repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/persistent-ai-agent-memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"TELNYX_API_KEY=your_telnyx_api_key_here"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example requires only a Telnyx API key. It does not require a phone number, messaging profile, or separate database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Details worth handling in production
&lt;/h2&gt;

&lt;p&gt;The sample includes a few useful safeguards that are easy to overlook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Encode path identifiers
&lt;/h3&gt;

&lt;p&gt;Namespace, profile, and operation identifiers should be encoded before being inserted into a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;

&lt;span class="n"&gt;profile_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents reserved characters in an existing customer identifier from changing the request path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validate inputs before sending them
&lt;/h3&gt;

&lt;p&gt;The example enforces several API constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_id must be &amp;lt;= 128 characters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query must be between 1 and 4096 characters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_k must be between 1 and 100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failing locally gives developers a clearer error than sending an invalid request and debugging it later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not recall immediately after ingesting
&lt;/h3&gt;

&lt;p&gt;An accepted write is not yet a recallable memory.&lt;/p&gt;

&lt;p&gt;If recall returns an empty list immediately after ingestion, first confirm that the operation reached &lt;code&gt;completed&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choose isolation boundaries deliberately
&lt;/h3&gt;

&lt;p&gt;Profiles are isolated from one another, and namespaces provide an additional boundary between applications or environments.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace: production-support
profile: customer_1024
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You might use separate namespaces for development and production, while each customer or caller receives a unique profile inside that namespace.&lt;/p&gt;

&lt;p&gt;Custom namespaces must be created before use. The &lt;code&gt;default&lt;/code&gt; namespace is available without a separate provisioning step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plan for memory deletion
&lt;/h3&gt;

&lt;p&gt;Persistent memory should come with a deletion strategy.&lt;/p&gt;

&lt;p&gt;The Agent Memory API includes endpoints for deleting an individual source or an entire profile. That allows an application to remove one imported session or erase everything associated with a profile when required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern fits
&lt;/h2&gt;

&lt;p&gt;The same ingest, poll, and recall workflow can support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customer support agents&lt;/strong&gt; that remember previous issues and preferences&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sales assistants&lt;/strong&gt; that maintain account context across conversations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling agents&lt;/strong&gt; that recall availability or communication preferences&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice agents&lt;/strong&gt; that recognize returning callers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal copilots&lt;/strong&gt; that retain user-specific working context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The communication channel can change while the memory model stays the same. SMS, voice, email, and browser chat can all resolve to the same profile identifier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;A longer prompt is not the same thing as long-term memory.&lt;/p&gt;

&lt;p&gt;Prompt context is temporary. Persistent memory gives an agent a durable place to store what it has learned and retrieve only the facts relevant to the next interaction.&lt;/p&gt;

&lt;p&gt;The implementation here stays intentionally small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ingest -&amp;gt; poll -&amp;gt; recall
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is enough to move an agent from isolated sessions toward real continuity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/persistent-ai-agent-memory" rel="noopener noreferrer"&gt;Persistent AI Agent Memory code example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-memory" rel="noopener noreferrer"&gt;Telnyx Agent Memory documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/ai" rel="noopener noreferrer"&gt;Telnyx AI tools and skills&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com" rel="noopener noreferrer"&gt;Telnyx developer documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://portal.telnyx.com" rel="noopener noreferrer"&gt;Telnyx Portal&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agentmemory</category>
    </item>
    <item>
      <title>Build a Multi-Tenant AI Spend Meter with Telnyx AI Gateway</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Tue, 06 Oct 2026 19:31:06 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-multi-tenant-ai-spend-meter-with-telnyx-ai-gateway-2ap7</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-multi-tenant-ai-spend-meter-with-telnyx-ai-gateway-2ap7</guid>
      <description>&lt;p&gt;Adding AI features to a multi-tenant product creates an operational question that application-level token counters do not fully answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How much did each tenant actually spend, and what stops the next request when the budget is gone?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your application can estimate usage, but retries, concurrent requests, streaming behavior, and process failures can make that estimate drift away from what the inference layer served.&lt;/p&gt;

&lt;p&gt;The open-source &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-gateway-tenant-meter" rel="noopener noreferrer"&gt;&lt;code&gt;ai-gateway-tenant-meter&lt;/code&gt;&lt;/a&gt; example moves the control point closer to the model request. It combines Telnyx AI Gateway with one durable Edge actor per tenant.&lt;/p&gt;

&lt;p&gt;The gateway meters and enforces. The actor remembers and reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the example builds
&lt;/h2&gt;

&lt;p&gt;The TypeScript application exposes a small HTTP API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /provision&lt;/code&gt; creates a tenant ledger, gateway token group, and token key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /spend?tenantId=...&lt;/code&gt; returns current spend, model usage, guardrail findings, alerts, and budget state&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /adjust&lt;/code&gt; changes the tenant budget with optimistic concurrency&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /health&lt;/code&gt; confirms the worker is available&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each provisioned tenant receives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a 30-day AI Gateway budget&lt;/li&gt;
&lt;li&gt;an allowlist of models&lt;/li&gt;
&lt;li&gt;prompt and response guardrails&lt;/li&gt;
&lt;li&gt;a one-time gateway token key&lt;/li&gt;
&lt;li&gt;one durable &lt;code&gt;SpendLedger&lt;/code&gt; actor&lt;/li&gt;
&lt;li&gt;private SQL tables for daily spend, alerts, and guardrail events&lt;/li&gt;
&lt;li&gt;an hourly rollup task&lt;/li&gt;
&lt;li&gt;SMS notifications at configurable budget thresholds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By default, the warning threshold is 80% and the hard threshold is 100%. The sample starts with &lt;code&gt;DEMO_MODE=true&lt;/code&gt;, so alerts are logged instead of sent while you test the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provision one gateway scope per tenant
&lt;/h2&gt;

&lt;p&gt;The worker routes a tenant ID to a stable actor identity. The actor then creates a gateway token group with its own budget and guardrail policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTokenGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;allowedModels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;monthlyBudget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTokenKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-primary`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token group request includes the controls that should travel with that tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GUARDRAILS_POLICY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;dlp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;financial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;buffered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tenant's application receives a one-time &lt;code&gt;ltg_sk_...&lt;/code&gt; key and uses the OpenAI-compatible inference endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://llm.telnyx.com/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer ltg_sk_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "Kimi-K2.6",
    "messages": [
      {"role": "user", "content": "Summarize this support request."}
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request made with that key is associated with the tenant's token group. When the group exhausts its budget, the gateway returns &lt;code&gt;403 budget_exceeded&lt;/code&gt; rather than relying on an application process to notice the overage first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the operational history in a durable actor
&lt;/h2&gt;

&lt;p&gt;Gateway enforcement answers whether the next inference request may run. The actor answers a different set of questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has the 80% alert already been sent?&lt;/li&gt;
&lt;li&gt;Which models generated the spend?&lt;/li&gt;
&lt;li&gt;What guardrail detectors fired?&lt;/li&gt;
&lt;li&gt;When does the current budget period reset?&lt;/li&gt;
&lt;li&gt;Who changed the budget, and from what value?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tenant maps to one &lt;code&gt;SpendLedger&lt;/code&gt; actor. That actor runs an hourly durable task and writes normalized usage into private SQL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;armRollup&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rollup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hourly-rollup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQL update is idempotent by tenant and day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;spend_days&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;flagged&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;flagged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the runtime restarts, the actor's state and SQL history remain available. The next rollup can continue without rebuilding the tenant ledger from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store guardrail findings without storing the matched text
&lt;/h2&gt;

&lt;p&gt;The actor reads gateway guardrail events and stores detector metadata such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stage&lt;/li&gt;
&lt;li&gt;outcome&lt;/li&gt;
&lt;li&gt;detector&lt;/li&gt;
&lt;li&gt;finding code&lt;/li&gt;
&lt;li&gt;count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not store the sensitive text that triggered the finding.&lt;/p&gt;

&lt;p&gt;That gives an operations team evidence that a secrets or financial-data rule fired without turning the ledger itself into another repository of sensitive prompt data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send each budget alert once per period
&lt;/h2&gt;

&lt;p&gt;During the rollup, the actor compares the current spend with the tenant's configured budget.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pctOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthToDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;monthlyBudget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;warnPct&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerted80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;80&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;warningMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;hardPct&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerted100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hardLimitMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;alerted80&lt;/code&gt; and &lt;code&gt;alerted100&lt;/code&gt; flags live in durable actor state. That prevents a restart or repeated hourly rollup from sending the same notification again.&lt;/p&gt;

&lt;p&gt;When the gateway reports a new &lt;code&gt;resets_at&lt;/code&gt; value, the actor recognizes that a new budget period has started and clears the alert guards.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;DEMO_MODE=false&lt;/code&gt;, alerts are sent through the Telnyx messaging binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELNYX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ADMIN_SMS_TO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ADMIN_SMS_FROM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Protect gateway mutations from retries and concurrent edits
&lt;/h2&gt;

&lt;p&gt;The example centralizes the AI Gateway wire contract in a &lt;code&gt;GatewayClient&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; includes an &lt;code&gt;Idempotency-Key&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Budget updates first read the token group's current version, then send that version through &lt;code&gt;If-Match&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTokenGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokenGroupId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patchTokenGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max_budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newBudget&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If another writer changes the group first, the API returns &lt;code&gt;412 precondition_failed&lt;/code&gt; instead of silently overwriting the newer value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the offline checks first
&lt;/h2&gt;

&lt;p&gt;Clone the example and run its smoke test before using live credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/ai-gateway-tenant-meter

npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env
npm run smoke
npm run typecheck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The smoke test verifies the gateway request shape, &lt;code&gt;data&lt;/code&gt; response envelopes, idempotency headers, &lt;code&gt;If-Match&lt;/code&gt; handling, spend calculations, alert guards, and finding normalization without making live API requests.&lt;/p&gt;

&lt;p&gt;For a deployed test, configure &lt;code&gt;TELNYX_API_KEY&lt;/code&gt;, &lt;code&gt;ADMIN_SMS_FROM&lt;/code&gt;, and &lt;code&gt;ADMIN_SMS_TO&lt;/code&gt;, keep &lt;code&gt;DEMO_MODE=true&lt;/code&gt;, and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run deploy
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://&amp;lt;your-deployment&amp;gt;/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then provision a tenant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;your-deployment&amp;gt;/provision &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "tenantId": "demo-tenant",
    "monthlyBudget": 100
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the returned token key securely. The tenant's application uses that credential for model requests through &lt;code&gt;https://llm.telnyx.com/v1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern fits
&lt;/h2&gt;

&lt;p&gt;This architecture is useful for platforms that operate AI features for multiple customers, teams, or environments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SaaS products offering embedded AI assistants&lt;/li&gt;
&lt;li&gt;managed-service providers running customer-specific agents&lt;/li&gt;
&lt;li&gt;internal AI platforms with departmental budgets&lt;/li&gt;
&lt;li&gt;agencies managing inference workloads for clients&lt;/li&gt;
&lt;li&gt;development platforms separating staging and production spend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reusable idea is straightforward: put budget enforcement at the inference gateway, and give each tenant a durable operational ledger that can explain what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/ai-gateway-tenant-meter" rel="noopener noreferrer"&gt;AI Gateway tenant meter source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway/management-api" rel="noopener noreferrer"&gt;AI Gateway management API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway/usage" rel="noopener noreferrer"&gt;AI Gateway usage reporting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway/guardrails" rel="noopener noreferrer"&gt;AI Gateway guardrails&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/ai-gateway/inference-api" rel="noopener noreferrer"&gt;AI Gateway inference API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/edge-compute/stateful-actors" rel="noopener noreferrer"&gt;Stateful Actors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/ai" rel="noopener noreferrer"&gt;Telnyx AI skills and toolkits&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>ai</category>
      <category>serverless</category>
      <category>telnyx</category>
    </item>
    <item>
      <title>Build a Durable Chargeback Review Workflow on Telnyx Edge</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:43:49 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-durable-chargeback-review-workflow-on-telnyx-edge-579o</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-a-durable-chargeback-review-workflow-on-telnyx-edge-579o</guid>
      <description>&lt;p&gt;A chargeback is not a single API request. It is a case that can remain open for days while evidence arrives, deadlines approach, and a reviewer decides what should happen next.&lt;/p&gt;

&lt;p&gt;That makes chargeback handling an interesting systems problem. The application needs to remember the case, wake up at the right time, evaluate evidence consistently, communicate with the customer, and preserve an audit trail even when the runtime restarts.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/chargeback-adjudication" rel="noopener noreferrer"&gt;&lt;code&gt;chargeback-adjudication&lt;/code&gt;&lt;/a&gt; TypeScript example models each dispute as a durable actor running on Telnyx Edge Compute.&lt;/p&gt;

&lt;p&gt;The sample combines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One Stateful Actor per dispute&lt;/li&gt;
&lt;li&gt;Telnyx Decision Models for structured evidence evaluation&lt;/li&gt;
&lt;li&gt;Scheduled tasks for decision and response deadlines&lt;/li&gt;
&lt;li&gt;SQL for evidence, review queues, and an append-only audit ledger&lt;/li&gt;
&lt;li&gt;SMS for customer updates and reviewer escalation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository uses synthetic evidence and defaults to &lt;code&gt;DEMO_MODE=true&lt;/code&gt;, so SMS messages are logged instead of sent while you explore the workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The central design choice is simple: &lt;strong&gt;the actor is the dispute case&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chargeback webhook
        |
        v
DisputeCase actor, keyed by disputeId
        |
        +-- SQL evidence and audit rows
        +-- Scheduled decision task
        +-- Telnyx Decision Models request
        +-- Policy and human-review routing
        +-- Customer or reviewer SMS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the actor is keyed by &lt;code&gt;disputeId&lt;/code&gt;, later evidence is routed back to the same durable case instead of being reconstructed from a stateless webhook request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a case from a webhook
&lt;/h2&gt;

&lt;p&gt;The Edge handler accepts a chargeback event at &lt;code&gt;POST /webhook/chargeback&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook/chargeback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parseJson&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ChargebackPayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disputeId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disputeId, customer, and orderId required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DISPUTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;idFromName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disputeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChargeback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;idFromName()&lt;/code&gt; gives every dispute a stable actor identity. Inside &lt;code&gt;onChargeback()&lt;/code&gt;, the actor stores the case state, seeds the sample evidence tables, calculates the response window, and schedules a decision task with a stable ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;decide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`decide:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;disputeId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stable task IDs are useful when webhook delivery or application logic retries. Repeated work can converge on the same scheduled operation instead of creating a new timer every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluate several questions in one Decision Models request
&lt;/h2&gt;

&lt;p&gt;The actor assembles the order, delivery record, contact history, and any newly submitted evidence into one shared state object.&lt;/p&gt;

&lt;p&gt;It sends that context to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v2/ai/typesafe/v1/systemone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request asks three named questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;choice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Rule on the chargeback.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;approve_rebate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Delivery evidence supports the customer's order.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;request_evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Evidence is inconclusive; more proof is needed.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Evidence supports the merchant; deny the dispute.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;loseProb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;score&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Rate the merchant's risk of losing.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Clearly likely to win&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Uncertain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Clearly likely to lose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;fraud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Does this look like a fraud attempt?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These question types serve different purposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;choice&lt;/code&gt; selects one supplied category.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;noul&lt;/code&gt; produces a signal between zero and one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;score&lt;/code&gt; returns an expected position on the ordered rubric.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Decision Models &lt;code&gt;score&lt;/code&gt; should not automatically be treated as a calibrated probability. With three rubric entries, the result falls along the rubric’s zero-to-two range and can be fractional.&lt;/p&gt;

&lt;p&gt;Production policy should be tested against representative, labeled cases rather than treating the result as certainty.&lt;/p&gt;

&lt;p&gt;The endpoint defaults to &lt;code&gt;telnyx/decision-flash&lt;/code&gt; when &lt;code&gt;model&lt;/code&gt; is omitted. You can also set the model explicitly based on the context requirements of your workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep model output separate from business policy
&lt;/h2&gt;

&lt;p&gt;The model produces structured evidence signals. Application code still owns the business action.&lt;/p&gt;

&lt;p&gt;The sample’s policy layer can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Approve a rebate and notify the customer&lt;/li&gt;
&lt;li&gt;Request additional evidence&lt;/li&gt;
&lt;li&gt;Deny the claim&lt;/li&gt;
&lt;li&gt;Route a suspected-fraud case to a human review queue
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;noul&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;FRAUD_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO reviewQueue VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;disputeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fraud_hold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendSms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;reviewerOnCall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Review required for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;disputeId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold in this sample is illustrative. Before using a similar rule in production, evaluate it against your own data, add authorization and reviewer controls, and clearly define which outcomes may be automated.&lt;/p&gt;

&lt;p&gt;Decision Models help determine what deserves attention. They do not remove the human reviewer from high-risk decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-evaluate when new evidence arrives
&lt;/h2&gt;

&lt;p&gt;A dispute can change after its initial review.&lt;/p&gt;

&lt;p&gt;The second webhook route, &lt;code&gt;POST /webhook/inbound-message&lt;/code&gt;, sends an SMS or MMS response back to the existing actor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onNewEvidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mediaUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assembleEvidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mediaUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;judgeWithDecisionModel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;newEvidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendAudit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;re-evaluated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The case keeps its identity and previous state. New evidence becomes another event in the same lifecycle, and the resulting evaluation is appended to the ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve an audit trail in SQL
&lt;/h2&gt;

&lt;p&gt;Every material event is written as a new row rather than overwriting the previous decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO audit VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;disputeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This append-only pattern makes it possible to reconstruct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What evidence the application had&lt;/li&gt;
&lt;li&gt;Which model output it received&lt;/li&gt;
&lt;li&gt;Which policy branch was selected&lt;/li&gt;
&lt;li&gt;When the state changed&lt;/li&gt;
&lt;li&gt;Whether new evidence caused a re-evaluation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a real financial or healthcare workflow, this example is only a starting point.&lt;/p&gt;

&lt;p&gt;Add authentication, webhook verification, encryption, retention controls, idempotency around external actions, reviewer permissions, monitoring, and a domain-specific compliance review before processing sensitive data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the sample
&lt;/h2&gt;

&lt;p&gt;Clone the examples repository and install the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/chargeback-adjudication

npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample configuration includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TELNYX_API_KEY=your_telnyx_api_key_here
RESPONSE_DEADLINE_DAYS=7
REVIEWER_ONCALL_E164=+1555XXXXXXXX
DEMO_MODE=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authenticate the Edge CLI, run the smoke test, and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;telnyx-edge auth api-key &lt;span class="nb"&gt;set&lt;/span&gt; &amp;lt;your_telnyx_api_key&amp;gt;
npx tsx smoke_test.ts
telnyx-edge ship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DEMO_MODE=true&lt;/code&gt; suppresses real SMS delivery and uses synthetic evidence. The deployed adjudication path still requires a Telnyx API key to call Decision Models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else this pattern fits
&lt;/h2&gt;

&lt;p&gt;The reusable idea is larger than chargebacks: assign one durable actor to every long-running case, keep its evidence and history nearby, request structured model outputs, and let explicit application policy determine what happens next.&lt;/p&gt;

&lt;p&gt;The same architecture could support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Insurance claim review&lt;/li&gt;
&lt;li&gt;Returns and refund exceptions&lt;/li&gt;
&lt;li&gt;Account-verification cases&lt;/li&gt;
&lt;li&gt;Compliance investigations&lt;/li&gt;
&lt;li&gt;Support escalations with deadlines&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/chargeback-adjudication" rel="noopener noreferrer"&gt;Chargeback adjudication source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/decision-models" rel="noopener noreferrer"&gt;Decision Models documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/api-reference/decision-models/evaluate-decision-models-typesafe-compatible" rel="noopener noreferrer"&gt;Decision Models API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-sdk" rel="noopener noreferrer"&gt;Agent SDK documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-sdk/sql" rel="noopener noreferrer"&gt;Agent SDK SQL documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-sdk/scheduled-tasks" rel="noopener noreferrer"&gt;Agent SDK scheduled tasks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/ai" rel="noopener noreferrer"&gt;Telnyx AI skills and toolkits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://portal.telnyx.com/" rel="noopener noreferrer"&gt;Telnyx Portal&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>ai</category>
      <category>serverless</category>
      <category>edgecompute</category>
    </item>
    <item>
      <title>Build Automated Post-Call QA Scoring with Telnyx Decision Models</title>
      <dc:creator>Sonam Gupta</dc:creator>
      <pubDate>Fri, 02 Oct 2026 20:36:24 +0000</pubDate>
      <link>https://dev.to/sonam_gupta_dba00ec9473e5/build-automated-post-call-qa-scoring-with-telnyx-decision-models-nh2</link>
      <guid>https://dev.to/sonam_gupta_dba00ec9473e5/build-automated-post-call-qa-scoring-with-telnyx-decision-models-nh2</guid>
      <description>&lt;p&gt;Support teams have a math problem.&lt;/p&gt;

&lt;p&gt;Thousands of customer calls happen every week, but supervisors can manually review only a small fraction of them. Even when a call is graded, that score often ends up isolated in a spreadsheet. It cannot tell you whether an agent had one difficult conversation or is developing a pattern.&lt;/p&gt;

&lt;p&gt;That creates two distinct engineering problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; evaluate more completed calls without requiring someone to listen to each recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; preserve each agent's results long enough to identify trends and make better coaching decisions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The open-source &lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/post-call-qa-scoring" rel="noopener noreferrer"&gt;&lt;code&gt;post-call-qa-scoring&lt;/code&gt;&lt;/a&gt; example addresses both. It uses Telnyx Decision Models to grade transcripts and a durable &lt;code&gt;QAAgent&lt;/code&gt; actor to maintain the quality history for each support agent.&lt;/p&gt;

&lt;p&gt;The result is not an autonomous disciplinary system. It is a way to surface calls and trends that deserve human attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the application does
&lt;/h2&gt;

&lt;p&gt;When a support call ends, the application:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;receives the transcript from a &lt;code&gt;call-conversation-ended&lt;/code&gt; webhook&lt;/li&gt;
&lt;li&gt;uses &lt;code&gt;transcription-saved&lt;/code&gt; as a fallback when the transcript is not embedded&lt;/li&gt;
&lt;li&gt;routes the transcript to a durable actor keyed by the support agent ID&lt;/li&gt;
&lt;li&gt;asks a Telnyx Decision Model for three structured grading signals&lt;/li&gt;
&lt;li&gt;stores the result in private per-actor SQL&lt;/li&gt;
&lt;li&gt;recomputes a five-call rolling average&lt;/li&gt;
&lt;li&gt;flags or clears a coaching recommendation based on that trend&lt;/li&gt;
&lt;li&gt;escalates potential compliance breaches to a manager over SMS&lt;/li&gt;
&lt;li&gt;sends the team lead a daily per-agent digest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each call ID is stored as a SQL primary key before grading is scheduled. The scheduled task also uses a stable ID, &lt;code&gt;grade:&amp;lt;callId&amp;gt;&lt;/code&gt;, so webhook retries do not create duplicate grades.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why use one actor per support agent?
&lt;/h2&gt;

&lt;p&gt;The central design decision is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The actor is the agent's quality profile.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The worker resolves an actor with &lt;code&gt;idFromName(agentId)&lt;/code&gt;. Every later transcript for that support agent reaches the same durable actor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actorId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QA_AGENT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;idFromName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qaAgent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QA_AGENT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actorId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;qaAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recordCallEnded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;digestEnabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That actor owns the score history, rolling average, coaching state, breach records, and digest schedule. The application does not have to reconstruct the agent's history from a cache every time another call ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grade the transcript into structured fields
&lt;/h2&gt;

&lt;p&gt;Free-text model output is awkward for QA automation. If the model returns a paragraph, downstream code has to interpret that prose before it can calculate an average or apply an escalation threshold.&lt;/p&gt;

&lt;p&gt;The example instead calls the Telnyx Decision Models endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v2/ai/typesafe/v1/systemone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request evaluates the transcript against three question types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;choice&lt;/code&gt;: pass or a specific failure category&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;noul&lt;/code&gt;: a 0–1 compliance-breach signal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;score&lt;/code&gt;: an overall quality score from 0–5&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application can then apply ordinary program logic to typed values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DecisionResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;noul&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A call can pass its general quality evaluation and still carry a serious compliance signal. The sample therefore treats these fields independently. When &lt;code&gt;noul &amp;gt; 0.8&lt;/code&gt;, it records the call in the &lt;code&gt;breaches&lt;/code&gt; table and sends an immediate manager-review alert regardless of the pass/fail choice.&lt;/p&gt;

&lt;p&gt;Decision Models do not replace the reviewer here. They decide which evidence should be placed in front of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn individual grades into a trend
&lt;/h2&gt;

&lt;p&gt;After every successful grade, the actor queries its SQL history and recomputes a five-call rolling average.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;noul&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'graded'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the average falls below &lt;code&gt;QA_COACHING_FLOOR&lt;/code&gt;, the actor flags the agent for coaching. If later calls bring the average back above the threshold, it clears the flag automatically.&lt;/p&gt;

&lt;p&gt;That distinction matters. One low score may be an isolated call. A declining rolling average is a stronger reason to investigate, coach, or review the underlying conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep failures visible
&lt;/h2&gt;

&lt;p&gt;The grading task uses bounded retries rather than retrying forever. Transient failures back off for 10, 30, and 60 seconds. If grading still cannot complete, the call is stored with &lt;code&gt;status="ungraded"&lt;/code&gt; and a &lt;code&gt;last_error&lt;/code&gt; value.&lt;/p&gt;

&lt;p&gt;The daily digest can therefore distinguish between a low score and a transcript that was never successfully graded. Failed AI work does not quietly disappear from the reporting pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the complete pipeline without placing a call
&lt;/h2&gt;

&lt;p&gt;The repository includes a synthetic demo endpoint. After deploying the example, call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;edge-function-url&amp;gt;/demo/trigger &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "agentId": "demo-agent",
    "callId": "demo-001",
    "transcript": "Agent: This call may be recorded for quality purposes..."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint routes the transcript through the same actor, Decision Models request, SQL history, trend calculation, and escalation logic used by the webhook flow. It is intended for demonstration and testing, so you can inspect the architecture before connecting a live call application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/team-telnyx/telnyx-code-examples.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telnyx-code-examples/post-call-qa-scoring

npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env
npm run typecheck
npm &lt;span class="nb"&gt;test
&lt;/span&gt;npm run deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For live Decision Models calls and SMS notifications, configure the Telnyx API key, a messaging-enabled Telnyx number, and the team lead's E.164 number as Edge secrets. The repository README documents every supported setting, including the coaching floor and digest hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take this pattern next
&lt;/h2&gt;

&lt;p&gt;The sample uses a regional-bank support scenario, but the architecture is useful anywhere conversations need consistent review over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;contact-center coaching&lt;/li&gt;
&lt;li&gt;healthcare communication audits&lt;/li&gt;
&lt;li&gt;insurance disclosure checks&lt;/li&gt;
&lt;li&gt;sales-call quality monitoring&lt;/li&gt;
&lt;li&gt;customer-service escalation review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important pattern is broader than call scoring: produce structured decisions, keep durable history around the entity being measured, and route high-stakes outcomes to a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/telnyx-code-examples/tree/main/post-call-qa-scoring" rel="noopener noreferrer"&gt;Post-call QA scoring code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/inference/decision-models" rel="noopener noreferrer"&gt;Telnyx Decision Models documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com/docs/agent-sdk" rel="noopener noreferrer"&gt;Telnyx Agent SDK documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/team-telnyx/ai" rel="noopener noreferrer"&gt;Telnyx AI skills and toolkits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.telnyx.com" rel="noopener noreferrer"&gt;Telnyx Developer documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>ai</category>
      <category>serverless</category>
      <category>telnyx</category>
    </item>
  </channel>
</rss>
