<?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: Alexey Sazhin</title>
    <description>The latest articles on DEV Community by Alexey Sazhin (@palermo-777).</description>
    <link>https://dev.to/palermo-777</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%2F3852318%2Fe6eb3d7a-e288-474d-9b8d-293088019798.png</url>
      <title>DEV Community: Alexey Sazhin</title>
      <link>https://dev.to/palermo-777</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/palermo-777"/>
    <language>en</language>
    <item>
      <title>Building a Voice Support Agent using ElevenLabs and Cloudflare</title>
      <dc:creator>Alexey Sazhin</dc:creator>
      <pubDate>Mon, 07 Sep 2026 21:51:33 +0000</pubDate>
      <link>https://dev.to/palermo-777/building-a-voice-support-agent-using-elevenlabs-and-cloudflare-89l</link>
      <guid>https://dev.to/palermo-777/building-a-voice-support-agent-using-elevenlabs-and-cloudflare-89l</guid>
      <description>&lt;h3&gt;
  
  
  Artifacts
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://github.com/palermo-777/elevenlabs-agent-cf/blob/master/" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt; with Cloudflare Workers code.&lt;/li&gt;
&lt;li&gt;JSON config of ElevenLabs Agent in &lt;a href="https://github.com/palermo-777/elevenlabs-agent-cf/blob/master/elevenlabs-agent/surfie-agent-export.json" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Intro
&lt;/h3&gt;

&lt;p&gt;I've been using Claude by voice more and more lately — it's just faster than typing when I'm thinking out loud. Last week it hit me: every agent I've actually built myself only takes text. No voice, nothing. So I went looking and found ElevenLabs. Their voice models are top-notch, and on top of that, they let you build full conversational agents, not just turn speech into text.&lt;/p&gt;

&lt;p&gt;That was enough to make me want to actually build something instead of just reading the docs.&lt;br&gt;
I paired it with Cloudflare, because I believe it's the fastest and easiest way to test a backend.&lt;/p&gt;

&lt;p&gt;There's one problem that shows up in almost every voice-agent demo: you give the agent a tool that's actually useful, and now it's also a tool that can hurt you.&lt;br&gt;
"Look up my order" is safe. "Refund my order" sounds almost the same but is a completely different kind of risk — if you wire both the same way because they feel similar, you've built a bot that can move money on a hallucination.&lt;/p&gt;

&lt;p&gt;I like to surf, so I wanted this demo to be as meaningful as possible.&lt;br&gt;
So here's what I built: a voice concierge for a fake surf shop.&lt;br&gt;
It can check order status, answer questions about products, and open a refund case — but it can't actually finish a refund itself.&lt;br&gt;
The stack is ElevenLabs Conversational AI + Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Quick disclaimer: this uses real public product names from a real German surfboard brand, WAU Eco Surfboards, purely for demo realism. It is not affiliated with, endorsed by, or operated by them.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Two pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The storefront + backend API&lt;/strong&gt; — one Cloudflare Worker (Vite + TypeScript static assets, plus a D1 database) serving the storefront and embedded widget, and exposing the actual order-lookup and refund-case endpoints Surfie's tools call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent&lt;/strong&gt; — an ElevenLabs Conversational AI agent ("Surfie") with a system prompt, a knowledge base, and two webhook tools.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   visitor (browser load)                   caller (voice)
             |                                    |
             |                                    v
             |                        +-------------------------+
             |                        |    ElevenLabs Agent     |
             |                        |        "Surfie"         |
             |                        | prompt + workflow graph |
             |                        |    + knowledge base     |
             |                        +-------------------------+
             |                                     |
             |                              tool calls over HTTPS
             |                        +-------------+--------------+
             |                        |                            |
             |                        v                            v
             |               GET /orders/{id}            POST /initiate-refund
             |                 (no auth)                   (Bearer token)
             |                        |                            |
             |                        +-------------+--------------+
             |                                      |
             v                                      v
   +------------------------------------------------------------------------+
   |                 Cloudflare Worker (site.demolabs.fyi)                  |
   |                                                                        |
   |  +------------------------+      +----------------------------+      |
   |  |       index.html       |      |          index.ts          |      |
   |  |  (storefront, embeds   |      |         (Hono API)         |      |
   |  |      the widget)       |      |                            |      |
   |  +------------------------+      +----------------------------+      |
   +------------------------------------------------------------------------+
                                                     |
                                                     v
                                        +-------------------------+
                                        |     D1: wau-refunds     |
                                        |     (refund cases)      |
                                        +-------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Setting up Cloudflare
&lt;/h2&gt;

