<?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: אחיה כהן</title>
    <description>The latest articles on DEV Community by אחיה כהן (@achiya-automation).</description>
    <link>https://dev.to/achiya-automation</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%2F3810102%2Fefb43e59-992c-4f8b-91df-ee602c7c853f.jpg</url>
      <title>DEV Community: אחיה כהן</title>
      <link>https://dev.to/achiya-automation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/achiya-automation"/>
    <language>en</language>
    <item>
      <title>I Measured What Self-Hosted Chatwoot Actually Uses. 348,703 Messages, 1.8 GB of RAM.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 02 Sep 2026 16:49:33 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-measured-what-self-hosted-chatwoot-actually-uses-348703-messages-18-gb-of-ram-4cp1</link>
      <guid>https://dev.to/achiya-automation/i-measured-what-self-hosted-chatwoot-actually-uses-348703-messages-18-gb-of-ram-4cp1</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I run a small automation shop, I set Chatwoot up for clients, and &lt;code&gt;ACHIYAVS&lt;/code&gt; is my affiliate code — if you use it on Chatwoot Cloud you pay 5% less and I earn a commission. Self-hosting, which is most of this post, earns me nothing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every "Chatwoot pricing" article copies the same four numbers off the pricing page. None of them say what the free option actually consumes, because none of them are running it.&lt;/p&gt;

&lt;p&gt;I am. So I measured mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The install
&lt;/h2&gt;

&lt;p&gt;One box. Not a cluster, not a managed database, no autoscaling group.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client accounts&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inboxes&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seats&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversations, all time&lt;/td&gt;
&lt;td&gt;10,321&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messages, all time&lt;/td&gt;
&lt;td&gt;348,703&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messages, last 30 days&lt;/td&gt;
&lt;td&gt;125,521&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collecting since&lt;/td&gt;
&lt;td&gt;25 Nov 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What it uses
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt;, steady state, mid-afternoon on a working day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chatwoot-rails-1       827 MiB
chatwoot-postgres-1    595 MiB
chatwoot-sidekiq-1     368 MiB
chatwoot-redis-1         7 MiB
                     ---------
                      1.80 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres is 997 MB on disk. The whole box sits at 20 GB of 38 GB used and 2.4 GB of 3.7 GB RAM — and that box hosts other things too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;348,703 messages across 20 inboxes fit inside about 1.8 GB of RAM.&lt;/strong&gt; A 4 GB VPS runs this without strain.&lt;/p&gt;

&lt;p&gt;The structural point matters more than the number: &lt;strong&gt;that footprint tracks message volume, not seat count.&lt;/strong&gt; Adding an agent to a self-hosted install costs nothing. On Cloud, every seat is another line on the bill, every month, for as long as you have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line item nobody quotes
&lt;/h2&gt;

&lt;p&gt;I went looking for what my install would cost on Cloud, and found the thing that actually decides the tier — and it is not the price.&lt;/p&gt;

&lt;p&gt;Each Cloud tier caps how long your conversation history is kept. Thirty days on the free tier, six months on the cheapest paid one, longer above that.&lt;/p&gt;

&lt;p&gt;My history goes back to 25 November 2025. That is 282 days. On the entry paid tier, roughly three months of client conversations would already be gone — not archived, gone.&lt;/p&gt;

&lt;p&gt;A support inbox is a record. "You told me in March that this was included" is a question someone has to be able to answer. That constraint, not the sticker price, is what pushes most teams up a tier.&lt;/p&gt;

&lt;p&gt;I put the full tier-by-tier breakdown, with the retention caps and the crossover math at different team sizes, on my own site: &lt;a href="https://achiya-automation.com/en/blog/chatwoot-pricing/" rel="noopener noreferrer"&gt;what Chatwoot actually costs&lt;/a&gt;. It is the version I wish I had found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost that is not in anyone's table
&lt;/h2&gt;

&lt;p&gt;Self-hosting is not free. It costs the thing hardest to price: your attention, at inconvenient hours.&lt;/p&gt;

&lt;p&gt;In the months I have run this I have handled Postgres connection-pool exhaustion, a Redis eviction policy that quietly dropped background jobs, a log rotation config that corrupted an access log badly enough to break my own monitoring, and an upgrade that reverted files I had mounted into the container.&lt;/p&gt;

&lt;p&gt;None of that was Chatwoot's fault. All of it was the price of the $0 licence.&lt;/p&gt;

&lt;p&gt;If your team does not already have someone comfortable in a terminal at 11pm, the honest answer is that Cloud is cheaper for you than self-hosting — because the alternative is not "a server," it is "a server plus a person."&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one
&lt;/h2&gt;

&lt;p&gt;Self-host if you have ops capacity, more than a handful of seats, and you want history you control. The economics improve with every seat and never get worse.&lt;/p&gt;

&lt;p&gt;Take Cloud if support is not your engineering problem and you would rather pay for that boundary. That is a legitimate purchase, not a defeat.&lt;/p&gt;

&lt;p&gt;And if you take Cloud, the code is &lt;code&gt;ACHIYAVS&lt;/code&gt; for 5% off — which is the disclosure at the top of this post, and the real number. Search for a Chatwoot code and you will find "70% off" and "$100 off" on half a dozen coupon sites. The &lt;a href="https://www.chatwoot.com/affiliate-program" rel="noopener noreferrer"&gt;published affiliate terms&lt;/a&gt; give the customer 5%. Anything larger is expired or was never real.&lt;/p&gt;

&lt;p&gt;On 34 seats that discount is worth about $33 a month. It is not a reason to choose Cloud. It is just the correct number, which turns out to be surprisingly hard to find.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Figures measured on a production install, 2 September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you self-host Chatwoot — what does your install actually use, and did the retention cap ever catch you out?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>chatwoot</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Run n8n in Both Modes. Queue Mode's Peak Load: 19 Jobs.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 31 Aug 2026 05:42:13 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-run-n8n-in-both-modes-queue-modes-peak-load-19-jobs-4bo5</link>
      <guid>https://dev.to/achiya-automation/i-run-n8n-in-both-modes-queue-modes-peak-load-19-jobs-4bo5</guid>
      <description>&lt;p&gt;I run two production n8n instances. Both sit on identical 2 vCPU / 4 GB VPSes. One runs queue mode with a dedicated worker, Redis, and Postgres. The other runs plain single-process mode.&lt;/p&gt;

&lt;p&gt;Last week the queue-mode instance handled 76,100 executions. The regular one handled 322.&lt;/p&gt;

&lt;p&gt;Here's the part that should change how you think about queue mode: at the single busiest moment of those 76,100 runs, exactly &lt;strong&gt;19 executions were in flight at once&lt;/strong&gt;. My worker runs with &lt;code&gt;--concurrency=5&lt;/code&gt;. The box never broke a sweat.&lt;/p&gt;

&lt;p&gt;Queue mode is the most recommended "scaling" step in every n8n thread, and most people who follow that advice are buying RAM and operational complexity they will never use. I have the numbers, and two production incidents, to show where the line actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, queried this morning
&lt;/h2&gt;

&lt;p&gt;Everything below comes from live SQL against both instances' Postgres, run on the day I wrote this (2026-08-31), plus &lt;code&gt;docker stats&lt;/code&gt; snapshots.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Instance A (queue mode)&lt;/th&gt;
&lt;th&gt;Instance B (regular)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;n8n version&lt;/td&gt;
&lt;td&gt;2.36.7&lt;/td&gt;
&lt;td&gt;2.36.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;2 vCPU / 4 GB&lt;/td&gt;
&lt;td&gt;2 vCPU / 4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Executions, last 7 days&lt;/td&gt;
&lt;td&gt;76,100&lt;/td&gt;
&lt;td&gt;322&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active workflows&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Success rate&lt;/td&gt;
&lt;td&gt;99.65%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median run duration&lt;/td&gt;
&lt;td&gt;0.20 s&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 / p99 duration&lt;/td&gt;
&lt;td&gt;1.59 s / 8.02 s&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longest single run&lt;/td&gt;
&lt;td&gt;7,320 s (~2 h)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak concurrent executions&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM: n8n processes&lt;/td&gt;
&lt;td&gt;456 MiB main + 445 MiB worker&lt;/td&gt;
&lt;td&gt;507 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM: Redis&lt;/td&gt;
&lt;td&gt;4 MiB&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out.&lt;/p&gt;

&lt;p&gt;First, 88% of the retained executions on instance A finish in &lt;strong&gt;under one second&lt;/strong&gt;. This is what real automation traffic looks like: webhooks arrive, a filter node rejects most of them, done. It is not a compute workload. It's a plumbing workload.&lt;/p&gt;

&lt;p&gt;Second, the queue stack costs me roughly 900 MiB of n8n processes to do what the regular instance does in 507 MiB. Redis is a rounding error, but the worker is a whole second n8n. That's the price of admission, before you've gained anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What queue mode actually bought me
&lt;/h2&gt;

&lt;p&gt;Not speed. A sub-second execution is sub-second in either mode. The queue adds a Redis hop; nobody notices it, but nobody gains from it either.&lt;/p&gt;

&lt;p&gt;What it actually bought me, in order of how often it mattered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Crash isolation.&lt;/strong&gt; See that 7,320-second run in the table? That was an AI workflow hanging on a model call for two hours before dying with an error, and it wasn't alone: the three longest runs in my window are all AI workflows that hung and eventually errored. In regular mode, runs like that live inside the same process that serves your editor and your webhooks, and a memory-hungry one takes everything down with it. In queue mode each of them burned one worker slot out of five while everything else kept flowing. This is the real feature. It's an availability feature, not a performance feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Restart behavior.&lt;/strong&gt; I can restart the worker (after an update, after a leak) without dropping incoming webhooks. The main process keeps accepting; the queue holds jobs until the worker returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A scaling path I've never used.&lt;/strong&gt; If I ever need a second worker, it's one compose line. Peak of 19 in-flight against concurrency 5 says that day is far away. That in-flight count also includes parent workflows sitting idle waiting on sub-workflow calls, so the true CPU-busy number is lower still.&lt;/p&gt;

&lt;h2&gt;
  
  
  What queue mode charged me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The migration race.&lt;/strong&gt; Every version upgrade, &lt;code&gt;docker compose up -d&lt;/code&gt; starts main and worker together, and both immediately try to run database migrations on the same Postgres. n8n has no cross-instance migration lock. The main wins; the worker crashes mid-migration with a &lt;code&gt;MigrationExecutor.executePendingMigrations&lt;/code&gt; stack trace and sits there unhealthy. First time it happened (2.30.6 → 2.32.6, during a routine fleet upgrade), the worker sat unhealthy while I worked out whether my database was half-migrated.&lt;/p&gt;

&lt;p&gt;It wasn't. The fix is boring and permanent: wait for main to report healthy, then &lt;code&gt;docker restart&lt;/code&gt; the worker. It rejoins cleanly because the migrations are already done. But nobody tells you this before you switch, and an unhealthy worker after an upgrade looks exactly like a disaster until you know it's choreography.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second thing to monitor.&lt;/strong&gt; A dead worker in queue mode fails quietly: webhooks still return 200, jobs still enqueue, and nothing executes. In regular mode, when n8n is down, everything visibly fails, which is ugly but honest. Queue mode converts loud failures into silent backlogs. You need a health check on the worker specifically, not just on the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident that queue mode couldn't touch
&lt;/h2&gt;

&lt;p&gt;The one time this instance actually hurt, no execution mode would have saved it.&lt;/p&gt;

&lt;p&gt;One gateway workflow, a filter in front of a WhatsApp bot, was receiving every event on the line and rejecting most of them in its first node. 11,880 executions per day, 96% finishing under a second. Pure no-op traffic.&lt;/p&gt;

&lt;p&gt;The damage wasn't CPU. The box was idle. The damage was &lt;strong&gt;writes&lt;/strong&gt;: n8n persists every execution by default, and my pruning cap (&lt;code&gt;EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000&lt;/code&gt;) quietly became the effective retention window. At that rate, 50K rows is 2.5 days. Sixteen of my active workflows had &lt;em&gt;zero&lt;/em&gt; saved executions left: a workflow failed three days earlier and there was no evidence it ever ran. Meanwhile &lt;code&gt;execution_data&lt;/code&gt; grew to 92% of a 1.6 GB database and the nightly dumps inflated the backup directory to 9.8 GB.&lt;/p&gt;

