<?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: DOS AI</title>
    <description>The latest articles on DEV Community by DOS AI (@dosai).</description>
    <link>https://dev.to/dosai</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%2F4079668%2Ff63ce775-05f8-4a6a-a898-831cf95bee89.png</url>
      <title>DEV Community: DOS AI</title>
      <link>https://dev.to/dosai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dosai"/>
    <language>en</language>
    <item>
      <title>We pinned our own domain to 127.0.0.1 and our SSRF guard ate two days of voice notes</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Wed, 14 Oct 2026 21:45:12 +0000</pubDate>
      <link>https://dev.to/dosai/we-pinned-our-own-domain-to-127001-and-our-ssrf-guard-ate-two-days-of-voice-notes-2jd3</link>
      <guid>https://dev.to/dosai/we-pinned-our-own-domain-to-127001-and-our-ssrf-guard-ate-two-days-of-voice-notes-2jd3</guid>
      <description>&lt;p&gt;I build DOS AI (dosai.pro), an AI assistant that answers a small business's customers in WhatsApp, Instagram and Telegram and keeps the leads in a built-in CRM. Customers in Kazakhstan send a lot of voice notes, so the bot transcribes them before it answers. For two days in October it could not hear any of them, and the cause was one line in &lt;code&gt;/etc/hosts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;We had just moved the platform from a managed cloud to our own server. The hardening script that ran after the move added this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1 sb.dosai.pro dosai.pro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent was reasonable: the app calls its own storage and auth services hundreds of times a minute, and those calls should not depend on DNS being up. Pin the names to loopback, let the reverse proxy on the box route them by SNI, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;The WhatsApp gateway stores every inbound voice note, photo and document in our own object storage and hands the pipeline a link like &lt;code&gt;https://sb.dosai.pro/storage/v1/object/...&lt;/code&gt;. Before the pipeline downloads anything from a URL, an SSRF guard resolves the hostname and refuses private, loopback and link-local addresses. That guard exists because the bot also follows links customers paste into the chat, and "fetch whatever the user sent" is exactly how you end up reading your own metadata endpoint.&lt;/p&gt;

&lt;p&gt;After the hosts change, &lt;code&gt;sb.dosai.pro&lt;/code&gt; resolved to &lt;code&gt;127.0.0.1&lt;/code&gt; on the server. The guard did what it was written to do: it saw a loopback address and dropped the download. No transcript, no image description, no player in the inbox. The assistant answered "I could not hear the voice note, could you type it?" to every customer who spoke instead of typing.&lt;/p&gt;

&lt;p&gt;Over two days: 314 of 339 voice notes, 241 of 248 photos and 92 of 94 documents across 13 projects were lost this way. Nothing crashed. Nothing logged at error level, because a refused unsafe URL is a warning by design. The founder noticed it on his own ad leads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it was not obvious
&lt;/h2&gt;

&lt;p&gt;Three things lined up.&lt;/p&gt;

&lt;p&gt;The hosts entry was made by one person as part of infrastructure work. The SSRF guard was written months earlier by another, for a different threat. Neither change was wrong on its own; the combination was.&lt;/p&gt;

&lt;p&gt;The failure mode looked like a model problem. "The bot says it cannot hear voice notes" sends you to the transcription code and the prompt first, not to name resolution.&lt;/p&gt;

&lt;p&gt;And the guard was correct from its own point of view. A hostname that resolves to loopback is a textbook DNS-rebinding pattern. It had no way to know that this particular name was ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;We considered four options.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove the hosts entry. Simplest, but it brings back the DNS dependency the migration had just removed, and the next person hardening the box will add it again.&lt;/li&gt;
&lt;li&gt;Allowlist by suffix: anything ending in &lt;code&gt;.dosai.pro&lt;/code&gt; is fine. Ownership is not inherited by subdomains; a wildcard record or a forgotten CNAME would open the guard.&lt;/li&gt;
&lt;li&gt;Skip the IP check for storage URLs only. Fixes this caller, and leaves the same trap for the next internal caller that fetches from our own name.&lt;/li&gt;
&lt;li&gt;Teach the guard which public names are ours, by exact match, and ask that question after resolving and before the private-IP check.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We went with the fourth. The site host and the storage host come from the same environment variables the app already uses, so there is one source of truth and no second list to forget. The check is an exact hostname comparison, not a suffix. The connection is still pinned to the resolved address, so a request to our name goes to loopback as intended, and the proxy picks the certificate by SNI.&lt;/p&gt;

&lt;p&gt;A hostname we do not own that resolves to loopback is still refused. Look-alike names (&lt;code&gt;sb.dosai.pro.evil.com&lt;/code&gt;, &lt;code&gt;dosai.pro-something&lt;/code&gt;) are still refused.&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;isOwnPublicHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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;boolean&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hostname&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;APP_HOST&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;APP_HOST&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;true&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;isOwnSupabaseHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// exact match against the storage host from env&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the guard, after DNS resolution:&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="nf"&gt;isOwnPublicHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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="na"&gt;safe&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;resolvedIp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allIps&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="c1"&gt;// ...private / loopback / link-local checks follow for everyone else&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regression test mocks the resolver to return &lt;code&gt;127.0.0.1&lt;/code&gt; for our own host and asserts the URL is accepted, then does the same for a foreign host and asserts it is refused. On the old code two of the five cases fail, which is the point of the test: it had to go red before the fix, or it proves nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;A guard that judges by IP address needs to know your public names, because the answer to "where does our name resolve" is not a property of the name. It changes when you move hosting, add a hosts entry, put a proxy in front, or run in a container with its own resolver.&lt;/p&gt;

&lt;p&gt;Security checks that fail closed are correct and quiet at the same time. If a refused download is a warning, the warning needs a counter and the counter needs an alert, otherwise you learn about it from a customer.&lt;/p&gt;

&lt;p&gt;And when a bot starts saying "I could not hear that" to everyone at once, suspect the plumbing before the model. The model did not change that morning. The server did.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>node</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>Measuring whether AI search names your product: six ways the measurement lied to me</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 11 Oct 2026 23:34:01 +0000</pubDate>
      <link>https://dev.to/dosai/measuring-whether-ai-search-names-your-product-six-ways-the-measurement-lied-to-me-2akg</link>
      <guid>https://dev.to/dosai/measuring-whether-ai-search-names-your-product-six-ways-the-measurement-lied-to-me-2akg</guid>
      <description>&lt;p&gt;I build DOS AI (dosai.pro), an AI assistant that answers a small business's customers in WhatsApp, Instagram and Telegram and keeps the leads in a built-in CRM. Search consoles show where a page ranks. Nobody shows you what an AI assistant says when a buyer asks it for a recommendation, so I wrote a script for that.&lt;/p&gt;