&lt;p&gt;I did all of this on Cloudflare's &lt;strong&gt;Free plan&lt;/strong&gt; — Workers, D1, and a custom domain all fit inside the free tier, which makes it a genuinely cheap way to test out an architecture like this before committing to anything.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Onboard your domain.&lt;/strong&gt; If your domain isn't already on Cloudflare, you need to add it as a zone first — see Cloudflare's &lt;a href="https://developers.cloudflare.com/fundamentals/manage-domains/add-site/" rel="noopener noreferrer"&gt;Onboard a domain&lt;/a&gt; guide. I bought mine directly through Cloudflare, so it was already set up as a zone in my account with nothing extra to configure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create the Worker.&lt;/strong&gt; I set mine up by hand the first time, before I had a repo to point at. Since then I've published the code, so you don't have to: Cloudflare's &lt;a href="https://developers.cloudflare.com/workers/platform/deploy-buttons/" rel="noopener noreferrer"&gt;Deploy to Cloudflare button&lt;/a&gt; below clones the repo into your own GitHub account, provisions whatever Cloudflare resources it needs (the D1 database, in this case), and wires up CI/CD so every push after that auto-builds and deploys — no manual Worker setup required.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://deploy.workers.cloudflare.com/?url=https://github.com/palermo-777/elevenlabs-agent-cf" rel="noopener noreferrer"&gt;→ Deploy to Cloudflare&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Point it at your domain.&lt;/strong&gt; Once it's deployed, go to the Worker's &lt;strong&gt;Settings → Domains &amp;amp; Routes → Add → Custom Domain&lt;/strong&gt; and enter your domain (or subdomain). Cloudflare creates the DNS record for you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ro0ghuolujaaghkmp30.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ro0ghuolujaaghkmp30.png" alt="Cloudflare Worker Custom Domain Configuration" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add your secret.&lt;/strong&gt; Under &lt;strong&gt;Settings → Variables and Secrets → Add&lt;/strong&gt;, set the type to &lt;code&gt;Secret&lt;/code&gt;, name it (I used &lt;code&gt;API_TOKEN&lt;/code&gt;), and paste in a token you generate yourself. This is the same value you'll paste into ElevenLabs later as a workspace secret — more on that in Wiring the tools, below.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fte3313tq5k029x3139xt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fte3313tq5k029x3139xt.png" alt="Setting Cloudflare Worker Secret" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The app itself
&lt;/h3&gt;

&lt;p&gt;The whole thing is one Worker, built with Vite + vanilla TypeScript — no framework. &lt;code&gt;index.html&lt;/code&gt; is the frontend: a static single-page site with the board grid and the embedded ElevenLabs widget. &lt;code&gt;index.ts&lt;/code&gt; is the backend: a small &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; app defining the two API endpoints Surfie's tools actually call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwuod7kkmeidraj71tpko.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwuod7kkmeidraj71tpko.png" alt="Worker Folder" width="477" height="695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the part I like most about building on Cloudflare: one Worker serves both the marketing site and the API behind it. No separate backend deployment, no CORS dance between two origins for your own frontend — just one deploy for the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wrangler.jsonc&lt;/code&gt; is where the bindings live: the D1 database that stores initiated refunds, plus the secret &lt;code&gt;initiate-refund&lt;/code&gt; checks against (the secret itself never appears here — that's the point of a Secret binding, it only exists in the dashboard or via &lt;code&gt;wrangler secret put&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&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;"d1_databases"&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;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DB_REFUNDS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"database_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;"wau-refunds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"database_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;"&amp;lt;your-d1-database-id&amp;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;And the two endpoints themselves:&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;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/orders/:orderId&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;c&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&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="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orderId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ORDERS&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="c1"&gt;// a hardcoded lookup table for the demo&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;order&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;c&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="na"&gt;order_id&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="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="s1"&gt;not_found&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&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="na"&gt;order_id&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="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/initiate-refund&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;bearerAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;verifyToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&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;token&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;c&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;API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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="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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;orderId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;order_id&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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="nx"&gt;c&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="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="s1"&gt;order_id is required&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;caseId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`REF-&lt;/span&gt;&lt;span class="p"&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;span class="nf"&gt;slice&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&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;DB_REFUNDS&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="s1"&gt;INSERT INTO refunds (case_id, order_id, reason) VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="dl"&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;caseId&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;reason&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="nf"&gt;run&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;c&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="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="s1"&gt;refund_initiated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;caseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;order_id&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Testing the endpoints directly
&lt;/h3&gt;