&lt;p&gt;The fix was two settings on one workflow: &lt;code&gt;saveDataSuccessExecution: none&lt;/code&gt;, &lt;code&gt;saveDataErrorExecution: all&lt;/code&gt;. You can see it in my daily counts: 18,238 saved executions on August 24, between 6,300 and 9,000 a day since. Same traffic, half the writes.&lt;/p&gt;

&lt;p&gt;Queue mode has no opinion about any of this. Workers don't reduce writes; they just move where the writing happens. If your n8n feels heavy, check what it's &lt;em&gt;storing&lt;/em&gt; before you scale what it's &lt;em&gt;computing&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the line actually is
&lt;/h2&gt;

&lt;p&gt;My rule after running both modes side by side:&lt;/p&gt;

&lt;p&gt;Stay in regular mode while &lt;strong&gt;all&lt;/strong&gt; of these are true: your runs are mostly short (check with the query below), a single stuck execution taking the UI down for a minute is survivable, and you're on one box anyway.&lt;/p&gt;

&lt;p&gt;Switch to queue mode when &lt;strong&gt;any&lt;/strong&gt; of these arrives: individual runs that go multi-minute or memory-heavy (my 2-hour hung AI call is the poster child), webhooks that must stay up while you restart things, or genuine horizontal scaling.&lt;/p&gt;

&lt;p&gt;Notice what's not on the list: raw execution count. 76,100 a week fits through &lt;code&gt;--concurrency=5&lt;/code&gt; because almost all of it is sub-second. Volume is the wrong trigger. Duration and blast radius are the right ones.&lt;/p&gt;

&lt;p&gt;Two queries to run against your own instance before you add containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What fraction of your runs are sub-second no-ops?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;under_1s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;execution_entity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Your true peak concurrency (sweep line over start/stop events)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;execution_entity&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;execution_entity&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first query says most of your load is sub-second, fix your filters before your architecture. If the second says your peak is under 10, queue mode is an availability decision, not a capacity one — make it for the crash isolation or don't make it at all.&lt;/p&gt;

&lt;p&gt;These two instances run client-facing automation at &lt;a href="https://achiya-automation.com/en/services/business-automation/" rel="noopener noreferrer"&gt;the automation studio behind 69 production n8n workflows&lt;/a&gt;, so the "survivable downtime" bar is set by paying customers, not by my patience. That's why the busy instance got queue mode long before it needed a second worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's your peak?&lt;/strong&gt; Run the sweep query on your own instance and post the number. I am collecting evidence for a follow-up on how oversized most n8n deployments are. If you've crossed your worker concurrency in production, I especially want to hear what the workload was.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>devops</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Ran 89,479 WhatsApp Messages Through WAHA. Twilio: $604.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 27 Aug 2026 06:54:18 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-ran-89479-whatsapp-messages-through-waha-twilio-604-3bog</link>
      <guid>https://dev.to/achiya-automation/i-ran-89479-whatsapp-messages-through-waha-twilio-604-3bog</guid>
      <description>&lt;p&gt;Last month my WhatsApp stack moved 89,479 messages. I got no invoice for any of them.&lt;/p&gt;

&lt;p&gt;That is not a brag, it is the setup for an honest accounting. Because "self-hosting is cheaper" is the least interesting sentence in infrastructure, and it is usually said by someone who has never been paged at 7am by a bot that went quiet at 2am. I want to put a real number on both sides of that trade: the money Twilio would have charged, and the money self-hosting quietly takes back.&lt;/p&gt;

&lt;p&gt;All the numbers below were pulled or fetched on &lt;strong&gt;August 27, 2026&lt;/strong&gt;. The rate cards move quarterly, so check yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traffic, measured rather than estimated
&lt;/h2&gt;

&lt;p&gt;Five WhatsApp inboxes, bridged from &lt;a href="https://waha.devlike.pro/" rel="noopener noreferrer"&gt;WAHA&lt;/a&gt; into a self-hosted Chatwoot. Thirty days:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;messages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;89,479&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbound (from users)&lt;/td&gt;
&lt;td&gt;45,563&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound (from us)&lt;/td&gt;
&lt;td&gt;43,916&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most benchmarks stop here, multiply by a per-message rate, and publish. That answer is wrong, because &lt;strong&gt;Meta does not charge per message. It charges per template sent outside an open customer service window.&lt;/strong&gt; Multiplying my full 89,479 by a template rate overstates the Meta line by about 3x. Multiplying just the outbound half still overstates it by about 1.5x.&lt;/p&gt;

&lt;p&gt;Since November 1, 2024 non-template messages are free. Since July 1, 2025 utility templates &lt;em&gt;answering a user inside an open 24-hour window&lt;/em&gt; are also free. So the only line that costs money is the outbound message that goes out when nobody has written to you in the last day.&lt;/p&gt;

&lt;p&gt;Which means the number you actually need is not "how many messages," it is "how many outbound messages had no inbound message from that contact in the preceding 24 hours."&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that produces the real bill
&lt;/h2&gt;

&lt;p&gt;Here it is against Chatwoot's schema. It uses a window function rather than a correlated &lt;code&gt;NOT EXISTS&lt;/code&gt;, because on a messages table of any size the correlated version will happily eat your connection pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_type&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- your WhatsApp inboxes&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'33 days'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="k"&gt;IN&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;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;-- 0 = incoming, 1 = outgoing&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
      &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;count&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;inside_window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
             &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outside_window&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth stealing: pull &lt;strong&gt;33&lt;/strong&gt; days of history but count only the last &lt;strong&gt;30&lt;/strong&gt;, otherwise messages near the boundary look like they have no preceding inbound and you overstate your bill. And &lt;code&gt;ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING&lt;/code&gt; is what keeps a message from counting itself as its own window opener.&lt;/p&gt;

&lt;p&gt;My result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; outbound | inside_window | outside_window
----------+---------------+----------------
    43916 |         14314 |          29602
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So of 43,916 outbound messages, &lt;strong&gt;29,602 (67%) would have been billable templates.&lt;/strong&gt; The other third rode free inside an open conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that costs on Twilio
&lt;/h2&gt;

&lt;p&gt;Twilio's WhatsApp pricing is two layers. Its own fee is &lt;strong&gt;$0.005 per message, inbound or outbound&lt;/strong&gt;, and that one applies to all 89,479. On top sits Meta's template fee, passed straight through, which varies by the recipient's country calling code.&lt;/p&gt;

&lt;p&gt;I'm in Israel. Meta's USD rate card effective July 1, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template category&lt;/th&gt;
&lt;th&gt;Israel&lt;/th&gt;
&lt;th&gt;UK&lt;/th&gt;
&lt;th&gt;Germany&lt;/th&gt;
&lt;th&gt;India&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Marketing&lt;/td&gt;
&lt;td&gt;$0.0353&lt;/td&gt;
&lt;td&gt;$0.0635&lt;/td&gt;
&lt;td&gt;$0.1365&lt;/td&gt;
&lt;td&gt;$0.0118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utility&lt;/td&gt;
&lt;td&gt;$0.0053&lt;/td&gt;
&lt;td&gt;$0.0220&lt;/td&gt;
&lt;td&gt;$0.0550&lt;/td&gt;
&lt;td&gt;$0.0014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;$0.0053&lt;/td&gt;
&lt;td&gt;$0.0220&lt;/td&gt;
&lt;td&gt;$0.0550&lt;/td&gt;
&lt;td&gt;$0.0014&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Israel is a cheap market for utility and a mid market for marketing. Germany is over ten times Israel's utility rate. If you are benchmarking, your country's row is the whole story. A US-centric blog post about WhatsApp costs is nearly useless to you.&lt;/p&gt;

&lt;p&gt;The month, priced out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Twilio handling   89,479 × $0.005   = $447.40
Meta (utility)    29,602 × $0.0053  = $156.89
                                    ---------
                                      $604.29

Meta (marketing)  29,602 × $0.0353  = $1,044.95
                                    ---------
                                    $1,492.35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;$604 if every billable template is a utility template. $1,492 if they are marketing.&lt;/strong&gt; The gap between those two numbers is not a pricing question, it is a template-categorization question, and Meta decides the category, not you.&lt;/p&gt;

&lt;p&gt;Scaled to the 10,000-messages-a-month shape people usually ask about, holding my ratios (49% outbound, 67% of that outside the window): &lt;strong&gt;$67.53 utility, $166.77 marketing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The other side of the ledger changed in June, quietly
&lt;/h2&gt;

&lt;p&gt;If you last looked at WAHA a year ago, you priced it wrong. WAHA used to sell a "Plus" license for the features anyone running it in production actually needs. In release &lt;strong&gt;2026.6.1&lt;/strong&gt;, announced June 21, 2026, every Plus feature moved into the free Core image and the tiers collapsed into a single &lt;strong&gt;optional&lt;/strong&gt; $5/month Community subscription. The project's own words: &lt;em&gt;"100% free and open source, with no limits on messages or time, and no license expiration."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the software line is now genuinely $0.&lt;/p&gt;

&lt;p&gt;The hardware line: WAHA is running on a 2 vCPU / 4 GB / 40 GB x86 VPS. It is not alone on that box: Redis and a second internal service share it. While carrying those 89,479 messages, the machine's load average was &lt;strong&gt;0.05, 0.22, 0.23&lt;/strong&gt;. This workload is not compute-bound, it is session-bound. Whatever your provider charges for the cheapest 4 GB instance, single digits a month, it does not move the comparison. Round the infra to $10 and self-hosting is still about 60× cheaper than the utility-rate scenario.&lt;/p&gt;

&lt;p&gt;Which is exactly the point at which an honest post has to stop being a sales pitch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What self-hosting actually charges you
&lt;/h2&gt;

&lt;p&gt;It bills in incidents, not invoices. Here is last week's, in full.&lt;/p&gt;

&lt;p&gt;On August 23 I rotated &lt;code&gt;WAHA_API_KEY&lt;/code&gt;. The rotation updated &lt;code&gt;/opt/waha/.env&lt;/code&gt; and the nginx snippet that injects the header. What it did not update was a second vhost, an internal tailnet-only copy created weeks earlier by duplicating the public one, where the key had been written as a &lt;strong&gt;literal&lt;/strong&gt; instead of the &lt;code&gt;$waha_api_key&lt;/code&gt; variable.&lt;/p&gt;

&lt;p&gt;Result: every &lt;code&gt;GET /api/files/…&lt;/code&gt; over the internal path started returning 401. Bots that download an image, OCR it, and act on the contents stopped being able to fetch anything. Of the three code nodes involved, one threw and killed its run outright; the other two caught the error and skipped silently, so those pipelines produced output — just output with no images in it. The public vhost was untouched, so every check that went through the domain came back green.&lt;/p&gt;

&lt;p&gt;The failure surfaced the next morning, because nobody had sent a photo overnight. A one-line fix, roughly twelve hours of quiet wrongness, and a monitoring path that was structurally incapable of seeing it.&lt;/p&gt;

&lt;p&gt;Twilio would have absorbed that entire class of problem. That is what the $604 buys. Not messages. The absence of a category of 2am.&lt;/p&gt;

&lt;p&gt;And there is a risk that no amount of care removes. WAHA drives a real WhatsApp Web session. Its own homepage says it plainly: &lt;em&gt;"WhatsApp does not allow bots or unofficial clients on their platform, so this shouldn't be considered totally safe."&lt;/em&gt; A banned number is not a support ticket, it is a dead asset. That is a business risk, not a technical one, and it does not belong in a cost table. It belongs in whatever conversation you have before you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line I actually draw
&lt;/h2&gt;

&lt;p&gt;After enough of these deployments, the rule I use with clients is not about volume at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The number is the business.&lt;/strong&gt; A clinic's only phone line, a store's published contact. Official API, pay Twilio. The ban risk is unpriceable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal, operational, or a secondary channel.&lt;/strong&gt; Courier groups, staff notifications, an intake line that can be replaced in an afternoon. WAHA. The savings are real and the downside is survivable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing blasts at any volume.&lt;/strong&gt; Official API, and check the marketing rate for your country before you promise anyone a budget. At $0.0353 a message in Israel and $0.1365 in Germany, the same campaign has wildly different economics depending on who receives it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Volume is the wrong axis. Replaceability is the right one. This is roughly the split behind &lt;a href="https://achiya-automation.com/en/pricing/" rel="noopener noreferrer"&gt;the pricing I quote Israeli businesses for WhatsApp automation&lt;/a&gt;: the channel decision comes first, and the bill follows from it.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing: Meta has further pricing changes landing &lt;strong&gt;October 1, 2026&lt;/strong&gt;, covering service and utility messages. Whatever you calculate today has a shelf life of about five weeks.&lt;/p&gt;

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