&lt;p&gt;The script is under 300 lines. Getting it to tell the truth took longer than writing it. Here are the six places where it, or the things around it, gave me a wrong picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Thirty-five questions a buyer would type, in seven groups: choosing a service, a task to solve, price, agencies, our brand, Kazakh, English. Thirty-two of them do not contain our name. Each goes to a model with the web search tool switched on and an approximate location set to our city. For every answer the script records the text and the cited URLs, then counts where we appear and which sites the answer was built from.&lt;/p&gt;

&lt;p&gt;A run costs about a dollar. I have four runs now: one in mid-September, two on October 10 and one on October 12.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. An echo counted as a mention
&lt;/h2&gt;

&lt;p&gt;The first version counted an answer as "they named us" if the text contained our name. One of the brand questions is "What is DOS AI?". The model answered that it could be AI software for MS-DOS or a denial-of-service attack. Our name was in the text, because the model repeated the question. The script scored it as a hit.&lt;/p&gt;

&lt;p&gt;Two of three "hits" in that run were echoes. The rule now: for a question that contains our name, only a citation of our domain counts. The old ruler still prints as a second line, so earlier runs stay comparable.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A missing argument looked like a result
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--limit&lt;/code&gt; takes a number. Without one it became &lt;code&gt;NaN&lt;/code&gt;, &lt;code&gt;slice(0, NaN)&lt;/code&gt; returned nothing, and the run finished with "named in 0 of 0". At a glance that reads as "nobody names us". A bad flag is now a launch error with exit code 2, and a run with zero answers exits with 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. One run is not a number
&lt;/h2&gt;

&lt;p&gt;On October 10 the script ran twice, four minutes apart. Same 35 questions, different answers. In the first run the model answered the brand question from memory. In the second it searched and found a different company with the same name on another domain, then quoted that company's prices as ours.&lt;/p&gt;

&lt;p&gt;The score was the same both times, so the baseline holds. A difference of one answer between runs is noise, because the model decides for itself whether to search. I only treat a change as real when the unbranded count moves, or when three runs move the same way.&lt;/p&gt;

&lt;p&gt;A third to a half of the answers involve no search at all: between 11 and 17 of 35, depending on the run. For those questions, nothing you publish this month matters. The answer comes from training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Published is not the same as visible
&lt;/h2&gt;

&lt;p&gt;Between the last two runs we published eight articles on the site and several on outside platforms. The score did not move, and two days is too short for that. I checked each platform anyway, and the reasons turned out to be more specific than "wait".&lt;/p&gt;

&lt;p&gt;One platform serves every article of a free account with &lt;code&gt;x-robots-tag: noindex&lt;/code&gt;, and the links I checked there carry &lt;code&gt;nofollow&lt;/code&gt;. Those articles do not exist for a search engine. Another keeps a new author's first article in a moderation queue with no public URL. I had counted both as "published".&lt;/p&gt;

&lt;p&gt;The check is one request per article: read the response headers and the robots meta tag. I now do it before counting a publication as a publication.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The sitemap was not re-read
&lt;/h2&gt;

&lt;p&gt;Search Console has an API. It told me Google last fetched our sitemap six days before the run, which is before any of the new pages existed. Of eight new articles Google knew five, found through links. Resubmitting the sitemap is one &lt;code&gt;PUT&lt;/code&gt; request with a write scope on the same service account that reads the reports.&lt;/p&gt;

&lt;p&gt;I had assumed that a sitemap with fresh dates gets picked up within a day. The last-download date is in the API response, and it takes one call to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The profile list had a dead link
&lt;/h2&gt;

&lt;p&gt;Our home page carries a &lt;code&gt;sameAs&lt;/code&gt; list in its structured data: the profiles that are also us. It exists to tie the name to the domain, which matters when another company shares the name. One of those profiles returned 404. A comment in the code said every address had been checked by a live probe, with the date. The check was real. It ran once, two months ago.&lt;/p&gt;

&lt;p&gt;I wrote a checker that reads the list from the live page and fails on 404 or 410. Its first version reported five profiles, all alive except one. The page lists eleven. A dangling &lt;code&gt;else&lt;/code&gt; had attached itself to the inner &lt;code&gt;if&lt;/code&gt;, so the walker skipped most of the tree. I caught it only because I compared the count with the source.&lt;/p&gt;

&lt;p&gt;A check that prints "all fine" for five items out of eleven is worse than no check. Print the count, and compare it with a number you got some other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the measurement says now
&lt;/h2&gt;

&lt;p&gt;By domain, the assistant finds us and describes the product correctly. By name alone, it finds the namesake. For the 32 questions without our name it does not mention us, and its answers to "which services exist" are assembled from roundup posts on other vendors' blogs.&lt;/p&gt;

&lt;p&gt;So the problem sits outside our site. We have enough pages. What is missing is pages on other domains that say what DOS AI (dosai.pro) is. That is slower work than writing articles, and the script will show whether it pays off. The next run is in about two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do first next time
&lt;/h2&gt;

&lt;p&gt;Decide what counts as a hit before the first run, and test the counting rule on an answer where the model only repeats your question. Then check the things you believe are already true: that your publications are indexable, that the sitemap was fetched, that your own links open. Three of the six problems above were in that second group.&lt;/p&gt;

&lt;p&gt;The product these numbers are about: &lt;a href="https://dosai.pro" rel="noopener noreferrer"&gt;dosai.pro&lt;/a&gt;. The page written for AI assistants, with the short description of what it is and what it is not, is at &lt;a href="https://dosai.pro/llms.txt" rel="noopener noreferrer"&gt;dosai.pro/llms.txt&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>startup</category>
      <category>testing</category>
    </item>
    <item>
      <title>Instagram comment-to-DM automation: the limits you design around</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sat, 10 Oct 2026 19:04:07 +0000</pubDate>
      <link>https://dev.to/dosai/instagram-comment-to-dm-automation-the-limits-you-design-around-17k6</link>
      <guid>https://dev.to/dosai/instagram-comment-to-dm-automation-the-limits-you-design-around-17k6</guid>
      <description>&lt;p&gt;"Comment PRICE and I will send you the list" looks like the simplest automation there is. A keyword, a direct message, done. I build DOS AI (dosai.pro), where businesses set up AI assistants for WhatsApp, Instagram and Telegram, and this feature ended up shaped almost entirely by what Instagram will not let you do.&lt;/p&gt;

&lt;p&gt;Here are the limits and what we built around each.&lt;/p&gt;