&lt;p&gt;Before wiring either one into ElevenLabs, it's worth hitting them with &lt;code&gt;curl&lt;/code&gt;. A real order comes back with shipping details:&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://site.demolabs.fyi/orders/WAU-77102
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"order_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;"WAU-77102"&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;"shipped"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"carrier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DPD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tracking_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"01778812340099887766"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"estimated_delivery"&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-09-09"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&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="s2"&gt;"Riverboard 5'6&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; (B-Ware, Bav Boards)"&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;An order ID that doesn't exist doesn't error out, it just says so:&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://site.demolabs.fyi/orders/WAU-123456
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"order_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;"WAU-123456"&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;"not_found"&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;And a refund, with the right bearer token, opens a real case in D1:&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://site.demolabs.fyi/initiate-refund &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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;token&amp;gt;"&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;'{"order_id":"WAU-77102"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"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;"refund_initiated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"case_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;"REF-B18C8D7F"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"order_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;"WAU-77102"&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;
  
  
  The storefront itself
&lt;/h2&gt;

&lt;p&gt;No React, no Vue, nothing componentized — a widget embed and a couple of &lt;code&gt;fetch()&lt;/code&gt; calls don't need a framework's state management. The board grid is generated from two arrays of real product names (river/wave-pool boards, ocean boards) mapped over a small color palette, since the point was authentic content without scraping real product photography:&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;RIVER_BOARDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GHOUL&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="s1"&gt;THRIFTSHOP&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="s1"&gt;THE C&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="s1"&gt;WASP&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="s1"&gt;TOMBSTONE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&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;OCEAN_BOARDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MAORI&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="s1"&gt;LONGBOARD&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="s1"&gt;PARTY FISH&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="s1"&gt;SOUL GLIDER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderBoardGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerId&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;names&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerId&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;container&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="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
      &amp;lt;article class="board-card"&amp;gt;
        &amp;lt;div class="board-shape" style="--board-color: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PALETTE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;PALETTE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;h4&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h4&amp;gt;
      &amp;lt;/article&amp;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;join&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Building the agent
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In the ElevenLabs dashboard, open &lt;strong&gt;Agents&lt;/strong&gt; in the sidebar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiznr2lim57unes5mqbwi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiznr2lim57unes5mqbwi.png" alt="ElevenLabs agents dashboard" width="800" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;+&lt;/strong&gt; next to Agents to start a new one. This opens a "New agent" dialog — don't pick one of the sample cards (Personal Assistant, Business Agent) or &lt;strong&gt;Blank Agent&lt;/strong&gt;; click &lt;strong&gt;Browse Templates&lt;/strong&gt; at the top instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnx41pu006gojcuvb7gpq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnx41pu006gojcuvb7gpq.png" alt="New agent dialog with Browse Templates option" width="799" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Search for "Track" (or browse) and pick &lt;strong&gt;Order Status &amp;amp; Tracking&lt;/strong&gt; — it handles order inquiries, tracking, delivery estimates, and basic returns out of the box, with two integrations already wired in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomgm0e7rpoh3wqdhh6zc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomgm0e7rpoh3wqdhh6zc.png" alt="Browse templates page with Order Status &amp;amp; Tracking result" width="799" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Under &lt;strong&gt;Knowledge Base&lt;/strong&gt; in the sidebar, upload one markdown file per topic — about us, shipping &amp;amp; returns, products &amp;amp; pricing, ding repair, shop locations, and the "Test Me Baby" board-testing program — via &lt;strong&gt;Add Files&lt;/strong&gt;. This is what the non-tool capabilities (company story, ding repair, shop locations, board testing, products and pricing) actually answer from.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17vfoj7uvzbmahwhcrt8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17vfoj7uvzbmahwhcrt8.png" alt="ElevenLabs Knowledge Base file list" width="800" height="712"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I started from that template and then rewrote the system prompt from scratch, because the template locks you out of editing it directly — you have to fork it into a blank agent first if you want real control.&lt;br&gt;
System Prompt can be change in &lt;strong&gt;Agent &amp;gt; System Prompt&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6muiqzntqlem7c1j6ct.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6muiqzntqlem7c1j6ct.png" alt="Agent System Prompt" width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I followed ElevenLabs' recommended shape for the prompt — Personality, Environment, Tone, Goal, Flow, Guardrails, and a dedicated section for when to end the call — instead of one long block of instructions. The Goal section keeps Surfie to two things: order status lookups via a tool, and shipping/returns/B-Ware questions answered from the knowledge base. The two guardrail-shaped sections do a lot of the actual work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Guardrails&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never invent an order status, tracking number, delivery date, or stock availability. Only state what the tool or knowledge base actually gives you.
&lt;span class="p"&gt;-&lt;/span&gt; Never promise a refund, exchange, or exception to the return policy — that needs a human.
&lt;span class="p"&gt;-&lt;/span&gt; Don't give board-size or skill-level recommendations as if they were firm advice; general orientation only ("that's listed as a beginner/intermediate board"), and point anyone who wants a real fitting to a human.
&lt;span class="p"&gt;-&lt;/span&gt; If a board arrived damaged, or there's any freight claim or dispute, hand off to a person immediately rather than trying to resolve it.
&lt;span class="p"&gt;-&lt;/span&gt; Keep it phone-call length, every turn.