&lt;p&gt;The number that decides this for you is not 89,479 and it is not $604. It is your own &lt;code&gt;outside_window&lt;/code&gt; count, and it is the one input a vendor pricing page cannot give you.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;run it against your own inboxes and post the three numbers.&lt;/strong&gt; Outbound, inside window, outside window. I am specifically curious whether the 67% outside-window share holds anywhere else, or whether it is an artifact of how my bots are scheduled. If that ratio is stable across use cases, it is a much better rule of thumb than anything on a vendor pricing page.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>devops</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Audited 50,669 n8n Runs. 85% of Failures Alerted Nobody.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 24 Aug 2026 05:40:54 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-audited-50669-n8n-runs-85-of-failures-alerted-nobody-47pm</link>
      <guid>https://dev.to/achiya-automation/i-audited-50669-n8n-runs-85-of-failures-alerted-nobody-47pm</guid>
      <description>&lt;p&gt;One of my own workflows — the gate that decides whether a new WhatsApp contact gets the bot or gets me — failed 24 times between Thursday and Sunday. Seventy-one hours. I found out on Monday morning, by opening the executions list for an unrelated reason.&lt;/p&gt;

&lt;p&gt;That is embarrassing for someone who sells automation. So instead of fixing the one workflow and moving on, I went and measured the actual state of error handling across all 225 workflows on my main production instance. Here is every execution my instance still retains — pruning is on, so this is a six-day window, 18–24 August:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Executions (6 days)&lt;/td&gt;
&lt;td&gt;50,669&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed executions (&lt;code&gt;error&lt;/code&gt; + &lt;code&gt;crashed&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;106&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure rate&lt;/td&gt;
&lt;td&gt;0.21%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active workflows&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active workflows with an error workflow attached&lt;/td&gt;
&lt;td&gt;26 (43%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failures that occurred in a workflow with &lt;strong&gt;no&lt;/strong&gt; error handling&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90 of 106 (85%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;n8n 2.35.5, queue mode, Postgres. A 0.21% failure rate is fine. The 85% is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why silence is the default
&lt;/h2&gt;

&lt;p&gt;Error handling in n8n is opt-in, per workflow. You build one workflow whose trigger is the &lt;strong&gt;Error Trigger&lt;/strong&gt; node, then in every &lt;em&gt;other&lt;/em&gt; workflow you open Settings → Error Workflow and point it at that one. There is no switch that applies it to everything you have already built, or to everything you build next.&lt;/p&gt;

&lt;p&gt;That is a per-workflow checkbox on a list that grows every week. Nobody forgets on purpose. You just build workflow #61 at 11pm, it works, you activate it, and you move on. The failure mode of an opt-in safety net is that coverage decays quietly while the number in your head stays at "yeah, we have error handling."&lt;/p&gt;

&lt;p&gt;Mine had decayed to 43%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gap actually cost
&lt;/h2&gt;

&lt;p&gt;I pulled the failures grouped by workflow, with a column for whether that workflow had an error handler attached:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Guarded&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp group lead detection (AI)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;36.5 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal-line bot gate&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;70.9 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client error-handling workflow&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;1.2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campaign-failure watchdog&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;8.7 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website lead intake form&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;53.0 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMS dispatcher (outbox)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1.2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appointment sync + no-show recovery&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1.1 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 others&lt;/td&gt;
&lt;td&gt;mixed&lt;/td&gt;
&lt;td&gt;1–2 each&lt;/td&gt;
&lt;td&gt;~0 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last column, not the failure counts. Every window longer than nine hours belongs to an unguarded workflow. The longest is 70.9 hours — a WhatsApp bot gate that failed 24 times over three days while I was working on other things. Nothing was on fire. No customer wrote in. The bot simply did not answer new contacts, and the only reason I found it is that I went looking.&lt;/p&gt;

&lt;p&gt;The guarded workflows failed too — 16 times between them. Their longest window was 1.2 hours, because something told me.&lt;/p&gt;

&lt;p&gt;That is the whole value proposition of the Error Trigger, and it is not "fewer failures." It is &lt;strong&gt;failures that end in hours instead of days&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually scared me
&lt;/h2&gt;

&lt;p&gt;Three of my workflows contain an Error Trigger node. Those are the handlers — the things that send the Telegram alert. Here is how many active workflows depend on each one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Handler&lt;/th&gt;
&lt;th&gt;Workflows it guards&lt;/th&gt;
&lt;th&gt;Guarded itself?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client error handler&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic error alert&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product owner alert&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every handler was unguarded. And the one covering 15 workflows is the same row from the table above: &lt;strong&gt;it failed 13 times inside that same six-day window.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For those 13 failures, 15 workflows had no alerting at all and no way to find out. The error handler cannot report its own errors, because the thing that reports errors is the error handler. Quis custodiet ipsos custodes, in YAML.&lt;/p&gt;

&lt;p&gt;You cannot fix this by pointing handler A at handler B, either. That just moves the single point of silence one hop and adds a cycle you will forget about. The catcher has to be watched from &lt;strong&gt;outside n8n&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, in four parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. One handler, not one per project.&lt;/strong&gt; Fan-in beats fan-out. A single Error Trigger workflow that formats &lt;code&gt;{{ $json.workflow.name }}&lt;/code&gt;, &lt;code&gt;{{ $json.execution.id }}&lt;/code&gt; and &lt;code&gt;{{ $json.execution.error.message }}&lt;/code&gt; into one alert is easier to keep correct than six near-copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Audit coverage with a query, not with your memory.&lt;/strong&gt; This is the whole audit — run it against your n8n Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'errorWorkflow'&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'errorWorkflow'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;guarded&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;workflow_entity&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;guarded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything with &lt;code&gt;guarded = f&lt;/code&gt; is a workflow that can fail into the void. If you are on n8n Cloud, the same check runs off &lt;code&gt;GET /api/v1/workflows?active=true&lt;/code&gt; and reading &lt;code&gt;settings.errorWorkflow&lt;/code&gt; per item.&lt;/p&gt;

&lt;p&gt;That query is now the first line of my weekly runbook, next to the backup check. A new &lt;code&gt;f&lt;/code&gt; row gets treated the way a failing test does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make the handler loud about workflows, not just about errors.&lt;/strong&gt; The first thing I changed in mine was putting the workflow name in the subject line instead of in the body. That sounds trivial. It is the difference between "an automation failed" (which you snooze) and "the lead intake failed" (which you do not).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Guard the guard from outside.&lt;/strong&gt; The Error Trigger has a blind spot that no amount of coverage fixes: it fires when an execution &lt;em&gt;fails&lt;/em&gt;. It does not fire when an execution never &lt;em&gt;starts&lt;/em&gt; — a deactivated trigger, a dead cron, a webhook whose URL changed, a worker that is not consuming the queue. A workflow that stopped running entirely produces zero failed executions and therefore zero alerts. It looks exactly like a quiet week.&lt;/p&gt;

&lt;p&gt;So the handler pings a dead man's switch on every run, and a plain cron on a different machine screams if the ping stops:&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;# in the error handler workflow, and in one heartbeat workflow per instance&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="s2"&gt;"https://your-monitor/ping/&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# elsewhere — not on the n8n box&lt;/span&gt;
&lt;span class="nv"&gt;LAST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m /var/lib/heartbeat/n8n1 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; LAST &lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 3600 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;notify &lt;span class="s2"&gt;"n8n1 heartbeat is &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; LAST&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;m stale"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; matters: a monitoring call that can fail the run it is monitoring is worse than no monitoring.&lt;/p&gt;

&lt;p&gt;That is it. Four parts, none clever. The reason it is worth writing down is that I had parts 1 and 3 for as long as I have run this instance and still ate a three-day outage, because I never had part 2 and never imagined I needed part 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I landed
&lt;/h2&gt;

&lt;p&gt;I am not going to pretend this is finished. As I write this, coverage is still 43% — what changed is that I now have the number, the query that produces it, and a list of 34 workflows in the order I am going to fix them, longest silent window first. The handler-watching-the-handler problem is the part I am fixing today, because it is one workflow and it covers fifteen.&lt;/p&gt;

&lt;p&gt;The honest reason I am writing this before finishing it: the audit took twenty minutes and the three-day outage took three days. Those twenty minutes are the cheapest thing in the entire stack I maintain — cheaper than the &lt;a href="https://achiya-automation.com/services/whatsapp-bot/" rel="noopener noreferrer"&gt;WhatsApp bots I run in production for clients&lt;/a&gt;, and a rounding error against the numbers in &lt;a href="https://achiya-automation.com/blog/business-automation-complete-guide/" rel="noopener noreferrer"&gt;this breakdown of what business automation actually costs&lt;/a&gt;. Run the query before you finish reading this post and you will probably learn something uncomfortable too.&lt;/p&gt;

&lt;p&gt;One genuine question, because I do not think I have solved this part: &lt;strong&gt;how do you monitor the trigger that never fires?&lt;/strong&gt; A heartbeat proves the instance is alive and proves &lt;em&gt;that&lt;/em&gt; workflow ran. It does not prove that a webhook from a third party still arrives, or that a specific cron survived the last upgrade. Per-workflow "expected minimum run rate" alerting is the obvious answer and it also sounds like a maintenance burden that decays exactly like error-workflow coverage did.&lt;/p&gt;

&lt;p&gt;If you run n8n in production: do you alert on absence of runs, and if so — how do you keep the thresholds from rotting?&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>devops</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Self-Hosted Chatwoot: 5 Failures the Docs Don't Warn You About</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 20 Aug 2026 06:10:34 +0000</pubDate>
      <link>https://dev.to/achiya-automation/self-hosted-chatwoot-5-failures-the-docs-dont-warn-you-about-47c3</link>
      <guid>https://dev.to/achiya-automation/self-hosted-chatwoot-5-failures-the-docs-dont-warn-you-about-47c3</guid>
      <description>&lt;p&gt;I run self-hosted Chatwoot as the WhatsApp inbox for a dozen or so small Israeli businesses. Two servers, a few thousand conversations a week, a drip-sequence engine bolted on the side.&lt;/p&gt;

&lt;p&gt;Chatwoot is good software. The self-hosting docs will get you to a running container. What they will not tell you is which failures actually happen at month six, when you have real customers and real volume. These five all bit me in production, and none of them looked like what they were.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your disk fills from somewhere Postgres never sees
&lt;/h2&gt;

&lt;p&gt;I got a disk alert at 86 percent and immediately went looking at the database. That was the wrong place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB (postgres):            680 MB
chatwoot_storage_data:     17 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attachments live in ActiveStorage, on a Docker volume, not in Postgres. Every image, voice note, and PDF a customer sends is a file on disk, and none of it shows up when you check database size. If your monitoring watches the DB, it will report everything is fine right up until the container cannot write.&lt;/p&gt;

&lt;p&gt;The growth curve is a function of how many accounts you host, not how busy any one of them is. Mine sat at roughly 0.05 GB a month until I onboarded seven new businesses over two months, and then it hit 16 GB a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the right volume:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker system &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;chatwoot_storage_data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Forty-four percent of my outbound storage was duplicate files
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me. When I actually measured what was on that volume, almost half the outbound media was byte-identical copies of the same file.&lt;/p&gt;

&lt;p&gt;One 14.5 MB video was stored 48 separate times. One image was stored 325 times.&lt;/p&gt;

&lt;p&gt;Chatwoot creates a new blob and a new file on disk on every send, even when the bytes are identical. That is correct behavior for a chat app where every message owns its attachment. It becomes expensive the moment you have anything that fans one file out to many conversations. In my case it was not campaigns at all, it was the drip engine sending the same media to 48 separate conversations as ordinary outbound messages, each one a fresh &lt;code&gt;readFile&lt;/code&gt; plus &lt;code&gt;fd.append('attachments[]', ...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Deduplicating is safe, and I verified this in the Rails source before touching anything. &lt;code&gt;ActiveStorage::Blob#purge&lt;/code&gt; is guarded by a foreign key on &lt;code&gt;active_storage_attachments.blob_id&lt;/code&gt;, so deleting one message will not take out a file that other messages still point at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ActiveStorage 7.1.5.2&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;purge&lt;/span&gt;
  &lt;span class="n"&gt;destroy&lt;/span&gt;
  &lt;span class="n"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;previously_persisted?&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;InvalidForeignKey&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One pass over existing blobs, matching on &lt;code&gt;checksum&lt;/code&gt; and &lt;code&gt;byte_size&lt;/code&gt; within the same account, reclaimed 3.20 GB across 7,160 blobs and took the disk from 86 percent to 71 percent. You want an index on &lt;code&gt;active_storage_blobs (checksum, byte_size)&lt;/code&gt; before you try this, and a size floor so you are not doing lookups for every 4 KB thumbnail.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;POST /messages&lt;/code&gt; returns 200 before anything has been sent
&lt;/h2&gt;

&lt;p&gt;This one cost me an outage, and it is entirely my own fault for reading the status code as confirmation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /api/v1/conversations/{id}/messages&lt;/code&gt; returns 200 immediately. All it has done is insert a row into &lt;code&gt;public.messages&lt;/code&gt; with &lt;code&gt;status = 0&lt;/code&gt; and no &lt;code&gt;source_id&lt;/code&gt;. The actual delivery to Meta happens later, in a Sidekiq job on the &lt;code&gt;high&lt;/code&gt; queue. The &lt;code&gt;source_id&lt;/code&gt;, which is the WhatsApp message ID, only gets written once Meta acknowledges.&lt;/p&gt;

&lt;p&gt;So a tight send loop looks completely healthy from the client side while it quietly fills a queue that everything else also depends on. Four resend runs kicked off within ninety seconds, roughly thirteen messages a second, pushed 5,108 messages through that endpoint. The &lt;code&gt;high&lt;/code&gt; queue grew to 3,786 jobs with eleven minutes of latency, and every inbound message from an actual paying customer sat behind them.&lt;/p&gt;

&lt;p&gt;Nothing errored. The dashboard just showed a lot of pending clocks, which in Chatwoot means "waiting for delivery receipt" rather than "scheduled" — a distinction I have now confused twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure queue depth, not HTTP status:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;chatwoot-sidekiq-1 bundle &lt;span class="nb"&gt;exec &lt;/span&gt;ruby &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'
  require "sidekiq/api"
  q = Sidekiq::Queue.new("high")
  puts "#{q.size} jobs (latency #{q.latency.round}s)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix that actually held was a backpressure check in my own sender: every 40 sends, count my messages still sitting at &lt;code&gt;source_id IS NULL AND status = 0&lt;/code&gt;. Above 250, pause until it drops under 80, with a 120-second ceiling. That needs no access to Chatwoot's Redis and it measures the right thing, which is the pressure I created rather than global queue depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Deleting an inbox is a Rails-level cascade, and it is silent
&lt;/h2&gt;

&lt;p&gt;During a WhatsApp Business Account migration I deleted an inbox. Here is what went with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;894    conversations
6,160  messages          (471 of them lead replies)
1,776  contact_inboxes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Contacts survive, because they live at the account level. Everything else is gone. This is a &lt;code&gt;dependent: :destroy&lt;/code&gt; cascade in the Rails models, not a database constraint, so nothing in Postgres warns you and there is no confirmation dialog proportionate to what is about to happen.&lt;/p&gt;

&lt;p&gt;The part I did not anticipate: the real blocker afterwards was not the lost conversation history. It was &lt;code&gt;contact_inboxes&lt;/code&gt;. Without those rows, nothing can open a conversation at all — my engine just started returning &lt;code&gt;no WhatsApp contact_inbox&lt;/code&gt; for every contact. Conversation history is nice to have. &lt;code&gt;contact_inboxes&lt;/code&gt; is load-bearing.&lt;/p&gt;

&lt;p&gt;The consolation is that anything you keep in your own schema survives, since it has no foreign keys into Chatwoot's tables. My sequence enrollments came through untouched, so nobody's position in a drip sequence was lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Restoring from backup has three traps that all look like data loss
&lt;/h2&gt;

&lt;p&gt;I had a backup. Restoring it still took most of a day, because of three things that each make it look like the restore failed when it has not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trigger overwrites your &lt;code&gt;display_id&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;conversations_before_insert_row_tr&lt;/code&gt; calls &lt;code&gt;nextval&lt;/code&gt; on a per-account sequence, BEFORE INSERT, so every conversation you inject with an explicit &lt;code&gt;display_id&lt;/code&gt; silently gets a brand new one. Every foreign reference you were trying to preserve detaches. The way through is to insert, then &lt;code&gt;UPDATE ... SET display_id&lt;/code&gt; from a staging table (UPDATE does not fire that trigger), then &lt;code&gt;setval&lt;/code&gt; the sequence to the real maximum.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There are unique indexes that are not constraints.&lt;/strong&gt; They do not appear in &lt;code&gt;pg_constraint&lt;/code&gt;, so if you go looking for what you might collide with, you will not find them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;contact_inboxes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conversations&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conversations&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&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;ON CONFLICT (id) DO NOTHING&lt;/code&gt; sails straight into all three. Use &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; with no target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One skipped row 500s the entire UI.&lt;/strong&gt; A &lt;code&gt;conversations.contact_inbox_id&lt;/code&gt; pointing at a &lt;code&gt;contact_inbox&lt;/code&gt; you did not inject produces &lt;code&gt;undefined method 'source_id' for nil&lt;/code&gt;, which surfaces as an infinite spinner across the whole dashboard rather than a broken single conversation. Remap every orphaned &lt;code&gt;contact_inbox_id&lt;/code&gt; before you declare the restore done.&lt;/p&gt;

&lt;p&gt;Worth knowing: &lt;code&gt;contact_inboxes.source_id&lt;/code&gt; is just the phone number in E.164 without the leading &lt;code&gt;+&lt;/code&gt; (so &lt;code&gt;+972 50 000 0000&lt;/code&gt; becomes &lt;code&gt;972500000000&lt;/code&gt;). It does not depend on which WABA you are on, which means you can rebuild these rows from &lt;code&gt;contacts.phone_number&lt;/code&gt; even with no backup at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: "timeout exceeded when trying to connect" is not your database
&lt;/h2&gt;

&lt;p&gt;The dashboard stopped loading with &lt;code&gt;timeout exceeded when trying to connect&lt;/code&gt;. The same error appeared on my background ticks, which meant sending had stopped for every client. It looked exactly like Postgres falling over. Postgres was fine.&lt;/p&gt;

&lt;p&gt;The cause was one &lt;code&gt;OR&lt;/code&gt; inside one &lt;code&gt;NOT EXISTS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;campaign_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;campaign_id&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
         &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_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;With the &lt;code&gt;OR&lt;/code&gt; in there, Postgres can only hash on &lt;code&gt;campaign_id&lt;/code&gt;. The rest becomes a Join Filter evaluated across every pair in the bucket. On 23K messages against 23K ledger rows that is &lt;code&gt;Rows Removed by Join Filter: 22,371,572&lt;/code&gt; and 125 seconds, to return zero rows. The cost is quadratic in campaign size, so it only detonates for your largest customers.&lt;/p&gt;

&lt;p&gt;The connection pool did the rest. It is &lt;code&gt;max: 5&lt;/code&gt; and shared across every client's API requests plus the background ticks. Four of these queries at once starved it, and everything else died on a 10-second connection timeout. A slow query in one tenant took down every tenant.&lt;/p&gt;

&lt;p&gt;The fix is De Morgan, &lt;code&gt;¬(A∨B) ≡ ¬A ∧ ¬B&lt;/code&gt; — split into two &lt;code&gt;NOT EXISTS&lt;/code&gt;, each with a complete equality condition to hash on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
            &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;campaign_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;campaign_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
                &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;campaign_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;campaign_id&lt;/span&gt;
                  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
                  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;125,366 ms to 381 ms. I verified equivalence with a bidirectional &lt;code&gt;EXCEPT&lt;/code&gt; against production across every account before shipping it, including the one account that actually had legacy rows the filter was there to catch.&lt;/p&gt;

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

&lt;p&gt;Four of these five presented as something other than what they were. A disk alert that was not the database. A 200 that had not sent. A timeout that was not the database either. A restore that looked like it had lost data it had not.&lt;/p&gt;

&lt;p&gt;Self-hosting Chatwoot is genuinely worth it at this scale, and I would make the same call again. But budget your operational attention for the layer between the container and your own code, because that is where all of this lives. Most of what I have learned here came out of running &lt;a href="https://achiya-automation.com/services/whatsapp-bot/" rel="noopener noreferrer"&gt;the WhatsApp automation I build for Israeli businesses&lt;/a&gt; on top of it, which is to say it came out of breaking things in front of paying customers.&lt;/p&gt;

&lt;p&gt;If you are still weighing self-hosting against Cloud, the operational attention above is the real line item — I put it next to the tier prices and the data-retention caps in &lt;a href="https://achiya-automation.com/en/blog/chatwoot-pricing/" rel="noopener noreferrer"&gt;what Chatwoot actually costs to run&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One I have not solved: has anyone found a clean way to get Chatwoot to tell you a message actually reached Meta, without polling &lt;code&gt;source_id&lt;/code&gt; yourself? I would rather subscribe to something than poll a column, and I have not found the hook.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>postgres</category>
      <category>devops</category>
      <category>discuss</category>
    </item>
    <item>
      <title>The Bing Side Door: 87% of ChatGPT's Citations Match Bing's Top 10</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Tue, 18 Aug 2026 09:11:12 +0000</pubDate>
      <link>https://dev.to/achiya-automation/the-bing-side-door-87-of-chatgpts-citations-match-bings-top-10-168c</link>
      <guid>https://dev.to/achiya-automation/the-bing-side-door-87-of-chatgpts-citations-match-bings-top-10-168c</guid>
      <description>&lt;p&gt;Two days ago I published &lt;a href="https://dev.to/achiya-automation/i-logged-every-ai-crawler-for-34-days-chatgpt-outreads-googlebot-369o"&gt;a census of every AI crawler that hit my site over 34 days&lt;/a&gt;. The headline was that ChatGPT now fetches my pages for live users more often than Googlebot crawls them. This post is about the number in that dataset I couldn't stop thinking about — the one that isn't ChatGPT at all.&lt;/p&gt;

&lt;p&gt;It's bingbot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ratio nobody is optimizing for
&lt;/h2&gt;

&lt;p&gt;Fresh numbers from this morning's run (36 days of Caddy access logs, HTTP 200 responses only, for a small business site):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bot&lt;/th&gt;
&lt;th&gt;Hits&lt;/th&gt;
&lt;th&gt;Per day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;bingbot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,488&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;123.3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT-User (live retrieval)&lt;/td&gt;
&lt;td&gt;1,394&lt;/td&gt;
&lt;td&gt;38.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot&lt;/td&gt;
&lt;td&gt;1,073&lt;/td&gt;
&lt;td&gt;29.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot (training)&lt;/td&gt;
&lt;td&gt;505&lt;/td&gt;
&lt;td&gt;13.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;249&lt;/td&gt;
&lt;td&gt;6.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bing's crawler reads my site &lt;strong&gt;4.2 times more&lt;/strong&gt; than Google's. For years I would have read that as wasted crawl budget from the search engine nobody uses — Bing's share of human search traffic barely registers in my analytics.&lt;/p&gt;

&lt;p&gt;Then you put the second fact next to it: &lt;a href="https://www.seerinteractive.com/insights/87-percent-of-searchgpt-citations-match-bings-top-results" rel="noopener noreferrer"&gt;Seer Interactive ran 500+ citations through SearchGPT and found that 87% of them matched Bing's top-10 organic results for the same query&lt;/a&gt;. Only 56% matched Google's top 10.&lt;/p&gt;

&lt;p&gt;ChatGPT's web search doesn't have its own index. When it goes looking for sources to cite, it queries Bing. Those 4,488 bingbot hits aren't for Bing's ten human users — they're the supply chain for every ChatGPT answer that might mention you. Bing stopped being a search engine and became an API for AI answers, and its crawler's appetite finally makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the side door looks like from inside the logs
&lt;/h2&gt;

&lt;p&gt;The chain is visible end to end in my own data. bingbot's most-crawled pages this window are my blog's long-form posts and guides. And the pages ChatGPT-User actually fetches when a human asks something? The same category: my Chatwoot-vs-Intercom comparison got 101 live ChatGPT retrievals in 36 days, and a Hebrew post about WhatsApp spam law got 226 retrievals from Claude-User alone. Comparison pages and definitive guides go in through Bing, come out as citations.&lt;/p&gt;

&lt;p&gt;I run a WhatsApp automation studio in Israel, and this pipeline stopped being theoretical for me in mid-July, when a Google core update cut my Israeli search clicks nearly in half. The traffic that kept arriving through the collapse was exactly this AI-assistant channel — which, per Seer's data, is largely downstream of an index whose webmaster console I had never once opened.&lt;/p&gt;

&lt;p&gt;One more uncomfortable detail from the earlier post: when I compared server logs to Google Analytics, GA4 saw about 11% of the visitors the logs saw. AI-referred and privacy-shielded traffic is mostly invisible to your dashboard. If you only look at GA4, this entire channel doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical checklist
&lt;/h2&gt;

&lt;p&gt;If 87% of ChatGPT citations come from Bing's top 10, then "rank in Bing" is now a growth channel with almost no competition, because everyone else is still fighting over Google. What I've changed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Stop treating bingbot as a nuisance.&lt;/strong&gt; Plenty of robots.txt files and WAF rules throttle or block bingbot to "save crawl budget." That's now equivalent to blocking your ChatGPT visibility at the front door. Check yours before anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Register in Bing Webmaster Tools.&lt;/strong&gt; It takes minutes (you can import your verified site straight from Google Search Console) and it's the only place you'll see how Bing actually indexes you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Ship IndexNow.&lt;/strong&gt; Bing supports instant push indexing — a tiny API ping on every publish instead of waiting for a crawl. For a channel where citations follow the index, freshness is free ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Feed it the content type the pipeline rewards.&lt;/strong&gt; In my logs, what gets retrieved for live users is comparisons ("X vs Y"), pricing breakdowns, and complete guides — pages that answer a full question. Listicle fragments don't get cited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Measure in your logs, not your analytics.&lt;/strong&gt; Thirty seconds on any server:&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;# bingbot vs Googlebot, raw hit counts&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'bingbot'&lt;/span&gt; access.log
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'Googlebot'&lt;/span&gt; access.log

&lt;span class="c"&gt;# which pages bingbot actually wants&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'bingbot'&lt;/span&gt; access.log | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $7}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Adjust the &lt;code&gt;$7&lt;/code&gt; field for your log format; mine is Caddy JSON piped through a small parser.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;I spent years doing SEO for an audience of humans on Google. The &lt;a href="https://achiya-automation.com/services/whatsapp-bot/" rel="noopener noreferrer"&gt;team behind 50+ WhatsApp bot deployments&lt;/a&gt; that pays my bills gets found today by a different reader: a retrieval bot working for an AI that a buyer asked in natural language. That reader enters through Bing, and almost nobody is holding the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update (19 Aug): I checked, and part of this post is wrong
&lt;/h2&gt;

&lt;p&gt;A commenter, &lt;a href="https://dev.to/bulti_global"&gt;@bulti_global&lt;/a&gt;, pushed back on turning the 87% overlap into "citations follow the Bing index," and asked a sharp question: are there pages that rank in Bing's top 10 but never trigger a retrieval? He suggested splitting the funnel page by page instead of trusting the aggregate bot ratio.&lt;/p&gt;

&lt;p&gt;Answering it forced me to open Bing Webmaster Tools — where I found an account I'd forgotten I had, holding 91 days of data. Two corrections follow from that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, I overstated my own ignorance.&lt;/strong&gt; I wrote that this ran through "an index I had never once logged into." I had registered; I had just never looked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, and worse for my thesis: at page level, Bing rank does not predict AI citation.&lt;/strong&gt; Same site, same 91-day window (19 May – 17 Aug). Bing organic delivered 650 impressions and 4 clicks. AI citations over that window: 81.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Bing impressions&lt;/th&gt;
&lt;th&gt;Avg position&lt;/th&gt;
&lt;th&gt;AI citations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;whatsapp-bot-platforms-comparison-2026&lt;/td&gt;
&lt;td&gt;169&lt;/td&gt;
&lt;td&gt;4.48&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;62&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whatsapp-automation-guide&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;4.00&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whatsapp-bot-for-business&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;4.20&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;free-whatsapp-bot-guide&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;5.87&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pricing&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;4.32&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whatsapp-bot-clinics&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;3.28&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whatsapp-bot-for-gyms&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;2.81&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My best-ranking page in Bing — average position 2.81 — has never been cited. Neither have 3.28, 4.00 or 4.32. The page that takes 62 of the 81 citations ranks &lt;em&gt;worse&lt;/em&gt; than four pages that were never cited once, and one page picked up 7 citations without appearing in my top-14 by impressions at all.&lt;/p&gt;

&lt;p&gt;What separates the winner looks like format, not position: it is the only head-to-head "X vs Y vs Z" comparison table on the site. Item 4 of the checklist below survives. The implicit "rank in Bing and citations follow" does not — not at page level, not on this sample.&lt;/p&gt;

&lt;p&gt;One caveat on the numbers, because it matters: Bing labels that report "Microsoft Copilots and Partners." It measures the Microsoft AI surface, not ChatGPT specifically. It is adjacent to the claim, not identical to it.&lt;/p&gt;

&lt;p&gt;Registering still turned out to be the single highest-value thing in this post — just not for the reason I gave. It is where you find out that your best-ranking page is invisible to the answer engines.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Run the grep above on your own logs and tell me your bingbot-to-Googlebot ratio in the comments — I'm collecting data points for a follow-up post. Mine is 4.2:1. If yours is under 1:1, I'd especially like to know what kind of site it is.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>chatgpt</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I found code in my repo I'd never seen. All 82 tests passed. I quarantined it for three days anyway.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 17 Aug 2026 03:22:43 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-found-code-in-my-repo-id-never-seen-all-82-tests-passed-i-quarantined-it-for-three-days-anyway-33go</link>
      <guid>https://dev.to/achiya-automation/i-found-code-in-my-repo-id-never-seen-all-82-tests-passed-i-quarantined-it-for-three-days-anyway-33go</guid>
      <description>&lt;p&gt;During a routine morning triage of my open-source project, &lt;code&gt;git status&lt;/code&gt; showed a modified file I had no memory of touching: &lt;code&gt;extension/background.js&lt;/code&gt;, last modified 24 hours earlier, sitting next to a fresh &lt;code&gt;background.js.bak&lt;/code&gt; someone had thoughtfully left behind.&lt;/p&gt;

&lt;p&gt;Nobody broke in. I run several AI coding sessions in parallel against the same machine, and one of them — working on a completely different task, automating a GoHighLevel workflow — had hit a limitation in my browser automation tool, &lt;em&gt;fixed the tool itself&lt;/em&gt;, verified the fix, and then moved on with its actual job. It never committed. It never told anyone. It just left better code in my working tree and walked away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diff was good. That was the problem.
&lt;/h2&gt;

&lt;p&gt;The change itself was a real feature. My &lt;code&gt;query_all&lt;/code&gt; tool (it queries DOM elements across a page) stopped at the main frame: if the elements you wanted lived inside a cross-origin iframe, you got back a clean, confident, empty array. The uncommitted diff added an &lt;code&gt;execAcrossFrames()&lt;/code&gt; helper that runs the query in every frame and merges the results, plus &lt;code&gt;x&lt;/code&gt;/&lt;code&gt;y&lt;/code&gt;/&lt;code&gt;frame&lt;/code&gt; fields on each returned element.&lt;/p&gt;

&lt;p&gt;I verified it the way you'd verify anything: syntax check passed, and the full test suite — all 82 tests — ran green &lt;em&gt;with the change in place&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So: useful feature, my own repository, every signal green. Everything about the situation said &lt;strong&gt;commit it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I didn't. I wrote it up in my project log, left the file untouched, and set an explicit deadline: if it's still sitting there uncommitted in three days, evaluate it properly — upstream it or revert it and file an issue. Not "leave it and see," which is how working trees rot. A quarantine with no release date is just a junk drawer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why quarantine green code?
&lt;/h2&gt;

&lt;p&gt;Two reasons, and neither is paranoia.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: authorship isn't verification.&lt;/strong&gt; The session that wrote this code had context I didn't have. Maybe it was mid-iteration and the diff was half of a plan. Maybe the &lt;code&gt;.bak&lt;/code&gt; file meant it intended to roll back. Committing someone's work-in-progress freezes it at a moment &lt;em&gt;they&lt;/em&gt; didn't choose. The fact that the "someone" was technically me, in another window, changes nothing — I had none of that session's context. A diff you don't remember writing is a stranger's diff. The stranger being you is a detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: green tests measure what you thought to test.&lt;/strong&gt; My suite passed because nothing in it asserted anything about cross-origin frames — the tests were blind to the change, not endorsing it. "All tests pass" and "no test looked" produce the same green checkmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 3: review it like a stranger's pull request
&lt;/h2&gt;

&lt;p&gt;The deadline arrived, the diff hadn't moved, so I did what I'd do with an external PR from an unknown contributor: re-ran everything (still 82/82), then read the semantics line by line instead of trusting the vibe.&lt;/p&gt;

&lt;p&gt;And there it was — the one bug no test could have caught, exactly where I'd felt vague unease on day 0. The code's comments claimed the new &lt;code&gt;x&lt;/code&gt;/&lt;code&gt;y&lt;/code&gt; coordinates were page-level. They aren't. Each element's coordinates are relative to &lt;em&gt;its own frame's viewport&lt;/em&gt;. For main-frame elements that's the same thing; for an element inside a cross-origin iframe, it's iframe-relative — so a caller who took those numbers and clicked at that position on the page would click the wrong spot. Silently. Only on the exact pages this feature was built for.&lt;/p&gt;

&lt;p&gt;The code was right; its &lt;em&gt;claim about itself&lt;/em&gt; was wrong. That's a documentation bug today and a caller's logic bug tomorrow. Fix: correct the comment to state the real coordinate space, and document that callers must offset using the &lt;code&gt;frame&lt;/code&gt; field the diff had already (presciently) added.&lt;/p&gt;

&lt;p&gt;Then it graduated: comment fixed, changelog written, committed as a proper feature with attribution to the context it came from, the &lt;code&gt;.bak&lt;/code&gt; file byte-compared against git history and deleted, and released in v2.16.0. The quarantine didn't slow the feature down. It shipped three days later than "immediately" — and shipped &lt;em&gt;correct&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I keep from this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A diff you don't remember is untrusted input&lt;/strong&gt;, even in your own tree, even green. Multi-agent workflows make this a weekly event, not a freak accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never commit a parallel session's WIP on discovery.&lt;/strong&gt; You're freezing someone else's half-thought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarantine needs a deadline.&lt;/strong&gt; "I'll look at it later" is how repos accumulate mystery files. Name the day and the two exits: upstream or revert-and-file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On review day, read the claims, not just the code.&lt;/strong&gt; The only real bug was in a comment — a semantic promise no test asserted and no linter checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The uncomfortable part: five years ago, "unexplained code appeared in my working tree overnight" meant your laptop was compromised. Now it means Tuesday. The tooling caught up with writing code faster than our habits caught up with &lt;em&gt;receiving&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you handle this?&lt;/strong&gt; If you run parallel AI sessions — or just share a repo with your past self — what's your protocol when you find a diff you don't remember writing? Commit-if-green, revert-on-sight, or something in between?&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Logged Every AI Crawler for 34 Days. ChatGPT Outreads Googlebot</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 16 Aug 2026 09:40:07 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-logged-every-ai-crawler-for-34-days-chatgpt-outreads-googlebot-369o</link>
      <guid>https://dev.to/achiya-automation/i-logged-every-ai-crawler-for-34-days-chatgpt-outreads-googlebot-369o</guid>
      <description>&lt;p&gt;In mid-July, my Google clicks in my home market (Israel) dropped by almost half. Buyer-intent queries that used to bring steady leads just evaporated from Search Console. While I was staring at GSC dashboards trying to figure out what broke, I finally did the thing I should have done months earlier: I stopped looking at dashboards and started reading raw server logs.&lt;/p&gt;

&lt;p&gt;What I found there was a parallel universe. Google Search was sending me less than ever — but AI systems were reading my site &lt;em&gt;constantly&lt;/em&gt;. Not "someday this will matter" constantly. Right-now constantly: an AI assistant was fetching a page of mine roughly every 26 minutes, around the clock, because a real human had just asked it a question.&lt;/p&gt;

&lt;p&gt;So I built a small log analyzer and let it run. Here's what 34 days of complete Caddy logs from a small business site (about 70 real human visitors a day) actually look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;All counts are HTTP 200 responses only (more on why below), over 34 days:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bot&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Per day&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bingbot&lt;/td&gt;
&lt;td&gt;5,444&lt;/td&gt;
&lt;td&gt;158.2&lt;/td&gt;
&lt;td&gt;Bing's index — which feeds ChatGPT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ChatGPT-User&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,388&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Live fetch while a human asks ChatGPT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot&lt;/td&gt;
&lt;td&gt;1,233&lt;/td&gt;
&lt;td&gt;35.8&lt;/td&gt;
&lt;td&gt;Classic Google crawl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;547&lt;/td&gt;
&lt;td&gt;15.9&lt;/td&gt;
&lt;td&gt;OpenAI training crawler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude-User&lt;/td&gt;
&lt;td&gt;519&lt;/td&gt;
&lt;td&gt;15.1&lt;/td&gt;
&lt;td&gt;Live fetch while a human asks Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;281&lt;/td&gt;
&lt;td&gt;8.2&lt;/td&gt;
&lt;td&gt;ChatGPT search indexing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applebot&lt;/td&gt;
&lt;td&gt;268&lt;/td&gt;
&lt;td&gt;7.8&lt;/td&gt;
&lt;td&gt;Apple (Siri / Apple Intelligence)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;214&lt;/td&gt;
&lt;td&gt;6.2&lt;/td&gt;
&lt;td&gt;Anthropic training crawler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazonbot&lt;/td&gt;
&lt;td&gt;136&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;Amazon (Alexa &amp;amp; co.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;Perplexity indexing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things in that table genuinely surprised me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ChatGPT-User outreads Googlebot.&lt;/strong&gt; 40.3 fetches a day versus 35.8. This isn't a crawler building an index for later — &lt;code&gt;ChatGPT-User&lt;/code&gt; is the user-agent OpenAI sends when a human is &lt;em&gt;mid-conversation&lt;/em&gt; and ChatGPT decides to pull a live page to answer them. On my site, that now happens more often than Googlebot visits. For a tiny business site in a niche market, I did not expect that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bing crawls 4.4x harder than Google.&lt;/strong&gt; 158 requests a day versus 36. Nobody optimizes for Bing. Bing's referral traffic is a rounding error. But &lt;a href="https://www.seerinteractive.com/insights/87-percent-of-searchgpt-citations-match-bings-top-results" rel="noopener noreferrer"&gt;Seer Interactive found that 87% of SearchGPT's citations match Bing's top organic results&lt;/a&gt; (versus 56% for Google) — Bing's index is a structural input to what ChatGPT can retrieve and cite. Suddenly that 4.4:1 ratio reads very differently: the "irrelevant" search engine is the supply chain for the assistant that's actually fetching my pages 40 times a day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live retrieval is the biggest AI bucket that matters.&lt;/strong&gt; Grouping by &lt;em&gt;meaning&lt;/em&gt; rather than by bot name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔴 Live user retrieval (a human asked right now)   1,907  (~56/day)
🔎 AI-search indexing (OAI-SearchBot, Perplexity,
   bingbot, Applebot)                               6,096  (~177/day)
📚 Training crawls (GPTBot, ClaudeBot, Amazonbot)     897  (~26/day)
🌐 Classic Google                                   1,233  (~36/day)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1,907 live retrievals in 34 days. Each one of those is a moment where a real person asked an AI assistant something, and the assistant chose &lt;em&gt;my&lt;/em&gt; site as a source. That's the leading indicator for citations — and it's the number I now track weekly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they actually read (this is the useful part)
&lt;/h2&gt;

&lt;p&gt;Per-bot top pages turned out to be more interesting than the totals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT-User&lt;/strong&gt; loves the homepage (115 fetches), a WhatsApp automation guide (108), and — third place — &lt;a href="https://achiya-automation.com/en/blog/whatsapp-bot-pricing-guide-2026/" rel="noopener noreferrer"&gt;our WhatsApp bot pricing guide&lt;/a&gt;. People are asking ChatGPT what a WhatsApp bot costs, and ChatGPT is pulling my pricing page to answer them. That's a sales conversation I'm part of without knowing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude-User&lt;/strong&gt; is obsessed with exactly one post: a spam-detection write-up got 233 of its 519 total fetches — 45% of everything Claude reads on my site is one article. One strong, specific technical post apparently becomes a go-to source for one assistant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPTBot&lt;/strong&gt; (the &lt;em&gt;training&lt;/em&gt; crawler) spends its time on &lt;code&gt;/signin&lt;/code&gt; and &lt;code&gt;/forgot-password&lt;/code&gt;. Twelve visits to my login page. Enjoy the training data, I guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perplexity-User: zero.&lt;/strong&gt; PerplexityBot indexes me (103 requests), but not a single live user-triggered retrieval in 34 days. Every assistant has its own taste in sources, and Perplexity's taste apparently doesn't include me. Yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Methodology, and the trap that almost ruined the data
&lt;/h2&gt;

&lt;p&gt;The analyzer is ~60 lines of Python that runs over Caddy's JSON access logs. The core is just user-agent patterns with one ordering rule — the &lt;code&gt;*-User&lt;/code&gt; patterns must be matched &lt;em&gt;before&lt;/em&gt; the generic bot patterns, because the loop stops at first match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BOTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ChatGPT-User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ChatGPT-User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# live retrieval
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claude-User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claude-User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;    &lt;span class="c1"&gt;# live retrieval
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Perplexity-User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Perplexity-User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OAI-SearchBot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OAI-SearchBot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPTBot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPTBot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;          &lt;span class="c1"&gt;# training
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PerplexityBot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Perplexity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# ... bingbot, Googlebot, ClaudeBot, Applebot, Amazonbot
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap: &lt;strong&gt;count only HTTP 200s.&lt;/strong&gt; My first version counted every request, and the numbers were inflated with garbage — security scanners spoof OpenAI user-agents (a scanner claiming to be GPTBot while probing for &lt;code&gt;/wp-admin&lt;/code&gt; on a site that doesn't run WordPress) and land on 404s. Filtering to 200-only removed the impostors and left actual content fetches. If you run this on your own logs and skip that filter, your "AI traffic" will be part vulnerability scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your analytics can't see any of this
&lt;/h2&gt;

&lt;p&gt;None of these 8,900 AI-related requests appear in GA4. No JavaScript execution, no consent banner click, no pageview event. And it's worse than that: on my site, GA4 undercounts &lt;em&gt;humans&lt;/em&gt; by roughly 9x too — only ~11% of visitors accept the cookie banner (I measured this two independent ways over 11 days: ~780 real browser visitors in the logs and widget-loads, 89 in GA4).&lt;/p&gt;

&lt;p&gt;So the fastest-growing reader of my site — AI assistants answering buyer questions in real time — is completely invisible unless you read server logs. If your hosting gives you raw access logs, that's where this story lives. If it doesn't, you can't see it at all, and you might be making content decisions while blind to a quarter of your real readership.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing differently now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Watching &lt;code&gt;*-User&lt;/code&gt; agents weekly&lt;/strong&gt;, not rankings daily. Live retrievals are the citation pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taking Bing seriously for the first time in my life.&lt;/strong&gt; Bing Webmaster Tools went from "never opened it" to the top of this week's list — if 87% of ChatGPT citations flow through Bing's index, it's the cheapest GEO lever there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing more single-topic, specific pages.&lt;/strong&gt; Claude's 233-fetch obsession with one spam-detection post convinced me: assistants latch onto pages that answer one question thoroughly, not pillar pages that answer twelve questions shallowly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Now I want your denominator.&lt;/strong&gt; Pull one month of access logs and count &lt;code&gt;bingbot&lt;/code&gt; vs &lt;code&gt;Googlebot&lt;/code&gt; hits — my ratio is 4.4:1. If you run a small site, especially a non-English one, I'd genuinely like to know whether that ratio holds outside my niche: drop your two numbers (and your site's language) in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I said my tool supported multiple instances. A stranger running 15 of them filed three bugs. All three were right.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 13 Aug 2026 08:20:51 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-said-my-tool-supported-multiple-instances-a-stranger-running-15-of-them-filed-three-bugs-all-3egi</link>
      <guid>https://dev.to/achiya-automation/i-said-my-tool-supported-multiple-instances-a-stranger-running-15-of-them-filed-three-bugs-all-3egi</guid>
      <description>&lt;p&gt;I maintain &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, an MCP server that lets AI agents drive a real Safari session on macOS. Over a few releases this summer I shipped per-session tab isolation — the feature that lets several agents share one browser without stealing each other's tabs. I wrote about it here. Twice. I was reasonably proud of it.&lt;/p&gt;

&lt;p&gt;Then a stranger showed up with a deployment I had never imagined — three macOS profiles, up to fifteen concurrent server instances — and filed three bug reports in the space of seven seconds.&lt;/p&gt;

&lt;p&gt;All three were right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reports
&lt;/h2&gt;

&lt;p&gt;What made them remarkable wasn't just the hit rate. It was the form. Each issue had a description of the deployment, a repro, and observed data from their own machines. No speculation, no "it feels flaky." They had read the code, formed a hypothesis about where it breaks at their scale, and then watched it break exactly there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: the memory guard that guarded nothing.&lt;/strong&gt; The server has a WebKit memory monitor that sweeps idle tabs before Safari's content process balloons. It also has a gate: only the instance acting as "extension host" — in practice, whichever instance won the race for port 9224 — was allowed to sweep, and only its own tabs. In a fifteen-instance deployment, that means fourteen instances accumulate tabs forever and one instance politely cleans up after itself. The guard was effectively inert precisely in the topology where memory pressure is worst.&lt;/p&gt;

&lt;p&gt;The embarrassing part: the gate was redundant. There was already a lock file guaranteeing one sweeper per cycle. Someone (me) had added a second safety mechanism on top of an existing one, and the second one broke the feature. The fix was a deletion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: the ownership file that ate its own entries.&lt;/strong&gt; Instances record which tabs they own in a shared JSON file. The save path was read-at-startup, modify-in-memory, overwrite-on-save. Classic. With one instance, harmless. With fifteen, every save was a snapshot overwrite that silently dropped whatever the other fourteen had written in the meantime.&lt;/p&gt;

&lt;p&gt;Note what this bug &lt;em&gt;doesn't&lt;/em&gt; do: it doesn't crash, it doesn't touch the wrong tab, it doesn't log anything. Losing an ownership entry is fail-safe — a tab just stops being tracked. Which is exactly why it survived. Bugs that fail loudly get fixed in week one. Bugs that fail &lt;em&gt;politely&lt;/em&gt; wait for someone to run fifteen instances and notice entries vanishing.&lt;/p&gt;

&lt;p&gt;The fix was merge-on-write: union the disk state with local state, newest timestamp wins, with explicit removal deltas so deletions don't get resurrected by a stale peer. I considered a file lock and deliberately skipped it — the residual race window is sub-millisecond and fail-safe, and a lock adds a failure mode of its own. That tradeoff is written down in the code, next to the merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3: the poll that never backed off.&lt;/strong&gt; When a profile's Safari window is closed, the server polls for it to reappear — a fixed 3-second interval spawning an osascript subprocess each time. Forever. Their logs showed the consequence: roughly 1,200 log lines and 1,200 subprocess spawns per hour, per idle instance. Multiply by fifteen.&lt;/p&gt;

&lt;p&gt;The fix is the boring one you already know: self-scheduling backoff, 3s doubling to a 60s cap, reset on success. While fixing it I found a bonus bug the report hadn't mentioned — a trace log being written to &lt;code&gt;__dirname&lt;/code&gt;, which for an npm package means &lt;em&gt;inside node_modules&lt;/em&gt;. That file had been quietly growing inside an installed package directory the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your test topology is a claim about the world, and nobody peer-reviews it.&lt;/strong&gt; I had tested multiple instances — on one profile, launched from one place, in small numbers. The reporter ran three profiles times five. Every one of the three bugs lived precisely in the gap between my topology and theirs. Not one was visible in mine. The tests were green the entire time; the suite passed 78/78 the morning the reports came in. Green tests verify the world you thought to simulate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail-safe failures are the long-lived ones.&lt;/strong&gt; All three bugs share a property: the system kept working. Memory got worse slowly. Entries vanished silently. Subprocesses spawned invisibly. If any of them had thrown an exception, they'd have died in code review. Design your failures to be loud, or accept that the quiet ones will be found by a stranger, years later, at a scale you didn't test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sometimes the fix is a deletion.&lt;/strong&gt; The memory-guard bug existed because two safety mechanisms overlapped and the newer one strangled the feature. I didn't need to write cleverer coordination code — I needed to remove the redundant half. The diff that fixed the scariest-sounding of the three bugs was net negative lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good bug report at foreign scale is free QA you could not have bought.&lt;/strong&gt; I could not have afforded to stand up a fifteen-instance, three-profile deployment as a test rig for a feature I wasn't sure anyone used that hard. Someone built it because they needed it, then handed me the findings with repros. The only correct responses are fast verification, honest credit, and shipping the fix. All three fixes went out in one release the next day, each issue closed with a reply documenting not just what changed but which alternatives I rejected and why — including the lock I chose not to add.&lt;/p&gt;

&lt;p&gt;That last part matters more than it looks. The reporter now knows the merge-on-write has a known, bounded, fail-safe race. The next person who hits something weird there won't start from zero — the reasoning is in the thread, not in my head.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable question
&lt;/h2&gt;

&lt;p&gt;The feature worked. The story I told about it was, at my scale, true. It took someone else's production topology to reveal that "supports multiple instances" was really "supports the multiple-instance shapes I happened to try."&lt;/p&gt;

&lt;p&gt;What's the biggest gap between your test topology and a user's real deployment that you've been bitten by — and did the bug fail loudly or politely?&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>An invisible character broke a security patch. Then it broke my review. Then it broke my review of the fix.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:15:27 +0000</pubDate>
      <link>https://dev.to/achiya-automation/an-invisible-character-broke-a-security-patch-then-it-broke-my-review-then-it-broke-my-review-of-444c</link>
      <guid>https://dev.to/achiya-automation/an-invisible-character-broke-a-security-patch-then-it-broke-my-review-then-it-broke-my-review-of-444c</guid>
      <description>&lt;p&gt;My open-source project, &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, got a security-hardening pull request from an automated scanner. The idea behind the patch was right. The patch itself un-parsed the codebase. Then the same defect infected my review of the patch, my review of the fix, and eventually the private note I wrote to remind myself to stop letting this happen.&lt;/p&gt;

&lt;p&gt;This is a story about one character, told in three acts, and it ends with a rule I now apply to everything I publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The character
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;U+2028 LINE SEPARATOR&lt;/code&gt; (and its sibling &lt;code&gt;U+2029 PARAGRAPH SEPARATOR&lt;/code&gt;) is a legal Unicode character that most terminals and editors render as a space, or as nothing at all.&lt;/p&gt;

&lt;p&gt;In JavaScript it has two sharp edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In strings&lt;/strong&gt;, it counts as a line terminator. If you build a single-quoted string by escaping only quotes and backslashes, a raw &lt;code&gt;U+2028&lt;/code&gt; in the payload can terminate the literal early. That is a classic injection vector, and it is what the pull request set out to fix. Legitimate concern, real hardening value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In regex literals&lt;/strong&gt;, the grammar forbids line terminators outright. A raw &lt;code&gt;U+2028&lt;/code&gt; between the slashes of &lt;code&gt;/.../&lt;/code&gt; is not a weird regex. It is a &lt;code&gt;SyntaxError&lt;/code&gt; at parse time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep both edges in mind. The first one motivated the patch. The second one is what the patch shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: the patch that un-parsed the codebase
&lt;/h2&gt;

&lt;p&gt;The PR extended my escaping helper to also escape &lt;code&gt;\r&lt;/code&gt;, &lt;code&gt;\n&lt;/code&gt;, &lt;code&gt;U+2028&lt;/code&gt; and &lt;code&gt;U+2029&lt;/code&gt;. Seven lines added, one changed. The problem: the new &lt;code&gt;.replace()&lt;/code&gt; calls embedded the &lt;strong&gt;raw characters&lt;/strong&gt; inside regex literals, instead of the escape sequences &lt;code&gt;\u2028&lt;/code&gt; and &lt;code&gt;\u2029&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My terminal renders &lt;code&gt;U+2028&lt;/code&gt; as a space. So the diff &lt;em&gt;looked&lt;/em&gt; like it said &lt;code&gt;.replace(/ /g, ...)&lt;/code&gt; — replace every space. That would be a different catastrophic bug. The truth was worse in a more interesting way: the file no longer parsed at all. &lt;code&gt;node --check&lt;/code&gt; failed with a &lt;code&gt;SyntaxError&lt;/code&gt;. The main server file imports that module, so the entire MCP server failed to start.&lt;/p&gt;

&lt;p&gt;A security patch that prevents the server from running is, to be fair, extremely secure.&lt;/p&gt;

&lt;p&gt;What caught it was not reading the code. It was refusing to trust rendering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python &lt;code&gt;repr()&lt;/code&gt; on the raw diff showed the literal characters.&lt;/li&gt;
&lt;li&gt;A fun tell: Python's &lt;code&gt;splitlines()&lt;/code&gt; splits on &lt;code&gt;U+2028&lt;/code&gt;, so a "one-line" regex was quietly two lines to Python.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node --check&lt;/code&gt; turned suspicion into proof.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote all of that up in a &lt;code&gt;REQUEST_CHANGES&lt;/code&gt; review, with a corrected snippet showing the right approach: put the ASCII escape sequence &lt;code&gt;\u2028&lt;/code&gt; in the source, never the raw character.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: my review had the same bug
&lt;/h2&gt;

&lt;p&gt;I composed that review body in a shell heredoc. Heredocs pass Unicode through byte-for-byte. The corrected snippet I posted — the one teaching the contributor to use escape sequences — was posted &lt;strong&gt;with the raw characters in it&lt;/strong&gt;, exactly where the ASCII text &lt;code&gt;\u2028&lt;/code&gt; was supposed to appear.&lt;/p&gt;

&lt;p&gt;The review explaining the invisible-character bug contained the invisible-character bug.&lt;/p&gt;

&lt;p&gt;I only caught it because, after posting, I fetched my own review body back through the API and ran &lt;code&gt;repr()&lt;/code&gt; on it. Then I rebuilt the body in Python, constructing the escape-sequence text from character codes instead of typing it, and patched the review via the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: the fix was perfect. My approval was not.
&lt;/h2&gt;

&lt;p&gt;The contributor fixed everything properly: escape sequences in the regex literals, a regression test that locks the full escaping recipe (backslash, quote, &lt;code&gt;\r&lt;/code&gt;, &lt;code&gt;\n&lt;/code&gt;, &lt;code&gt;U+2028&lt;/code&gt;, &lt;code&gt;U+2029&lt;/code&gt; — in that order, so order drift fails the suite), and a documented behavior change. I verified it in a fresh clone at byte level. Full test suite green. I approved.&lt;/p&gt;

&lt;p&gt;My approval review quoted those escape sequences. It posted with raw &lt;code&gt;U+2028&lt;/code&gt; in it. Again. Same failure, same detection (re-fetch, &lt;code&gt;repr()&lt;/code&gt;), same API patch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Epilogue: it kept going
&lt;/h2&gt;

&lt;p&gt;The status row I wrote about the incident, in my own tracking file? Raw &lt;code&gt;U+2028&lt;/code&gt; in it.&lt;/p&gt;

&lt;p&gt;The personal rule file I then wrote — literally titled "byte-verify anything that discusses this character" — was written to disk &lt;strong&gt;with two raw &lt;code&gt;U+2028&lt;/code&gt; bytes in it&lt;/strong&gt;. The note about the disease was a carrier.&lt;/p&gt;

&lt;p&gt;Four artifacts in two days. Every one of them was &lt;em&gt;about&lt;/em&gt; the defect. Every one of them contained it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;Text that discusses a character tends to contain that character. When I write about &lt;code&gt;U+2028&lt;/code&gt;, some layer in the pipeline — text generation, a heredoc, an editor buffer, a clipboard — will happily emit the real code point where I intended the six ASCII characters that name it. Every layer treats it as ordinary printable Unicode and passes it through. Every renderer displays it as nothing.&lt;/p&gt;

&lt;p&gt;So there is no natural checkpoint where the defect becomes visible. The character is only "visible" in a byte-level representation: &lt;code&gt;repr()&lt;/code&gt;, a hex dump, an assertion. If your verification is "I looked at it and it looked fine," you have verified nothing — for this class of bug, &lt;em&gt;looking&lt;/em&gt; is the one sense that cannot detect it.&lt;/p&gt;

&lt;p&gt;It has a quine-like quality that I find genuinely funny now, several patches later: the document about the virus carries the virus.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules that survived
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never trust rendering for invisible characters.&lt;/strong&gt; Verify bytes: &lt;code&gt;repr()&lt;/code&gt; in Python, &lt;code&gt;xxd&lt;/code&gt;, &lt;code&gt;grep -P&lt;/code&gt; with the code point. Eyes are the wrong instrument.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compose text about escape characters programmatically.&lt;/strong&gt; I now build the string "\u2028" as &lt;code&gt;chr(92) + "u2028"&lt;/code&gt; in a script. I do not type it into anything that will be published.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert before posting.&lt;/strong&gt; &lt;code&gt;assert chr(0x2028) not in body&lt;/code&gt; costs one line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-fetch after posting and verify the published artifact.&lt;/strong&gt; The pipeline between you and the platform can preserve what you thought you had stripped. Verify what readers actually see, not what you sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own artifacts are in scope.&lt;/strong&gt; Byte-checking a stranger's diff is the obvious part. The review of the diff, the changelog entry, the tracking note, the article — those are artifacts too, and they are &lt;em&gt;more&lt;/em&gt; likely to carry the character, because they talk about it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The meta part
&lt;/h2&gt;

&lt;p&gt;This article discusses &lt;code&gt;U+2028&lt;/code&gt; roughly thirty times. It was generated by a script that constructs every single mention from character codes and asserts that the raw bytes are absent before the file is written. After publishing, I fetched the live body back and byte-verified it.&lt;/p&gt;

&lt;p&gt;Based on my track record of the last two days: if I had skipped any of that, you would right now be reading an article about an invisible character, carrying the invisible character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the nastiest invisible-character bug you have hit — and did your tooling show it to you, or did you find it by accident?&lt;/strong&gt; Zero-width joiners in usernames, BOMs at the top of config files, non-breaking spaces in YAML — I want to hear the war stories.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I gave a user the right answer with the wrong mechanism. They caught it within a day.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Tue, 04 Aug 2026 08:17:36 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-gave-a-user-the-right-answer-with-the-wrong-mechanism-he-caught-it-within-a-day-2k41</link>
      <guid>https://dev.to/achiya-automation/i-gave-a-user-the-right-answer-with-the-wrong-mechanism-he-caught-it-within-a-day-2k41</guid>
      <description>&lt;p&gt;Last week I wrote about &lt;a href="https://dev.to/achiya-automation/a-user-spent-four-days-designing-a-feature-for-my-project-the-right-answer-was-zero-lines-of-code-bff"&gt;saying no to a feature request&lt;/a&gt;: a user designed a &lt;code&gt;laneId&lt;/code&gt; mode for my project over four days, and the right answer turned out to be zero lines of code, because they ended up shipping the fix themselves — in their own layer.&lt;/p&gt;

&lt;p&gt;That post was accurate. The decision was right. The code that shipped was right.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;explanation&lt;/em&gt; I gave them for why a particular setup would work — the one I wrote with full confidence, in public, on the issue thread — was wrong. Not completely wrong. Wrong in the more embarrassing way: one layer off.&lt;/p&gt;

&lt;p&gt;They caught it within a day of my closing reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim
&lt;/h2&gt;

&lt;p&gt;Quick context: I maintain &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, an MCP server that gives AI agents a real Safari session on macOS. The whole thread (&lt;a href="https://github.com/achiya-automation/safari-mcp/issues/76" rel="noopener noreferrer"&gt;#76&lt;/a&gt;) was about running &lt;em&gt;multiple&lt;/em&gt; agents against one Safari without them stealing each other's tabs.&lt;/p&gt;

&lt;p&gt;My server already had the hard part: in HTTP daemon mode, every client gets its own &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, and the server keys all tab state — active tab, ownership markers, the works — off that session. Two clients on one daemon cannot see each other's tab pointer. I had shipped a real fix for a real bug in that mechanism days earlier. Tests, verification, release notes. That layer was solid, and I knew it was solid.&lt;/p&gt;

&lt;p&gt;The user runs their agents through &lt;a href="https://github.com/openclaw/mcporter" rel="noopener noreferrer"&gt;mcporter&lt;/a&gt;, a runner that manages MCP servers for multiple agent sessions. They'd found mcporter's own isolation knobs — a separate daemon directory per agent — and asked whether that would cover them.&lt;/p&gt;

&lt;p&gt;Here's what I told them, nearly verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Separate daemon dir per agent + stdio transport = isolation by process boundary, but you're back to N Safari-controller processes.&lt;br&gt;
Separate daemon dir per agent + &lt;strong&gt;HTTP transport = the winning combo&lt;/strong&gt; — each mcporter daemon holds its own MCP client, so each gets its own &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, so my per-session isolation engages. One process, full isolation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Clean analysis. Two topologies, one recommendation, a confident bolded phrase. I framed their lane concern as something you only need on the stdio path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correction
&lt;/h2&gt;

&lt;p&gt;They built and published their fix (&lt;a href="https://pi.dev/packages/mcporter-lanes" rel="noopener noreferrer"&gt;mcporter-lanes&lt;/a&gt;), I credited it, updated my README, wrote the blog post, closed the loop. Then, about an hour after my closing reply, they posted again — politely, precisely:&lt;/p&gt;

&lt;p&gt;With mcporter in the middle, lanes are needed &lt;strong&gt;regardless of transport&lt;/strong&gt;. Because mcporter does one of two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Caches one MCP client and reuses it for every agent session.&lt;/strong&gt; My server sees exactly one &lt;code&gt;Mcp-Session-Id&lt;/code&gt;. My beautiful per-session isolation never engages — not because it's broken, but because from where my server stands, there &lt;em&gt;is&lt;/em&gt; only one session. Every agent walks in wearing the same badge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creates a fresh client per call.&lt;/strong&gt; Now there's no session continuity at all — each tool call is a stranger, and per-session state is meaningless in the other direction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no middle ground between those two without lanes. The collapse doesn't live in the transport choice. It lives in mcporter's client cache — one layer &lt;em&gt;upstream&lt;/em&gt; of everything I had analyzed.&lt;/p&gt;

&lt;p&gt;My "winning combo" framing wasn't a winning combo. Transport was never the variable that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I got it wrong
&lt;/h2&gt;

&lt;p&gt;I want to be precise about the failure, because it's a failure mode I suspect most maintainers share.&lt;/p&gt;

&lt;p&gt;I verified my own layer exhaustively. I read my session map code before answering. I had literally shipped and tested the per-session mechanism that same week. Every claim I made about &lt;em&gt;my server&lt;/em&gt; was true.&lt;/p&gt;

&lt;p&gt;But "each client gets its own session" carries a silent assumption: &lt;strong&gt;that each agent is a distinct client.&lt;/strong&gt; Whether that's true is decided by the middleware between the agent and my server — a layer I don't own, don't ship, and hadn't read. I reasoned about mcporter's topology by analogy ("a daemon per agent surely means a client per agent") instead of by its actual caching behavior.&lt;/p&gt;

&lt;p&gt;The guarantee I was advertising was a chain: agent → runner → client cache → transport → my server → browser extension. I had audited exactly one link and pronounced the chain sound.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was one sentence
&lt;/h2&gt;

&lt;p&gt;Here's the part that keeps this in the same family as the last post: the correction required zero lines of code, again.&lt;/p&gt;

&lt;p&gt;My server's behavior was correct in both topologies. What was wrong was a paragraph in my README that framed lanes as a stdio-only concern. The fix (commit &lt;code&gt;f12e5e3&lt;/code&gt;) rewords it: if you're behind mcporter, you need lanes &lt;em&gt;on any transport&lt;/em&gt;, because the client cache sits upstream of the transport choice.&lt;/p&gt;

&lt;p&gt;One sentence. But a load-bearing sentence — it's the difference between the next user with this setup debugging a "broken" isolation feature for an afternoon, and them installing the right package in two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm taking from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your isolation guarantee is only as strong as the weakest layer between the user and you.&lt;/strong&gt; And most of those layers aren't yours. Documenting a guarantee without naming its assumptions ("this engages &lt;em&gt;if&lt;/em&gt; each agent presents its own session") is how correct code produces incorrect promises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public reasoning gets corrected while it's still cheap.&lt;/strong&gt; I posted my topology analysis on the issue instead of just DMing a recommendation. That's the only reason the wrong mechanism lived for about a day instead of shipping into folklore. The user could quote my exact sentence back at me and point at the exact layer where it broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"I verified my layer" feels identical to "I verified the system."&lt;/strong&gt; From the inside, the two are indistinguishable — both come with the same warm confidence. The only reliable tiebreaker I know is someone who actually runs the other layers. They had read mcporter's internals. I had read mine. Together we had read the system; separately, neither of us had.&lt;/p&gt;

&lt;p&gt;I closed my last post saying the user solved their problem in the layer where it actually lived. Turns out my explanation had the same bug as the feature request: right idea, wrong layer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you ever documented a guarantee your own layer couldn't actually promise — one that depended on middleware you don't control? How did you find out?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>A user spent four days designing a feature for my project. The right answer was zero lines of code.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Fri, 31 Jul 2026 11:12:44 +0000</pubDate>
      <link>https://dev.to/achiya-automation/a-user-spent-four-days-designing-a-feature-for-my-project-the-right-answer-was-zero-lines-of-code-bff</link>
      <guid>https://dev.to/achiya-automation/a-user-spent-four-days-designing-a-feature-for-my-project-the-right-answer-was-zero-lines-of-code-bff</guid>
      <description>&lt;p&gt;Last week I got the best feature request my open-source project has ever received. It was detailed, technically literate, fail-closed by design, and came from someone who clearly runs the tool harder than I do.&lt;/p&gt;

&lt;p&gt;I'm not going to build it. And the person who asked for it agrees — because the conversation ended with &lt;em&gt;him&lt;/em&gt; shipping the fix, in his own layer, as a package other people can now use.&lt;/p&gt;

&lt;p&gt;I want to walk through how that happened, because the whole arc is a case study in a question I think we ask too rarely: not "how do I implement this?" but "&lt;strong&gt;where does this concern actually live?&lt;/strong&gt;"&lt;/p&gt;

&lt;h2&gt;
  
  
  The request
&lt;/h2&gt;

&lt;p&gt;My project is &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, an MCP server that lets AI coding agents drive the real Safari on macOS — your session, your logins, your tabs. Which is exactly what makes concurrency dangerous: if two agents share one browser, "the active tab" is a loaded gun.&lt;/p&gt;

&lt;p&gt;The issue, from a user running parallel AI agent sessions against his everyday Safari, asked for &lt;strong&gt;isolated tab lanes&lt;/strong&gt;: each concurrent client gets stable ownership of its own tab, explicit targeting, and — his words, and the best phrase in the whole thread — behavior that &lt;em&gt;never falls back to whatever tab is in front&lt;/em&gt;, because the front tab is usually the human's.&lt;/p&gt;

&lt;p&gt;Five concrete asks. A design sketch. Fail-closed semantics specified up front. Feature requests do not get better than this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise one: most of it already existed
&lt;/h2&gt;

&lt;p&gt;My first instinct was to answer from the roadmap. I made myself read the source instead, and the result was uncomfortable: &lt;strong&gt;three of his five asks had already shipped.&lt;/strong&gt; Per-session tab state keyed by MCP session id. Tab identity by a marker stamped into the page, not by index, so it survives navigation and tab reordering. And the fail-closed refusal he asked for existed &lt;em&gt;nearly verbatim&lt;/em&gt; — the codebase literally prints "refusing to fall back to 'current tab of window' (would target the user's active tab)."&lt;/p&gt;

&lt;p&gt;He's a competent engineer who read the README carefully and reasonably concluded none of this existed. That's not his failure. All of it was documented only in a design doc the README never linked. A capability nobody can discover is indistinguishable from a capability you never built — so the first "feature" I shipped from this thread was a README section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise two: the feature request found a real bug
&lt;/h2&gt;

&lt;p&gt;Then his follow-up made it better. He dug into &lt;em&gt;why&lt;/em&gt; isolation wasn't engaging in his setup and found that in shared-daemon mode, every client's browser-extension traffic carried the same process-wide session id — so the extension saw all clients as one session, and one client could be served another's cached tab.&lt;/p&gt;

&lt;p&gt;He was right. I verified it in the source, shipped the fix the same day, and his name is in the release notes. A feature request that produces a shipped bugfix has already paid for itself. But the main course was still on the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proposal I liked and still didn't build
&lt;/h2&gt;

&lt;p&gt;His remaining gap was real: his client tooling multiplexes several agent sessions through one connection pool, collapsing them into a &lt;em&gt;single&lt;/em&gt; MCP client — so per-session isolation, which keys on the client boundary, never gets a chance to engage. His proposal: a &lt;code&gt;laneId&lt;/code&gt; parameter on every tool call, required, fail-closed, routed into the existing session map.&lt;/p&gt;

&lt;p&gt;Here's the trap: internally, this was &lt;em&gt;almost free&lt;/em&gt;. The per-session state machinery already existed; running each call in a lane-scoped context would have reused it nearly unchanged. When a feature is one afternoon away, "cheap to build" starts masquerading as "right to build."&lt;/p&gt;

&lt;p&gt;But the cost wasn't in the implementation. The server exposes 97 tools, and a required &lt;code&gt;laneId&lt;/code&gt; means &lt;strong&gt;97 tool schemas grow a parameter&lt;/strong&gt; — every schema an agent reads, every byte of context it burns, in every single-client setup that will never have two clients. A multi-client concern would leak into everyone's single-client API, forever. Schemas are the one part of an MCP server you can't refactor quietly later.&lt;/p&gt;

&lt;p&gt;So I did the annoying thing: I said I liked it, wrote down exactly how it &lt;em&gt;would&lt;/em&gt; be built and gated if ever needed — and held the build. Publicly. In the issue. Which felt like stalling, right up until it turned out to be the move that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the concern actually lived
&lt;/h2&gt;

&lt;p&gt;Look at the shape of the problem again. The isolation wasn't missing from my server — it was being &lt;em&gt;erased upstream&lt;/em&gt;, by the layer that collapsed N agent sessions into one client. Asking my server to add lanes was asking the downstream layer to compensate for something the upstream layer destroyed.&lt;/p&gt;

&lt;p&gt;He reached the same conclusion, went looking in his own stack — and found his multiplexer already had per-instance isolation knobs. Then he did the genuinely great thing: instead of a private workaround, he &lt;strong&gt;built and published an extension&lt;/strong&gt; that gives each of his agent sessions its own daemon directory — its own connection, its own server process, its own lane — with a composite lane key for the concurrency edge cases and an idle timeout so processes don't pile up.&lt;/p&gt;

&lt;p&gt;It's not specific to my project. It works for &lt;em&gt;any&lt;/em&gt; stateful MCP server behind that multiplexer. The fix landed in the layer that owned the problem, and it landed as infrastructure other people can reuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;p&gt;Final tally for the "rejected" feature request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A README section that made existing isolation discoverable, instead of documented-but-invisible&lt;/li&gt;
&lt;li&gt;A real cross-client session bug found, verified, and shipped as a fix — credited to the reporter&lt;/li&gt;
&lt;li&gt;A reusable extension, published by the user, solving the problem in the layer that owned it&lt;/li&gt;
&lt;li&gt;A written-down design for &lt;code&gt;laneId&lt;/code&gt;, on the record, buildable in a day if the need ever materializes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero lines of feature code in my repo&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;YAGNI usually gets framed as refusal — a door closed in the requester's face. Done right, it's redirection: the effort didn't disappear, it moved to where it belonged, and the ecosystem ended up with more than my feature would have delivered. The best code review I ever got was a feature request. The best feature I shipped last week was a link to someone else's package.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's the best feature request you've ever declined — and did saying "not here" ever produce something better than building it would have? I'd genuinely like to hear the counter-examples too: the time you held the line on YAGNI and it turned out you did need it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