&lt;h2&gt;
  
  
  You cannot write first, and a comment is the exception
&lt;/h2&gt;

&lt;p&gt;A business cannot start a conversation in Instagram. Not by hand through a tool, not with a bot. The person starts it, with a direct message or with a comment.&lt;/p&gt;

&lt;p&gt;So a comment is more valuable than it looks: it is permission to send one private reply. One. After that single message you are silent until the person answers.&lt;/p&gt;

&lt;p&gt;In our dialog list such a conversation is marked "waiting for a message", and the reply box is closed. An open box that fails on send reads as a bug.&lt;/p&gt;

&lt;p&gt;The practical rule for whoever writes the text: the first message must end with a question. "Here is our price list" ends the conversation. "Here are the prices, which day suits you?" starts one.&lt;/p&gt;

&lt;h2&gt;
  
  
  One private reply per comment, and it is short
&lt;/h2&gt;

&lt;p&gt;The private reply is a single message of up to 1000 bytes. In Russian or Kazakh that is about 500 letters, since Cyrillic takes two bytes per letter. The limit is in bytes, not characters, which is easy to miss if you only test in English.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public reply needs variants
&lt;/h2&gt;

&lt;p&gt;Answering under the comment as well is good practice, but the same text under a hundred comments looks like spam to Instagram and can get the account limited. We require at least two variants for a public reply and rotate them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hourly ceilings, and a queue instead of a drop
&lt;/h2&gt;

&lt;p&gt;We keep our own ceilings per project: 60 public replies and 200 direct messages an hour. When a post goes viral the rule keeps firing, and the replies above the ceiling wait.&lt;/p&gt;

&lt;p&gt;A reply that cannot go now is never dropped. It stays in the feed marked "will go later" and is sent when the ceiling frees up, for as long as Instagram still accepts a reply to that comment, which is up to 7 days. A second comment by the same person does not queue a second reply.&lt;/p&gt;

&lt;p&gt;The same queue covers the other reasons a send fails: a temporary error on Meta's side, or revoked access to the account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 24 hour window
&lt;/h2&gt;

&lt;p&gt;Once the person writes to you, you may answer for 24 hours after their last message. The rule is the same for a bot, for a human operator in our dashboard and for any other third-party tool. After that the only way to reply is the Instagram app itself.&lt;/p&gt;

&lt;p&gt;This is why Instagram is the one channel where our appointment reminders do not run: a reminder a day ahead cannot fit a 24 hour window that started at booking time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyword matching
&lt;/h2&gt;

&lt;p&gt;A keyword matches from the start of a word. A rule on "price" fires on "pricelist". A rule on "book" does not fire on "facebook". Plain substring matching would fire on that second case.&lt;/p&gt;

&lt;p&gt;One event is handled by one rule, and keyword rules win over the AI rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting the model answer real questions
&lt;/h2&gt;

&lt;p&gt;There is a rule that lets the assistant answer genuine questions under posts, such as "how do I book?", when the answer exists in the business's instructions or documents. It skips praise, emoji and spam, and it may not invent an answer.&lt;/p&gt;

&lt;p&gt;Each checked comment costs the owner money, like any model call, so this rule has its own ceiling of 60 an hour and sits below the keyword rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The follower gate
&lt;/h2&gt;

&lt;p&gt;Owners ask for "send it only to followers". It works: instead of the text the person gets a button, a follower gets the material, a non-follower gets a request to follow and a second button.&lt;/p&gt;

&lt;p&gt;We describe the risk plainly in the guide. Instagram's anti-spam rules do not favour trading a follow for a reward, many accounts do it anyway, and after complaints reach can be cut. That is the owner's decision to make with the risk in front of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What generalises
&lt;/h2&gt;

&lt;p&gt;Most of the code here handles "not now": one reply only, not after 24 hours, not above the ceiling, not the same text twice. If you build on a messaging platform, list its refusals first and design the states for them, including what the user sees while a message waits.&lt;/p&gt;

&lt;p&gt;The owner-facing description is in our guide: &lt;a href="https://dosai.pro/docs/guide/03-tools/automations" rel="noopener noreferrer"&gt;rules and comments&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>instagram</category>
      <category>chatbots</category>
      <category>automation</category>
      <category>startup</category>
    </item>
    <item>
      <title>Appointment reminders in a chat bot: six rules we ended up with</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sat, 10 Oct 2026 19:03:30 +0000</pubDate>
      <link>https://dev.to/dosai/appointment-reminders-in-a-chat-bot-six-rules-we-ended-up-with-ci2</link>
      <guid>https://dev.to/dosai/appointment-reminders-in-a-chat-bot-six-rules-we-ended-up-with-ci2</guid>
      <description>&lt;p&gt;I build DOS AI (dosai.pro), a platform where a small business sets up an AI assistant for WhatsApp, Instagram and Telegram. Booking is a core feature there, and the part that sounds trivial, "send a reminder before the visit", turned out to hold most of the rules.&lt;/p&gt;

&lt;p&gt;None of this is about the model. The reminder texts are templates, not generated, and they cost the owner nothing. The work was deciding when not to send.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Night is a hard wall
&lt;/h2&gt;

&lt;p&gt;Messages about a booking go out between 9:00 and 21:00 in the project's time zone. Two cases fall out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A reminder that would land at night is sent the evening before, at 20:30. A 9:30 visit with a "two hours before" reminder gets it at 20:30 the previous day, not at 7:30.&lt;/li&gt;
&lt;li&gt;A confirmation for a booking created at night goes out at 9:00. If the visit itself is before 10:00, the confirmation is skipped, because the reminder already covers it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A confirmation arriving twenty minutes before the visit it confirms is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Do not confirm what the customer already knows
&lt;/h2&gt;

&lt;p&gt;A confirmation is for a person who has not heard from you yet. So it is not sent when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the bot made the booking itself (it has just written "you are booked" in the same chat);&lt;/li&gt;
&lt;li&gt;the chat was active in the last 30 minutes;&lt;/li&gt;
&lt;li&gt;the business's own system says it sends confirmations on its side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reminders still go out in all three cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Never write first to a stranger on WhatsApp
&lt;/h2&gt;

&lt;p&gt;A reminder is a message the business sends first, with no fresh message from the customer. WhatsApp blocks numbers that write to people who never wrote to them. So the rule is: we only message someone who has already written to this WhatsApp number, and we check that at send time, not at booking time. If the customer writes between the booking and the reminder, the reminder goes.&lt;/p&gt;

&lt;p&gt;For everyone else the booking shows the reason in plain words, "the customer has never written to your WhatsApp number", and a button that opens WhatsApp on the owner's phone with the text prepared. A human sends it. That is slower, and it keeps the number alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Each channel has its own window
&lt;/h2&gt;