&lt;span class="gh"&gt;# When to end the call&lt;/span&gt;
ALWAYS call the end_call tool (don't just say goodbye verbally) when:
&lt;span class="p"&gt;-&lt;/span&gt; The caller says goodbye in any form ('thanks bye', 'I'm good', 'all set', 'no that's it')
&lt;span class="p"&gt;-&lt;/span&gt; The caller explicitly asks to end the call
&lt;span class="p"&gt;-&lt;/span&gt; Their question has been fully answered and they confirm they don't need anything else

Briefly acknowledge AND then call end_call. Verbal goodbye alone leaves the call open.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second bullet is the one that matters most. It's not enough to build the refund tool correctly on the backend — the model also has to &lt;em&gt;talk about it&lt;/em&gt; correctly, or a technically-safe system can still make a customer think a human doesn't need to look at anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The conversation is a graph, not just a prompt
&lt;/h2&gt;

&lt;p&gt;The base prompt above isn't the whole story — underneath it, Surfie is wired as a small workflow. In the agent's &lt;strong&gt;Workflow&lt;/strong&gt; tab I laid out six nodes, each able to layer its own instructions on top of the base prompt for just that step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faggwg9n42ig4xoy8mya3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faggwg9n42ig4xoy8mya3.png" alt="Agent Workflow" width="800" height="932"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greeting&lt;/strong&gt; — opens with the fixed first message, figures out whether this is an order question or a product question, and branches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assess&lt;/strong&gt; — looks up the order (or answers a product question straight from the knowledge base) and branches again: order status, warranty claim, or done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process Return&lt;/strong&gt; / &lt;strong&gt;Process Warranty&lt;/strong&gt; — the two branches. Process Warranty is the one that can actually call &lt;code&gt;initiate-refund&lt;/code&gt;, and only after re-confirming the order exists and was delivered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm&lt;/strong&gt; — reads back what happened and any reference numbers, then moves to End.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End&lt;/strong&gt; — hangs up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each node's &lt;code&gt;additional_prompt&lt;/code&gt; adds to the base prompt rather than replacing it. Greeting's, for example, is just:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Open warmly: 'Hi, this is Surfie... I can help you check on an order status or consult about our Surfboards, what is your order number or do you have a question about our Surfboards?' Capture identification. Identify intent: order number, or a question about products. Branch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A node isn't limited to telling the model how to talk, though — it can also tell it which tool to reach for. Process Return, for instance, wires in &lt;code&gt;order_status&lt;/code&gt; directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Capture: order number (format WAU-XXXXX). Never guess one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Call order_status with exactly that order number. Don't call it before you have a number.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;IMPORTANT: If the customer signals they want to end the call, briefly acknowledge AND call end_call.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8s9gvgpf5zj47rd1y4g7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8s9gvgpf5zj47rd1y4g7.png" alt="Order Status Node" width="800" height="697"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the part that makes this more than a flowchart drawn on top of one prompt — each step pulls in exactly the tool and instructions it needs, instead of the base prompt having to anticipate every tool call up front.&lt;/p&gt;

&lt;p&gt;It's easy to assume a voice agent is "one prompt plus some tools." In practice, routing different parts of the conversation through different prompt slices means the warranty-specific caution (verify the order was actually delivered before offering a refund case) only shows up when it's relevant, instead of cluttering every turn of a plain order-status lookup. I ran every node on Gemini 3.5 Flash and it held up fine across all six — no need to reach for a bigger model just because the conversation now had more moving parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring the tools: read vs. write get treated differently
&lt;/h2&gt;

&lt;p&gt;Surfie has two webhook tools, and they are not configured the same way on purpose. To add either one, go to &lt;strong&gt;Tools&lt;/strong&gt; in the sidebar and click &lt;strong&gt;Add tool&lt;/strong&gt;:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cfk5u7ouhawygiac9xs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cfk5u7ouhawygiac9xs.png" alt="ElevenLabs Tools" width="799" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;order_status&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;GET /orders/{order_id}&lt;/code&gt;, no auth, open CORS. It's read-only and returns nothing sensitive beyond a shipping status, so there's no reason to gate it. Set &lt;strong&gt;Method&lt;/strong&gt; to &lt;code&gt;GET&lt;/code&gt;, the &lt;strong&gt;URL&lt;/strong&gt; to &lt;code&gt;https://site.demolabs.fyi/orders/{order_id}&lt;/code&gt;, and leave &lt;strong&gt;Authentication&lt;/strong&gt; as &lt;code&gt;None&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahtilc1hax93zl0duglp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahtilc1hax93zl0duglp.png" alt="order_status tool configuration" width="799" height="643"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;{order_id}&lt;/code&gt; is a path parameter, ElevenLabs asks how the LLM should fill it in. Set &lt;strong&gt;Value Type&lt;/strong&gt; to &lt;code&gt;LLM Prompt&lt;/code&gt; and describe the format in plain language — "this is order id in format WAU-XXXXX" — so the model extracts it from the conversation instead of guessing at the shape:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5qn3lntwlmlbrzvlkiti.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5qn3lntwlmlbrzvlkiti.png" alt="Path parameter config for order_status tool" width="799" height="643"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can test the tool right from this screen before ever wiring it into the agent — see Setting up Cloudflare above for what a real and a not-found order actually return. This also matters for the prompt: its Flow section tells Surfie to handle a &lt;code&gt;not_found&lt;/code&gt; status by asking the customer to double-check the number, not by panicking.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcoe3znqe09otzrbf078f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcoe3znqe09otzrbf078f.png" alt="Test Tool panel showing a successful order_status call" width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;initiate-refund&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;POST /initiate-refund&lt;/code&gt;, requires &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. This is the one that opens a support case in a D1 table. Same &lt;strong&gt;Add tool&lt;/strong&gt; flow, but &lt;strong&gt;Method&lt;/strong&gt; is &lt;code&gt;POST&lt;/code&gt; and, critically, &lt;strong&gt;Authentication&lt;/strong&gt; is not &lt;code&gt;None&lt;/code&gt; — it's set to a named auth connection (&lt;code&gt;Token_API_WRITE&lt;/code&gt;) rather than a raw string, so the token itself never sits in a visible field on the tool config:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rs7pw4js10qhjnhkwvr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rs7pw4js10qhjnhkwvr.png" alt="initiate-refund tool configuration: POST, URL, Authentication Token_API_WRITE" width="800" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;order_id&lt;/code&gt; here is a body parameter instead of a path parameter, but the same idea applies: mark it &lt;code&gt;Required&lt;/code&gt;, set &lt;strong&gt;Value Type&lt;/strong&gt; to &lt;code&gt;LLM Prompt&lt;/code&gt;, and spell out the format so the model doesn't invent one:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhs23bvefjcwlwyettq05.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhs23bvefjcwlwyettq05.png" alt="Path parameter configuration for order_id on initiate-refund" width="800" height="994"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A successful test creates a real case in D1 and hands back a case ID — see Setting up Cloudflare above for the exact response:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4unnb15jnhmwmap6gao.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4unnb15jnhmwmap6gao.png" alt="Test Tool panel showing a successful initiate-refund call" width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ElevenLabs lets you store that bearer token as a workspace secret and reference it by connection name in the tool config, so the raw value never sits in a visible field — the tool auth setting points at a named connection, not a string.&lt;/p&gt;

&lt;p&gt;On the Worker side — shown in full back in Setting up Cloudflare — the check is &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono&lt;/a&gt;'s &lt;code&gt;bearerAuth&lt;/code&gt; middleware, gated on &lt;code&gt;env.API_TOKEN&lt;/code&gt;. That's a Cloudflare &lt;strong&gt;Secret&lt;/strong&gt;, not a plain variable — set once via the dashboard (or &lt;code&gt;wrangler secret put&lt;/code&gt;), never checked into the repo, never shown again after you set it. The same string gets pasted into ElevenLabs' side as a workspace secret. Two independent places holding the same shared value, neither of which is source control.&lt;/p&gt;

&lt;p&gt;A rejected call gets a &lt;code&gt;401&lt;/code&gt; with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header, which is a small thing but worth doing — it's the difference between a tool config that fails obviously during setup and one that fails silently in front of a customer.&lt;/p&gt;

&lt;p&gt;The widget itself is two lines, dropped straight into the page body (change code in index.html of Cloudflare Worker to your agent-id):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;elevenlabs-convai&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"surfie-widget"&lt;/span&gt; &lt;span class="na"&gt;agent-id=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;&amp;lt;insert-your-agent-id-here&amp;gt;&amp;gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/elevenlabs-convai&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/@elevenlabs/convai-widget-embed"&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Note on Security
&lt;/h3&gt;

&lt;p&gt;One gotcha worth flagging if you're trying this yourself: the widget needs a secure context for microphone access, so a plain &lt;code&gt;file://&lt;/code&gt; preview won't let voice input work. Test it via &lt;code&gt;wrangler dev&lt;/code&gt; (localhost counts) or a real deploy — not by double-clicking the HTML file.&lt;/p&gt;

&lt;p&gt;That embed snippet also exposes the agent's &lt;code&gt;agent-id&lt;/code&gt; in plain text — anyone who copies those two lines onto their own page gets a fully working Surfie conversation, on my ElevenLabs usage. The fix is in the agent's &lt;strong&gt;Security&lt;/strong&gt; tab: an &lt;strong&gt;Allowlist&lt;/strong&gt; of hosts permitted to connect, plus a &lt;strong&gt;Fail when Origin header is missing&lt;/strong&gt; toggle so a request with no &lt;code&gt;Origin&lt;/code&gt; header at all doesn't sneak through. I added &lt;code&gt;site.demolabs.fyi&lt;/code&gt; and turned that toggle on:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ov8xnua3kp89n4l607g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ov8xnua3kp89n4l607g.png" alt="ElevenLabs Security tab: Allowlist with site.demolabs.fyi and Fail when Origin header is missing enabled" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's not the full list of widget-security settings worth knowing — &lt;a href="https://www.mindstudio.ai/blog/elevenlabs-voice-widget-security-settings-before-going-public" rel="noopener noreferrer"&gt;this MindStudio writeup&lt;/a&gt; covers the rest in more depth than I will here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;p&gt;The natural next step is an MCP tool instead of a plain webhook, so Surfie could reach a broader set of backend actions through one connection rather than a hand-registered tool per endpoint — with the tradeoff that MCP tools need to be internet-reachable and currently don't mix with zero-retention/HIPAA workspace modes, which matters more once this stops being a demo. After that: a Twilio number so Surfie takes real phone calls instead of only the web widget, and a &lt;code&gt;GET /refund-status/{case_id}&lt;/code&gt; tool so a customer can check on a case they already opened instead of only creating new ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Live at &lt;code&gt;site.demolabs.fyi&lt;/code&gt;, clearly marked as an unofficial demo. Test order IDs if you want to see Surfie in action: &lt;code&gt;WAU-10234&lt;/code&gt;, &lt;code&gt;WAU-88213&lt;/code&gt;, &lt;code&gt;WAU-55501&lt;/code&gt;, &lt;code&gt;WAU-77102&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I built this evaluating ElevenLabs' agent platform properly rather than just reading the docs — the interesting decisions showed up in the tool config, the auth boundary, and how the conversation gets routed, not in the system prompt copy itself.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>elevenlabs</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Paying Claude Prices for "Hi" — Route LLM Prompts to the Right Model in 50 Lines</title>
      <dc:creator>Alexey Sazhin</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:13:37 +0000</pubDate>
      <link>https://dev.to/palermo-777/stop-paying-claude-prices-for-hi-route-llm-prompts-to-the-right-model-in-50-lines-3el0</link>
      <guid>https://dev.to/palermo-777/stop-paying-claude-prices-for-hi-route-llm-prompts-to-the-right-model-in-50-lines-3el0</guid>
      <description>&lt;p&gt;Every AI app hits the same fork in the road: pick an expensive frontier model and burn budget on trivial prompts, or pick a cheap open model and ship mediocre answers on hard ones.&lt;/p&gt;

&lt;p&gt;You shouldn't have to pick. Not every prompt deserves Claude Sonnet 4.6. "What's the capital of Portugal?" and "Refactor this 400-line React component to use Suspense" are not the same job — and they shouldn't cost the same.&lt;/p&gt;

&lt;p&gt;In this tutorial we'll build a &lt;strong&gt;prompt classifier that routes each incoming request to the right model automatically&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coding questions&lt;/strong&gt; → Claude Sonnet 4.6 (best-in-class for code)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else&lt;/strong&gt; → a cheap, fast open model on Workers AI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is ~50 lines of TypeScript running on Cloudflare Workers, and the routing logic lives in AI Gateway — no vendor lock-in, unified logs, one endpoint for your app.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4efxhdum7tmsr5ez3mdq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4efxhdum7tmsr5ez3mdq.png" alt=" " width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A Worker&lt;/strong&gt; intercepts each OpenAI-compatible chat request&lt;/li&gt;
&lt;li&gt;It uses &lt;strong&gt;Workers AI&lt;/strong&gt; (Llama 4 Scout, free tier) to classify the prompt as &lt;code&gt;coding&lt;/code&gt; or &lt;code&gt;simple&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It attaches that tag as a &lt;code&gt;cf-aig-metadata&lt;/code&gt; header, then forwards to &lt;strong&gt;AI Gateway&lt;/strong&gt;, which reads the tag and routes to the correct upstream model&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your client only ever sees one endpoint. AI Gateway gives you unified logs, caching, and rate-limiting across both providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Cloudflare account (free tier is fine)&lt;/li&gt;
&lt;li&gt;An Anthropic API key (&lt;a href="https://console.anthropic.com/" rel="noopener noreferrer"&gt;sign up here&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Node.js 20+ and &lt;a href="https://developers.cloudflare.com/workers/wrangler/" rel="noopener noreferrer"&gt;Wrangler&lt;/a&gt; installed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Create an AI Gateway
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In the Cloudflare Dashboard → &lt;strong&gt;AI&lt;/strong&gt; → &lt;strong&gt;AI Gateway&lt;/strong&gt; → &lt;strong&gt;Create Gateway&lt;/strong&gt;. Give it a name (e.g. &lt;code&gt;smart-router&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Make sure Authenticated Gateway option is enabled;&lt;/li&gt;
&lt;li&gt;AI Gateway created with Authenticated Gateway enabled;&lt;/li&gt;
&lt;li&gt;Copy the API token shown on the next screen — Cloudflare won't show it again. You will need this token to set the secret &lt;strong&gt;AI_GATEWAY_TOKEN&lt;/strong&gt; on a later step.&lt;/li&gt;
&lt;li&gt;Then add your Anthropic API key:&lt;/li&gt;
&lt;li&gt;Open your gateway → &lt;strong&gt;Provider Keys&lt;/strong&gt; tab&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add&lt;/strong&gt; → select Anthropic → paste your key&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fse6z2bvgy19imbea9c9b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fse6z2bvgy19imbea9c9b.png" alt=" " width="800" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AI Gateway will now handle authentication to Anthropic on your behalf — you won't need to expose that key from your Worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Configure the Dynamic Route
&lt;/h2&gt;

&lt;p&gt;Still in your gateway, go to &lt;strong&gt;Dynamic Routes&lt;/strong&gt; → &lt;strong&gt;Add Route&lt;/strong&gt; → &lt;strong&gt;Start from scratch&lt;/strong&gt;. Name it &lt;code&gt;route1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Build this logic in the visual editor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; &lt;code&gt;metadata.task == "coding"&lt;/code&gt; → route to &lt;strong&gt;Anthropic&lt;/strong&gt; → &lt;code&gt;claude-sonnet-4-6&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Else&lt;/strong&gt; → route to &lt;strong&gt;Workers AI&lt;/strong&gt; → &lt;code&gt;@cf/moonshotai/kimi-k2.7-code&lt;/code&gt; (or any other cheap Workers AI model)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Save the route. AI Gateway now knows how to fork traffic based on a &lt;code&gt;metadata.task&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fezikvak26hqx8vz7gnml.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fezikvak26hqx8vz7gnml.png" alt=" " width="799" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Build the classifier Worker
&lt;/h2&gt;

&lt;p&gt;Scaffold a new Worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create cloudflare@latest &lt;span class="nt"&gt;--&lt;/span&gt; prompt-router &lt;span class="nt"&gt;--type&lt;/span&gt; hello-world &lt;span class="nt"&gt;--ts&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;prompt-router
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Navigate into the new &lt;strong&gt;prompt-router/src&lt;/strong&gt; folder and replace &lt;strong&gt;src/index.ts&lt;/strong&gt; with:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;AI&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="nl"&gt;GATEWAY_ACCOUNT_ID&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;GATEWAY_NAME&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&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="na"&gt;env&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&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;gatewayUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://gateway.ai.cloudflare.com/v1/&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;GATEWAY_ACCOUNT_ID&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GATEWAY_NAME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/compat/chat/completions`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&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="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;body&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;request&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="k"&gt;catch&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prompt-router is running&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="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;body&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="nx"&gt;length&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prompt-router is running&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;// Extract the latest user message&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&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;at&lt;/span&gt;&lt;span class="p"&gt;(&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;lastMessage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;lastMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lastMessage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="na"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Classify with a small, cheap model&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;classification&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;env&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="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@cf/meta/llama-4-scout-17b-16e-instruct&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;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;Classify the user prompt into exactly one word: 'coding' or 'simple'. Reply with only that single word, nothing else.&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="nx"&gt;prompt&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;coding&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;coding&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;simple&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Forward to AI Gateway with the classification as metadata&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&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;Headers&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;headers&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cf-aig-metadata&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="nx"&gt;task&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gatewayUrl&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="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;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;body&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;Update wrangler.jsonc.&lt;br&gt;
1) Replace &lt;strong&gt;{GATEWAY_ACCOUNT_ID}&lt;/strong&gt; by your Cloudflare Account ID: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the Cloudflare dashboard, go to the &lt;strong&gt;Account&lt;/strong&gt; list.&lt;/li&gt;
&lt;li&gt;Select the menu button at the end of the account row.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Copy account ID&lt;/strong&gt;.
&lt;img width="354" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fep7keijvzwzs1c0sohr0.png" alt="Image description" height="476"&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;2) Update &lt;strong&gt;{GATEWAY_NAME}&lt;/strong&gt; with your values (find them in &lt;strong&gt;AI &amp;gt; AI Gateway &amp;gt; Settings&lt;/strong&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;"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;"prompt-router"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&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/index.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_flags"&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="s2"&gt;"nodejs_compat"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ai"&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;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AI"&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;"vars"&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;"GATEWAY_ACCOUNT_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;"your-account-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"GATEWAY_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;"smart-router"&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;Store the AI Gateway API token (saved on the creation of AI Gateway) as a Worker secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wrangler secret put AI_GATEWAY_TOKEN
&lt;span class="c"&gt;# paste the token when prompted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the worker:&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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4: Test it&lt;/p&gt;

&lt;p&gt;Fire two prompts at your Worker with curl:&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;# Simple prompt — should route to Workers AI&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://prompt-router.&amp;lt;your-subdomain&amp;gt;.workers.dev &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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;your-ai-gateway-token&amp;gt;"&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": "dynamic/route1",
    "messages": [{"role": "user", "content": "What is the capital of Portugal?"}]
  }'&lt;/span&gt;

&lt;span class="c"&gt;# Coding prompt — should route to Claude&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://prompt-router.&amp;lt;your-subdomain&amp;gt;.workers.dev &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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;your-ai-gateway-token&amp;gt;"&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": "dynamic/route1",
    "messages": [{"role": "user", "content": "Write a Python function that reverses a linked list in place."}]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Go to &lt;strong&gt;Cloudflare Dashboard &amp;gt; Compute &amp;gt; Workers &amp;amp; Pages prompt-router&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Check the &lt;strong&gt;Observability / Logs&lt;/strong&gt; tab. You should see it successfully extract the prompt and log: Task: coding.&lt;/li&gt;
&lt;li&gt;Go to AI &amp;gt; AI Gateway &amp;gt; Gateways &amp;gt; Your Gateway.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;Logs&lt;/strong&gt; tab and make sure that &lt;strong&gt;claude-sonnet-4-6&lt;/strong&gt; model (slower, expensive, higher quality) is used for coding tasks and &lt;strong&gt;Kimi-K2&lt;/strong&gt; (fast, cheap) for the rest. Cost delta is visible in the logs.
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6drb18r5oacw4h5mur8v.png" alt=" " width="800" height="182"&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Step 5: Point your existing app at it&lt;br&gt;
Anything that speaks the OpenAI Chat Completions API will work. For example, in Open WebUI or LibreChat:&lt;br&gt;
Base URL: &lt;a href="https://prompt-router" rel="noopener noreferrer"&gt;https://prompt-router&lt;/a&gt;..workers.dev&lt;br&gt;
Model: dynamic/route1&lt;br&gt;
API Key: your AI Gateway token&lt;/p&gt;

&lt;p&gt;In Continue.dev or Cursor, use the same base URL and model name. From the client's perspective it's a single OpenAI-compatible model — the routing is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this actually save money?
&lt;/h2&gt;

&lt;p&gt;Very rough back-of-envelope, based on current public prices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input $/1M tokens&lt;/th&gt;
&lt;th&gt;Output $/1M token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.6&lt;/td&gt;
&lt;td&gt;$3.0&lt;/td&gt;
&lt;td&gt;$15.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workers AI (Kimi-K2)&lt;/td&gt;
&lt;td&gt;$0.95&lt;/td&gt;
&lt;td&gt;$4.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If 70% of a typical chat app's prompts are "simple" (conversation, factual questions, summaries) and 30% are "coding" or complex reasoning, routing them separately cuts total LLM cost by roughly 60-70% versus sending everything to Claude — while keeping Claude on the workloads where it earns its price.&lt;/p&gt;

&lt;p&gt;Your mileage will vary. Instrument the classifier's output and measure your own traffic mix before quoting numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;A few natural extensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More categories. Add reasoning, creative, summarization, translation — each routed to a specialist model.&lt;/li&gt;
&lt;li&gt;Confidence-based fallback. Ask the classifier for a confidence score, and only route to the cheap model if confidence is high; otherwise default to the strong one.&lt;/li&gt;
&lt;li&gt;Per-user or per-tier routing. Free-tier users always hit the cheap model; paid users get the frontier one. Read a header or JWT claim and inject it into cf-aig-metadata alongside task.&lt;/li&gt;
&lt;li&gt;Evals-based routing. Score responses from both models offline, then bias the router toward whichever one wins for a given category.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The interesting bit isn't the classifier — it's the pattern. Once you have a Worker sitting between your app and AI Gateway, injecting metadata, you can express any routing policy you want in the Gateway's visual editor without touching code again.&lt;/li&gt;
&lt;li&gt;Prompt-based routing is the simplest useful policy. Once you've shipped it, cost-aware, latency-aware, and quality-aware routing are all one Dynamic Route edit away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full code and setup notes: &lt;a href="https://github.com/palermo-777/prompt-router" rel="noopener noreferrer"&gt;GitHub Repo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build something with this, drop a comment — especially if you find a smarter classifier prompt than mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cloudflare</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