&lt;p&gt;Instagram does not take part at all. You may answer there only within 24 hours of the customer's last message, so a reminder a day ahead cannot get through. Telegram does take part: a chat with a bot exists only if the customer started it.&lt;/p&gt;

&lt;p&gt;It is tempting to hide this behind a "send reminder" abstraction. We show it instead, because the owner needs to know why a given customer got nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A reschedule must find the same booking
&lt;/h2&gt;

&lt;p&gt;Moving a booking moves its reminders. Cancelling removes them. The hard part is knowing that two events are the same booking.&lt;/p&gt;

&lt;p&gt;When a booking lives in an external system, we ask for its booking id and match on that. Without an id we fall back to a rule: a repeat booking by the same customer in the same conversation within 2 hours is a reschedule, a later one is a new booking. It is a guess, and the guide says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Every message has a visible fate
&lt;/h2&gt;

&lt;p&gt;Each reminder in the list carries one of five labels: scheduled (with the time already adjusted for the night rule), sent, not sent with a reason, not delivered, removed. "Not sent" always names the cause: no WhatsApp connected, subscription ended, reminders switched off, customer never wrote.&lt;/p&gt;

&lt;p&gt;Without such a list, "the customer says they got no reminder" has no answer. With it, the answer is one line to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do first next time
&lt;/h2&gt;

&lt;p&gt;Write the "do not send" rules before the "send" code. Sending a templated message at a given time is an afternoon. Night hours, strangers, channel windows and reschedules are where the design time goes, and each of them is a case of a real person getting a message they should not have.&lt;/p&gt;

&lt;p&gt;The owner-facing description of all this is in our guide: &lt;a href="https://dosai.pro/docs/guide/03-tools/41-bookings" rel="noopener noreferrer"&gt;confirmations and reminders&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chatbots</category>
      <category>whatsapp</category>
      <category>automation</category>
      <category>startup</category>
    </item>
    <item>
      <title>Choosing an AI chatbot for WhatsApp in 2026: six kinds of tools and ten questions to ask</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Tue, 15 Sep 2026 08:34:46 +0000</pubDate>
      <link>https://dev.to/dosai/choosing-an-ai-chatbot-for-whatsapp-in-2026-six-kinds-of-tools-and-ten-questions-to-ask-2h9f</link>
      <guid>https://dev.to/dosai/choosing-an-ai-chatbot-for-whatsapp-in-2026-six-kinds-of-tools-and-ten-questions-to-ask-2h9f</guid>
      <description>&lt;p&gt;Every small business owner who has ever missed a WhatsApp message at 11 pm eventually types "best AI chatbot for WhatsApp" into a search bar. The answers mix six very different kinds of product under one word, and the wrong pick costs months.&lt;/p&gt;

&lt;p&gt;I build one of these products (&lt;a href="https://dosai.pro/en" rel="noopener noreferrer"&gt;DOS AI&lt;/a&gt;, an AI assistant for WhatsApp and Telegram with a built-in CRM), so treat my view with that in mind. Below is the map I wish owners had before they talked to any vendor, including us.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what changed in 2026
&lt;/h2&gt;

&lt;p&gt;In October 2025 WhatsApp changed the terms of its Business Platform. From January 15, 2026 general-purpose AI assistants ("ask me anything" bots such as ChatGPT, Copilot or Perplexity) are no longer allowed on it. AI bots that serve a specific business (support, bookings, order status, selling that business's products) are still allowed.&lt;/p&gt;

&lt;p&gt;In practice: your bot should talk about your business and politely decline everything else. That is also simply good configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start from the job, not the tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"We lose customers because nobody answers fast enough."&lt;/strong&gt; You need something that answers on its own, in free text. Canned replies will not do it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Leads get lost in sales reps' phones."&lt;/strong&gt; You need a CRM with the chat inside. Humans can keep answering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The flow is repetitive: book, get a price, get a file."&lt;/strong&gt; A button script is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"We send promotions to our customer base."&lt;/strong&gt; That is a separate job, and on WhatsApp it means the official API with approved templates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Six kinds of tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Who answers the customer&lt;/th&gt;
&lt;th&gt;You pay for&lt;/th&gt;
&lt;th&gt;Pick it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp Business app&lt;/td&gt;
&lt;td&gt;the app itself&lt;/td&gt;
&lt;td&gt;you, plus a greeting&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;few messages, you answer yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flow builder&lt;/td&gt;
&lt;td&gt;Manychat, SendPulse, Chatfuel&lt;/td&gt;
&lt;td&gt;a button script&lt;/td&gt;
&lt;td&gt;subscription, often per contact&lt;/td&gt;
&lt;td&gt;uniform flow from ads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRM connector&lt;/td&gt;
&lt;td&gt;Wazzup, Chat2Desk, Kommo integrations&lt;/td&gt;
&lt;td&gt;your reps&lt;/td&gt;
&lt;td&gt;per channel&lt;/td&gt;
&lt;td&gt;you have a sales team and a CRM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official API provider&lt;/td&gt;
&lt;td&gt;WATI, Twilio, 360dialog&lt;/td&gt;
&lt;td&gt;whatever you build on top&lt;/td&gt;
&lt;td&gt;provider plan plus Meta template fees&lt;/td&gt;
&lt;td&gt;broadcasts, large companies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agency, done for you&lt;/td&gt;
&lt;td&gt;local studios&lt;/td&gt;
&lt;td&gt;a bot built for you&lt;/td&gt;
&lt;td&gt;build fee or monthly retainer&lt;/td&gt;
&lt;td&gt;no time, budget available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-serve AI platform&lt;/td&gt;
&lt;td&gt;DOS AI, Respond.io AI agents&lt;/td&gt;
&lt;td&gt;an AI model on your data&lt;/td&gt;
&lt;td&gt;subscription plus usage&lt;/td&gt;
&lt;td&gt;the bot must answer on its own and you edit it yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few notes that the table cannot hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flow builders&lt;/strong&gt; are predictable, which is their strength, and they break the moment a customer writes something the tree did not expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connectors&lt;/strong&gt; put chats into your CRM but do not answer. At night the customer still waits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Official API&lt;/strong&gt; numbers move off the regular phone app, and outbound template messages are billed by Meta. Replies within 24 hours of the customer's message are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI platforms&lt;/strong&gt; differ mostly in how they connect. Some run on the official API, DOS AI links your existing number by QR the way WhatsApp Web does. All of them share one honest weakness: a language model can be wrong, so anything involving money has to be checked by the system, not by a sentence in the prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Ten questions to ask any vendor before paying
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Who answers when the question is off-script: the bot in its own words, "sorry, I did not understand", or nobody?&lt;/li&gt;
&lt;li&gt;Does it work on my current number, or do I need a new one or an API migration?&lt;/li&gt;
&lt;li&gt;What makes the bill grow: contacts, channels, messages or AI usage? Price it on your monthly volume, not on the "from" tier.&lt;/li&gt;
&lt;li&gt;Who edits answers, and how long does a price change take?&lt;/li&gt;
&lt;li&gt;What does the bot do when it does not know? The right answer is "let me check" and a handover, not an invented price.&lt;/li&gt;
&lt;li&gt;Does it understand voice notes, and in which languages?&lt;/li&gt;
&lt;li&gt;Where do leads go: built-in CRM, your CRM, a spreadsheet, nowhere?&lt;/li&gt;
&lt;li&gt;Can I test the bot on my own questions before connecting the real number?&lt;/li&gt;
&lt;li&gt;How does payment work inside the chat, if at all?&lt;/li&gt;
&lt;li&gt;What protects my number from being banned, and does the product send messages to strangers in bulk?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A one-evening test for any option
&lt;/h2&gt;

&lt;p&gt;Take twenty real conversations from last week and write down every customer question, typos and voice notes included. Run them through the vendor's test chat. Count how many answers you would send to a customer without blushing. Fifteen out of twenty means the tool works and the rest is fixed with better instructions. Five out of twenty means no setting will save it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where DOS AI fits, honestly
&lt;/h2&gt;

&lt;p&gt;Good fit: small and mid-sized businesses whose customers write to WhatsApp or Telegram and expect a fast answer, such as clinics, salons, driving schools and training centres, especially where customers switch between Russian and Kazakh or send voice notes. Pricing: Telegram is free, WhatsApp is $25 per number per month plus AI usage (a whole customer conversation usually costs 6 to 13 cents), 14 days free after connecting the number.&lt;/p&gt;

&lt;p&gt;Not a fit: bulk broadcasting to thousands of people a day, a Meta-verified API number with approved templates, a strict button-only bot, or voice calls.&lt;/p&gt;

&lt;p&gt;If you are choosing right now, the ten questions above matter more than any vendor's feature list, ours included.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>ai</category>
      <category>chatbot</category>
      <category>smallbusiness</category>
    </item>
    <item>
      <title>A check that cannot fail is not a check</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 16:30:19 +0000</pubDate>
      <link>https://dev.to/dosai/a-check-that-cannot-fail-is-not-a-check-9dl</link>
      <guid>https://dev.to/dosai/a-check-that-cannot-fail-is-not-a-check-9dl</guid>
      <description>&lt;p&gt;Two of my guardrails reported clean for weeks. Neither of them was capable of reporting anything else.&lt;/p&gt;

&lt;p&gt;That is a different failure from a broken check. A broken check throws an error and you go fix it. These returned the exact output you get when everything is fine, so I read them as confirmation and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grep that searched for a string nobody has ever written
&lt;/h2&gt;

&lt;p&gt;We have a house rule against em dashes in published text. To enforce it on myself I grepped my own diffs:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;$'—&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s1"&gt;–'&lt;/span&gt; file.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Every time. The rule looked healthy for months.&lt;/p&gt;

&lt;p&gt;On macOS, BSD &lt;code&gt;grep&lt;/code&gt; in basic mode treats &lt;code&gt;\|&lt;/code&gt; as two literal characters instead of alternation. So that pattern searches for one literal five-character sequence, &lt;code&gt;—\|–&lt;/code&gt;, which appears in no file I will ever write. It returns &lt;code&gt;0&lt;/code&gt; for a file stuffed with em dashes and &lt;code&gt;0&lt;/code&gt; for an empty file. Those two zeros look identical.&lt;/p&gt;

&lt;p&gt;The version that works:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'—'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'–'&lt;/span&gt; file.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is not the escaping rule. It is that I treated a zero as evidence without once asking what a non-zero would have required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The analytics tag with nobody to send the hit
&lt;/h2&gt;

&lt;p&gt;Most web analytics snippets have a "defer" or "manual pageview" mode. You turn it on when your app is a SPA, because you want to control when a view is counted instead of letting the loader fire on script execution.&lt;/p&gt;

&lt;p&gt;Turning it on has a second effect that is easy to forget. The loader stops sending the &lt;em&gt;initial&lt;/em&gt; pageview too. From that moment the counter reports only what your code explicitly sends.&lt;/p&gt;

&lt;p&gt;If your code never sends anything, the outside view is indistinguishable from a working install:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the script loads, 200&lt;/li&gt;
&lt;li&gt;the counter appears as "installed" in the vendor dashboard&lt;/li&gt;
&lt;li&gt;no console errors&lt;/li&gt;
&lt;li&gt;no failed requests, because a request that is never made cannot fail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get an empty dashboard, and an empty dashboard has a much more likely explanation available: nobody visited. That explanation is wrong and it is very comfortable.&lt;/p&gt;

&lt;p&gt;The 20-second probe, using Yandex Metrica as the example, works the same way for most vendors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open any page with ?_ym_debug=1
console must print: PageView. Counter &amp;lt;id&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line is missing, nothing is being counted, whatever the dashboard says.&lt;/p&gt;

&lt;p&gt;The fix is two lines and the comment above them matters more than the code:&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="c1"&gt;// The counter runs deferred because this is a SPA and route changes are&lt;/span&gt;
&lt;span class="c1"&gt;// not document loads. Deferred means the loader sends NOTHING on its own.&lt;/span&gt;
&lt;span class="c1"&gt;// If you ever remove `defer`, remove this call in the same commit, or every&lt;/span&gt;
&lt;span class="c1"&gt;// view is counted twice.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trackPageView&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referer&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;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;COUNTER_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;hit&lt;/span&gt;&lt;span class="dl"&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;referer&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;referer&lt;/span&gt; &lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings that must agree, living in two files, with no error on either side when they disagree. That is the shape of the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What both of these have in common
&lt;/h2&gt;

&lt;p&gt;Neither check had a failing case I had ever observed.&lt;/p&gt;

&lt;p&gt;I had never seen the grep print a number greater than zero. I had never seen the analytics dashboard show a number at all. In both cases the absence was the expected output of the healthy state, so it carried no information, and I kept banking it as good news.&lt;/p&gt;

&lt;p&gt;Three habits came out of this, and they are cheap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feed the check something bad on purpose.&lt;/strong&gt; Before trusting a linter, a grep, a hook, or an alert, hand it input that must trip it. If you cannot make it fail, you do not have a check. You have a constant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be suspicious of zero specifically.&lt;/strong&gt; Zero errors, zero matches, zero events. Every one of those is also what a disconnected pipe returns. A pipeline that reports "3 problems found, 3 fixed" tells you it ran. A pipeline that reports nothing tells you nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify at the far end, not at the near end.&lt;/strong&gt; "The script returns 200" is a fact about your server. "The console prints PageView" is a fact about the thing you actually wanted. They are not the same fact, and only the second one is worth anything.&lt;/p&gt;

&lt;p&gt;The pattern generalises past these two. Alerts wired to a metric nobody emits. Retry logic behind a condition that is never true. A test asserting a mock. In each case the system reports health, because reporting health is the only thing it can do.&lt;/p&gt;




&lt;p&gt;I write about the ugly parts of running an assistant product on top of WhatsApp and Telegram at &lt;a href="https://dosai.pro" rel="noopener noreferrer"&gt;dosai.pro&lt;/a&gt;. The public API and MCP server are documented at &lt;a href="https://dosai.pro/docs/guide/08-developers/80-public-api" rel="noopener noreferrer"&gt;dosai.pro/docs/guide/08-developers/80-public-api&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>webdev</category>
      <category>observability</category>
    </item>
    <item>
      <title>Automating a phone number you do not own: four invariants</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 11:04:42 +0000</pubDate>
      <link>https://dev.to/dosai/automating-a-phone-number-you-do-not-own-four-invariants-356i</link>
      <guid>https://dev.to/dosai/automating-a-phone-number-you-do-not-own-four-invariants-356i</guid>
      <description>&lt;p&gt;If your product connects to WhatsApp through the linked-device protocol, your code operates a phone number that belongs to someone else. A small business owner scans a QR code and hands you their working line, the one printed on their van and saved in every customer's contacts.&lt;/p&gt;

&lt;p&gt;The cost of a mistake is not a 500 in your dashboard. It is a ban, and a ban is not something you can refund. Only the messenger lifts it, on the owner's appeal, on their timeline.&lt;/p&gt;

&lt;p&gt;What surprises most teams is that the anti-fraud system does not only read outgoing text. It watches how your protocol client behaves. A number can be flagged after two messages in a week if the client around it looks wrong.&lt;/p&gt;

&lt;p&gt;Below are four invariants I now treat as non-negotiable for any system of this kind. None of them are about content.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Reaction is allowed, initiative is not
&lt;/h2&gt;

&lt;p&gt;Replying to an incoming message is fine. Everything your process starts on its own is suspect.&lt;/p&gt;

&lt;p&gt;Never let automation tear down a live session, request a fresh QR, wipe stored credentials, or scan the server for numbers on a schedule. Those actions belong to an explicit human click in your UI, and nowhere else.&lt;/p&gt;

&lt;p&gt;The dangerous code is always well-intentioned. It reads like this:&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="c1"&gt;// looks caring, behaves like an unstable client&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastInbound&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SILENCE_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="nf"&gt;restartSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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 intent is "the channel has been quiet, let me reconnect just in case". From the outside, a client that re-authenticates a healthy session three times in a morning is indistinguishable from a compromised or emulated one. Silence is usually just a quiet Tuesday.&lt;/p&gt;

&lt;p&gt;If you need a liveness check, make it one auth-safe probe per silence episode with a long cooldown, and log the outcome. Anything that can loop must not touch authentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Any bulk lookup must converge
&lt;/h2&gt;

&lt;p&gt;Sooner or later you will want to resolve identifiers: map contacts, check which numbers exist, backfill a table after a protocol change. That loop talks to the messenger about people who are not your users.&lt;/p&gt;

&lt;p&gt;Three properties make it acceptable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it remembers negative answers, not only successful ones&lt;/li&gt;
&lt;li&gt;it has a ceiling per pass&lt;/li&gt;
&lt;li&gt;it has a minimum interval between passes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remembering only successes is the classic bug. Every pass re-asks about the same unresolvable numbers, the query count never drops, and the loop runs forever at full volume.&lt;/p&gt;

&lt;p&gt;The convergence probe is arithmetic. Count the lookups per day and compare consecutive days. Falling is healthy. Flat means the loop is asking the same questions forever, and from the provider's side a permanent high-volume stream of questions about third-party numbers looks exactly like scraping a contact database. That is a category they ban for on its own, no matter how few messages you send.&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="c1"&gt;// the fix is one table, not a rewrite&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordLookupResult&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// including "not found"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&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;pickPending&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CEILING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;olderThan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MIN_INTERVAL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. A process restart is not a reason to touch every session
&lt;/h2&gt;

&lt;p&gt;Deploys are when fleets die together. The container comes up, the boot path iterates over stored sessions, and every number reconnects inside the same second.&lt;/p&gt;

&lt;p&gt;Stagger it. A dozen seconds between reconnects plus jitter, and no automatic connection at all for sessions with no stored credentials, since those can only produce a QR request that nobody asked for.&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&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="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasStoredAuth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// QR is a human decision&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&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="mi"&gt;12&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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;
  
  
  4. There are no silent touches
&lt;/h2&gt;

&lt;p&gt;Every disconnect, reconnect and re-auth gets a row in a durable log with a parsed reason, and that log has to outlive the process that writes it.&lt;/p&gt;

&lt;p&gt;This sounds like ordinary hygiene until the day you need it. When a number gets flagged, you have one question to answer: what did our client do in the hours before. If your telemetry lived in container stdout, the answer is a guess, and guesses are how teams end up blaming the messenger for their own restart loop.&lt;/p&gt;

&lt;p&gt;Write the events to a table. Mount the raw log on a volume. Archive it on rebuild. It costs nothing and it is the difference between a post-mortem and a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test to run on your own code
&lt;/h2&gt;

&lt;p&gt;Take any automation you have that touches a session, and ask: if this misfires ten times in a row on a live customer number, what does the provider see?&lt;/p&gt;

&lt;p&gt;Ten replies to ten incoming messages, fine. Ten re-authentications, ten QR requests, or ten thousand queries about strangers' numbers, not fine. That question catches the whole class, and it catches it before someone loses their business line rather than after.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://dosai.pro" rel="noopener noreferrer"&gt;DOS AI&lt;/a&gt;, where these rules are enforced by the platform rather than left to the integrator. Happy to compare notes in the comments if you run a fleet of your own.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>automation</category>
      <category>reliability</category>
      <category>node</category>
    </item>
    <item>
      <title>Putting an MCP server in front of a real product: four things I got wrong first</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 11:04:31 +0000</pubDate>
      <link>https://dev.to/dosai/putting-an-mcp-server-in-front-of-a-real-product-four-things-i-got-wrong-first-1no2</link>
      <guid>https://dev.to/dosai/putting-an-mcp-server-in-front-of-a-real-product-four-things-i-got-wrong-first-1no2</guid>
      <description>&lt;p&gt;If you ship an MCP server for a product that already has a REST API, the tempting move is to wire the tools straight into your service layer. Skip the network hop, call the function, return the object. I did the opposite, and after a full audit of the surface I am glad I did. Here are the four decisions that turned out to matter, three of which I only got right on the second pass.&lt;/p&gt;

&lt;p&gt;I build a platform where an AI assistant answers customers in WhatsApp and Telegram, and the owner manages projects, conversations and leads from a dashboard. The MCP server exposes a slice of that to AI agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Every tool calls your own HTTP route, not your service layer
&lt;/h2&gt;

&lt;p&gt;This looks like pure waste. You are inside the same process, you have the client, you could call &lt;code&gt;listConversations(projectId)&lt;/code&gt; directly and save 20ms.&lt;/p&gt;

&lt;p&gt;Do not.&lt;/p&gt;

&lt;p&gt;Our API keys carry a read-only flag, and the gate that enforces it reads the request method from a header that middleware sets. An in-process call has no request, no header, and therefore no gate. The same is true for role checks, project scoping and rate limits: all of them live on the route. Bypassing the route means reimplementing four security controls in a second place, where they will drift.&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="c1"&gt;// tool definition, simplified&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&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;list_conversations&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&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;`/api/projects/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/conversations`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&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;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_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 tool layer becomes a projection: build a path, forward auth, shape the result. Nothing else. When we later added a new permission rank, no MCP code changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A 200 response is not a successful operation
&lt;/h2&gt;

&lt;p&gt;Our send-message route returns HTTP 200 with &lt;code&gt;{ ok: false, error: "channel refused" }&lt;/code&gt; when the messaging provider rejects the payload. The route did its job, so 200 is correct.&lt;/p&gt;

&lt;p&gt;The first version of the tool reported that as success. An agent told a human "message sent" about a message that never left the building. That is the worst class of bug in an agent product, because the agent is confident and the user has no reason to check.&lt;/p&gt;

&lt;p&gt;So tools carry an explicit failure predicate:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&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;send_message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;failWhen&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;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;ok&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;failMessage&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`Channel refused the message: &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;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&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;Rule of thumb: if your route can express failure inside a 200 body, your tool layer must know about it. HTTP status is a transport signal, not a business one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Unwrap your envelope, or your confirmations arrive empty
&lt;/h2&gt;

&lt;p&gt;Most of our routes answer with an envelope: &lt;code&gt;{ data: ... }&lt;/code&gt;, and a couple with &lt;code&gt;{ success: true, data: ... }&lt;/code&gt;. Human clients unwrap it without thinking. The tool projection did not.&lt;/p&gt;

&lt;p&gt;The result was subtle. &lt;code&gt;update_prompt&lt;/code&gt; returned &lt;code&gt;{ ok: true }&lt;/code&gt; and nothing else, because the interesting part sat one level deeper and got dropped. The agent had no way to confirm what it had written, so it either stayed vague or invented the detail.&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="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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unwrap&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="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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test this per tool. A single shared &lt;code&gt;unwrap&lt;/code&gt; is not enough if some routes wrap and some do not, which is exactly the state most real APIs are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Idempotency keys must be built from meaning only
&lt;/h2&gt;

&lt;p&gt;Our send tool deduplicates repeated calls. The first key looked like this:&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;key&lt;/span&gt; &lt;span class="o"&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;conversationId&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;text&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;The minute bucket felt harmless. It is not. Two identical calls that straddle a bucket boundary produce two different keys, so the duplicate goes through. The failure is rare, non-deterministic and impossible to reproduce on demand, which is another way of saying it will happen in front of a customer.&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;key&lt;/span&gt; &lt;span class="o"&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;conversationId&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="nf"&gt;sha256&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="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;If time belongs in the key, it should come from the domain (a booking slot, a billing period), never from the clock at call time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I decided on purpose: no destructive tools
&lt;/h2&gt;

&lt;p&gt;There is no delete-project tool, no payment tool, no remove-member tool. Not because they are hard, but because an agent that can read a customer message can be talked into acting on it, and the blast radius of a confused delete is not recoverable by an apology.&lt;/p&gt;

&lt;p&gt;A test enumerates the registry and fails if a tool name matches a destructive verb. That test exists so that a future me, in a hurry, has to argue with a red build instead of quietly adding one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone starting today
&lt;/h2&gt;

&lt;p&gt;Treat the MCP layer as a thin, dumb projection of an API you already trust. Every piece of intelligence you put in it is a piece of intelligence that now exists twice.&lt;/p&gt;

&lt;p&gt;Check the three failure modes above with a real agent, not with curl. Curl reads a raw JSON body and you fill in the meaning yourself. An agent reads what your tool returns and reports it to a human as fact, so an empty confirmation or a false success becomes a lie with your name on it.&lt;/p&gt;

&lt;p&gt;The spec of what we expose is public if you want to compare notes: &lt;a href="https://dosai.pro/llms.txt" rel="noopener noreferrer"&gt;dosai.pro/llms.txt&lt;/a&gt;. Happy to go into the auth details in the comments.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your prompt is not a security boundary</title>
      <dc:creator>DOS AI</dc:creator>
      <pubDate>Sun, 16 Aug 2026 03:56:19 +0000</pubDate>
      <link>https://dev.to/dosai/your-prompt-is-not-a-security-boundary-382e</link>
      <guid>https://dev.to/dosai/your-prompt-is-not-a-security-boundary-382e</guid>
      <description>&lt;p&gt;If your AI agent owns tools with side effects, one question decides whether it&lt;br&gt;
is safe to ship: what happens when the model confidently calls a money tool on&lt;br&gt;
invented grounds.&lt;/p&gt;

&lt;p&gt;This is a writeup of one mechanism that closes that hole, and of where the&lt;br&gt;
mechanism stops working. The context is assistants that talk to real customers&lt;br&gt;
in messengers and can do irreversible things: confirm a payment, issue an&lt;br&gt;
invoice, book a slot, notify the business owner.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why an instruction in the prompt is not a control
&lt;/h3&gt;

&lt;p&gt;A line like "only confirm payment after you received the receipt" executes&lt;br&gt;
with a probability, not with a guarantee. That is not a quality problem with&lt;br&gt;
the model. It follows from the training objective: be helpful, agree with the&lt;br&gt;
person in front of you.&lt;/p&gt;

&lt;p&gt;The conversation goes like this. The customer writes "I already paid, I will&lt;br&gt;
send the receipt later, please confirm". There is no receipt. The model sees a&lt;br&gt;
polite persistent human, sees an instruction that contradicts him, and over a&lt;br&gt;
long context it picks cooperation. It answers "payment confirmed" and calls&lt;br&gt;
the tool.&lt;/p&gt;

&lt;p&gt;For tone of voice, probabilistic execution is fine. For money it is not.&lt;/p&gt;

&lt;p&gt;One more hope worth killing early: the tool config field that looks like a&lt;br&gt;
predicate. Most function schemas carry something like &lt;code&gt;trigger_type&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;ai_decides&lt;/code&gt; literally means "the model decides". That field controls when the&lt;br&gt;
tool is offered, never under which facts the tool is allowed to fire.&lt;/p&gt;
&lt;h3&gt;
  
  
  A precondition is a declarative fact about the conversation
&lt;/h3&gt;

&lt;p&gt;The idea is small. A function carries a list of facts that the executor checks&lt;br&gt;
against the database before dispatch. Not "the model believes a receipt&lt;br&gt;
exists", but "there is an inbound attachment in this conversation".&lt;/p&gt;

&lt;p&gt;Stored as JSONB next to the function:&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="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;"client_sent_media"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"within_messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"media_kinds"&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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document"&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;"lead_field_filled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone"&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 type list is deliberately short and covers nearly every real requirement:&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;type&lt;/span&gt; &lt;span class="nx"&gt;FunctionPrecondition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&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;client_sent_media&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;within_messages&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;media_kinds&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="o"&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;lead_field_filled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;field&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="o"&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;function_called_before&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;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="o"&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;min_client_messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;count&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;client_sent_media&lt;/code&gt; scans the last N messages written by the customer rather&lt;br&gt;
than the last N rows of the thread. An owner who configures "the last 10&lt;br&gt;
messages" means ten customer replies, not ten rows half of which the bot wrote&lt;br&gt;
itself. The window is capped by a constant so that &lt;code&gt;within_messages: 100000&lt;/code&gt;&lt;br&gt;
in a config cannot turn the check into a table scan.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;function_called_before&lt;/code&gt; reads the event log and requires an earlier&lt;br&gt;
successful call in the same conversation. That is how you build chains like&lt;br&gt;
"verify identity first, then modify the booking".&lt;/p&gt;
&lt;h3&gt;
  
  
  Three implementation properties that carry the whole design
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The check lives in exactly one place.&lt;/strong&gt; It sits in the tool executor, after&lt;br&gt;
argument validation and strictly before dispatch to any handler. Put it inside&lt;br&gt;
the handlers instead and you fix the class one handler at a time, which means&lt;br&gt;
the next money-touching tool ships without a guard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A block is returned to the model as a tool error with a reason.&lt;/strong&gt; Not a&lt;br&gt;
silent refusal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Blocked: the customer must have sent a image/document attachment in their
last 10 messages. This did NOT happen. Do not tell the customer it did.
Ask the customer for what is missing, then call this function again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference matters more than it looks. After a silent refusal the model&lt;br&gt;
assumes the call went through and keeps lying to the customer. An error with a&lt;br&gt;
cause produces self correction inside the same round: the bot goes and asks&lt;br&gt;
for the receipt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The requirement is appended to the tool description&lt;/strong&gt;, so the model sees it&lt;br&gt;
before spending a call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HARD REQUIREMENT: this function is blocked and will refuse to run unless the
customer must have sent a image/document attachment in their last 10 messages.
Do not claim the action happened until the call actually succeeds.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fail open, on purpose
&lt;/h3&gt;

&lt;p&gt;When the check itself throws, the call goes through. It is not blocked.&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="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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;Precondition check failed, letting the call through&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the reasoning. A precondition defends against model hallucination, not&lt;br&gt;
against an attacker. An attacker has no reach into this layer at all: he&lt;br&gt;
speaks to the bot in words, while the facts come from our own database. So the&lt;br&gt;
failure mode should be chosen by cost. Blocking every function for every&lt;br&gt;
customer because Postgres blinked means breaking live conversations (no&lt;br&gt;
invoice, no booking, no answer) over a hypothesis. The failure goes loudly&lt;br&gt;
into the log, and the decision falls back to the prompt, exactly as it was&lt;br&gt;
before the guard existed.&lt;/p&gt;

&lt;p&gt;If this were access control the choice would be the opposite, fail closed. It&lt;br&gt;
is not access control, and pretending otherwise would be worse than having no&lt;br&gt;
guard.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it does not solve
&lt;/h3&gt;

&lt;p&gt;It does not replace authorization, idempotency or rate limits. It answers one&lt;br&gt;
question: is there a fact in this conversation without which the action makes&lt;br&gt;
no sense.&lt;/p&gt;

&lt;p&gt;It does not rescue a badly specified function. If your only guard is&lt;br&gt;
&lt;code&gt;min_client_messages: 2&lt;/code&gt;, you moved the problem one message down the road.&lt;/p&gt;

&lt;p&gt;It costs nothing where it is not used. A function with an empty precondition&lt;br&gt;
list issues zero queries, the branch returns on an empty array. That property&lt;br&gt;
is what keeps the guard alive past the second release: a check that slows down&lt;br&gt;
every conversation for the sake of one money flow gets removed by whoever is&lt;br&gt;
on call.&lt;/p&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;An LLM in production behaves like a capable intern. Most of its calls are&lt;br&gt;
good, and nobody lets an intern sign the cheques. Boundaries belong in code,&lt;br&gt;
get verified against data, and get logged in a way that survives a restart.&lt;br&gt;
The prompt owns the quality of the conversation, and nothing beyond it.&lt;/p&gt;

&lt;p&gt;All of the above runs in the platform I build, DOS AI: AI assistants for&lt;br&gt;
WhatsApp and Telegram with a built in CRM, configured in plain text. If you&lt;br&gt;
are building your own, our REST API, webhooks and MCP server are public, so&lt;br&gt;
you can plug your agent in and look at the contract from the outside. The&lt;br&gt;
machine readable spec sits at &lt;a href="https://dosai.pro/llms.txt" rel="noopener noreferrer"&gt;https://dosai.pro/llms.txt&lt;/a&gt; and the code samples&lt;br&gt;
are on GitHub: &lt;a href="https://github.com/adsytd1/dosai-api" rel="noopener noreferrer"&gt;https://github.com/adsytd1/dosai-api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on failure modes in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
