<?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 built a guard that refused to read the user's tab. Then my own cleanup code closed it.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 27 Jul 2026 06:40:34 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-built-a-guard-that-refused-to-read-the-users-tab-then-my-own-cleanup-code-closed-it-3fpe</link>
      <guid>https://dev.to/achiya-automation/i-built-a-guard-that-refused-to-read-the-users-tab-then-my-own-cleanup-code-closed-it-3fpe</guid>
      <description>&lt;p&gt;Three days ago my browser automation tool closed one of my own tabs. Not a tab it had opened — a dashboard I had open in another window, with a page I hadn't finished reading.&lt;/p&gt;

&lt;p&gt;What makes it worth writing up isn't the bug. It's that the guard designed to prevent exactly this had already fired, correctly, ninety seconds earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard worked
&lt;/h2&gt;

&lt;p&gt;Safari MCP lets an AI agent drive your real, logged-in Safari. That premise means the single worst thing it can do is act on a tab you're using. So there's an identity system: every tab the tool opens gets a marker stamped into &lt;code&gt;window.name&lt;/code&gt;, which survives navigation, redirects, and cross-origin loads. Before running anything in a tab, the tool checks the marker.&lt;/p&gt;

&lt;p&gt;I was filling in a form. The URL was a &lt;code&gt;forms.gle&lt;/code&gt; shortlink, which 302s to &lt;code&gt;docs.google.com&lt;/code&gt; — a cross-origin redirect that, it turns out, drops &lt;code&gt;window.name&lt;/code&gt;. My next read came back refused:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Tab tracking lost — refusing to target the user's current tab.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Correct. Exactly the intended behaviour. The tool no longer knew which tab was its own, so it declined to guess.&lt;/p&gt;

&lt;p&gt;So I did the tidy thing and cleaned up my orphaned tab:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;It closed a different tab. One of mine. The tool went from &lt;em&gt;"I can't prove which tab is mine, so I won't read"&lt;/em&gt; to &lt;em&gt;"let me close a tab"&lt;/em&gt; in one step, and nobody stopped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the hole
&lt;/h2&gt;

&lt;p&gt;Here is the close path as it existed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;osascript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`... close tab &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;osascript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`... close current tab of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// ← the user's tab&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;current tab of window&lt;/code&gt; is whatever the user is looking at. So the fallback for &lt;em&gt;"I don't know which tab is mine"&lt;/em&gt; was &lt;em&gt;"close theirs."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That branch is only reachable when the index is unknown — which is precisely the state the guard had just announced. The two pieces of code were describing the same condition and disagreeing about what it meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, one mistake
&lt;/h2&gt;

&lt;p&gt;When I went looking, the same fail-open was in all three layers of the stack, and it had the same shape every time: &lt;strong&gt;no ownership recorded was read as permission, not as refusal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first one is the most embarrassing, because it's a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_noOwnershipCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="c1"&gt;// Tab management&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;new_tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list_tabs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close_tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;switch_tab&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A set of operations exempt from the ownership check, grouped under the comment "read-only or tab management". Three of those four are harmless: &lt;code&gt;new_tab&lt;/code&gt; creates a tab, &lt;code&gt;list_tabs&lt;/code&gt; reads, and &lt;code&gt;switch_tab&lt;/code&gt; carries its own ownership check at the tool level. &lt;code&gt;close_tab&lt;/code&gt; destroys a user tab and had no check anywhere.&lt;/p&gt;

&lt;p&gt;It wasn't exempted by argument. It was exempted by &lt;em&gt;category&lt;/em&gt; — it looked like tab management, it sat next to tab management, so it inherited tab management's safety assumptions. Nobody ever wrote down "closing a tab is safe." The list said it, silently, by adjacency.&lt;/p&gt;

&lt;p&gt;The third layer, the browser extension, had a variant worth naming on its own. Its guard refuses an operation when the session owns tabs and this isn't one of them — but when the session owns &lt;em&gt;nothing&lt;/em&gt;, it allows the operation, with this comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// If no tabs owned yet, allow operation (backward compatibility for sessions that don't use new_tab)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That leniency is sensible for a write: worst case it edits the page you're already on. For a close it's a disaster. And "owns nothing" is exactly what a session reports after its transport drops and the client re-initialises — mid-task, tab still open. The lenient branch is reachable &lt;em&gt;only&lt;/em&gt; in the state where it's most wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I actually got wrong
&lt;/h2&gt;

&lt;p&gt;My first instinct for the fix was to reuse the rule the read path already uses: refuse a tab carrying another session's marker, allow an unmarked one. It keeps "read the page I'm looking at" working for a session that genuinely never opened a tab.&lt;/p&gt;

&lt;p&gt;That rule would not have prevented this. The tab I destroyed was a genuine user tab. It had no marker at all. Under "unmarked is fine", it sails straight through to &lt;code&gt;tabs.remove()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which forced the actual distinction: &lt;strong&gt;a wrong read costs information; a wrong close costs the user their work.&lt;/strong&gt; They don't get the same rule. The read paths keep the lenient fallback on purpose. The destructive path gets no fallback whatsoever — it closes a tab it can positively prove it owns, or it throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;explicitIndex&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_provenOwnTabIndex&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`Tab tracking lost — refusing to close a tab this session cannot prove it opened ...`&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;No &lt;code&gt;else&lt;/code&gt;. "Owns nothing" now means "closes nothing", which is the sentence that should have been in that first list all along.&lt;/p&gt;

&lt;p&gt;A few adjacent things fell out of it. Blanking a window's last tab — the workaround for "closing this would quit Safari" — used the same fallback, and throwing away someone's loaded page is destructive too; it's pinned to the proven index now. And in the extension, the guard validated one tab (&lt;code&gt;tabId&lt;/code&gt;) while the indexed branch removed a &lt;em&gt;different&lt;/em&gt; one (&lt;code&gt;_winTabs[index - 1]&lt;/code&gt;), so the check and the action were pointed at different tabs. That one had never fired in production; it was just waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;p&gt;The guard wasn't wrong and the fallback wasn't wrong. What was wrong is that they were two different answers to one question — &lt;em&gt;do we know which tab is ours?&lt;/em&gt; — living in two files, and only one of them had thought about what the answer implied.&lt;/p&gt;

&lt;p&gt;I've since stopped trusting the word "safe" in a category name. &lt;code&gt;close_tab&lt;/code&gt; was in a set called tab management, and the set was right: it &lt;em&gt;is&lt;/em&gt; tab management. Categories describe what an operation is. Guards have to be about what it costs when it's wrong. Sorting by the first and inheriting the second is how a destructive call ends up on the exempt list with no one having decided that.&lt;/p&gt;

&lt;p&gt;The test I wrote isn't behavioural, for the same reason as last time: the defect is a &lt;em&gt;missing refusal&lt;/em&gt; on a path that only runs after state is already lost. There's nothing to exercise. It reads the source and asserts that no AppleScript verb in the close path targets the front document — and I checked that it fails when I put the old branch back, because a regression test you haven't seen fail is a guess.&lt;/p&gt;

&lt;p&gt;Fixed in v2.15.8. The repo is &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt; — the full analysis is in &lt;a href="https://github.com/achiya-automation/safari-mcp/issues/68" rel="noopener noreferrer"&gt;issue #68&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A question for you:&lt;/strong&gt; how do you catch the operations that got their safety assumptions by adjacency — the ones sitting in a list they only half belong to? Every review I've done reads the code in the branch, not the membership of the set above it.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I run an affiliate code. Seven fake ones outrank it — and Google's AI believes them.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 23 Jul 2026 12:01:27 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-run-an-affiliate-code-seven-fake-ones-outrank-it-and-googles-ai-believes-them-19h1</link>
      <guid>https://dev.to/achiya-automation/i-run-an-affiliate-code-seven-fake-ones-outrank-it-and-googles-ai-believes-them-19h1</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I'm a Chatwoot affiliate. The links in this post are affiliate links, and the code I mention earns me a commission. That's exactly what this post is about, so it would be strange to bury it at the bottom.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Last week I did something I'd never bothered to do: I searched Google for my own affiliate code.&lt;/p&gt;

&lt;p&gt;Google asked me if I meant "ACHIEVERS."&lt;/p&gt;

&lt;p&gt;Two results came back, both from my own site. That's the entire web footprint of the code. Meanwhile, the first organic result for &lt;code&gt;chatwoot coupon&lt;/code&gt; promises &lt;strong&gt;70% off&lt;/strong&gt;, and a page ranking for &lt;code&gt;chatwoot discount code&lt;/code&gt; confidently tells you the code is &lt;code&gt;START&lt;/code&gt; for 20% off.&lt;/p&gt;

&lt;p&gt;Neither of those can be true, and the reason is arithmetic, not opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling is public
&lt;/h2&gt;

&lt;p&gt;Chatwoot publishes its affiliate terms on its own site:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Refer businesses to Chatwoot Cloud and earn 20% of the revenue for the first 12 months. Your referrals get 5% off their subscription too."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the whole mechanism. A partner earns 20% of the revenue for a year; the person they refer gets &lt;strong&gt;5% off&lt;/strong&gt;. There is no tier that issues 20%, 50% or 70% to a referral, because there's no lever in the program that produces one.&lt;/p&gt;

&lt;p&gt;So when I went looking, here's what's actually circulating as of July 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Claimed discount&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;START&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20% off&lt;/td&gt;
&lt;td&gt;three separate blogs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PINTU&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50% off&lt;/td&gt;
&lt;td&gt;AI-generated article site&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;KUMAR&lt;/code&gt; / &lt;code&gt;MALIK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;60% off&lt;/td&gt;
&lt;td&gt;startup directory, coupon aggregator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SAVE55&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unspecified&lt;/td&gt;
&lt;td&gt;LinkedIn "newsletter"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unspecified&lt;/td&gt;
&lt;td&gt;YouTube short&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5OFFCODE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;email-tools blog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;"70% off", "$100 off"&lt;/td&gt;
&lt;td&gt;coupon aggregators, no code attached&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only one of those is even in the right ballpark, and it isn't attached to a real referral account as far as I can tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually matters
&lt;/h2&gt;

&lt;p&gt;Here's the thing I didn't expect, and the reason I'm writing this instead of shrugging.&lt;/p&gt;

&lt;p&gt;I've been tracking whether Google's AI Overview names a specific code for these queries. One day it named mine across all three coupon queries I track. The next day — after no change on my end — it named &lt;strong&gt;none of them&lt;/strong&gt;, and instead fell back to citing chatwoot.com for the generic fact that referrals get 5% off.&lt;/p&gt;

&lt;p&gt;It got the &lt;em&gt;fact&lt;/em&gt; right. It just wouldn't commit to a &lt;em&gt;code&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;When I looked at the source distribution, the reason was obvious. Every fake code sits on 8+ independent domains. Mine sits on one. An LLM asked "what's the real code" is doing something closer to counting witnesses than checking arithmetic — and by that measure, seven fabrications beat one accurate source.&lt;/p&gt;

&lt;p&gt;This is the uncomfortable version of the "AI search rewards authority" advice everyone repeats. It doesn't reward being &lt;em&gt;right&lt;/em&gt;. It rewards being &lt;em&gt;corroborated&lt;/em&gt;. Those are different things, and coupon spam is the cleanest natural experiment I've seen for the gap between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why coupon spam is structurally good at this
&lt;/h2&gt;

&lt;p&gt;Fake discount content has properties that make it unusually effective against retrieval-based systems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's cheap to generate.&lt;/strong&gt; A "verified 2026 promo code" page is a template. One prompt produces fifty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's unfalsifiable at read time.&lt;/strong&gt; A crawler can't try the code at checkout. Neither can an LLM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's mutually reinforcing.&lt;/strong&gt; Aggregators scrape each other, so a single fabrication propagates into what looks like independent confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody corrects it.&lt;/strong&gt; The vendor doesn't chase coupon sites, and the affiliate who'd care is one voice against a dozen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The honest actor is structurally disadvantaged. I can say the real number is 5%, but I can only say it once, from one domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;Three things, none of them clever:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Said the number plainly and named the fakes.&lt;/strong&gt; My &lt;a href="https://achiya-automation.com/en/blog/chatwoot-vs-intercom/" rel="noopener noreferrer"&gt;Chatwoot vs Intercom comparison&lt;/a&gt; now lists &lt;code&gt;START&lt;/code&gt;, &lt;code&gt;PINTU&lt;/code&gt;, &lt;code&gt;KUMAR&lt;/code&gt;, &lt;code&gt;MALIK&lt;/code&gt;, &lt;code&gt;SAVE55&lt;/code&gt;, &lt;code&gt;GET50&lt;/code&gt; and &lt;code&gt;5OFFCODE&lt;/code&gt; explicitly and explains why each is inconsistent with the published program terms. Naming them is the part that matters — "there are fake codes out there" is unquotable; "&lt;code&gt;START&lt;/code&gt; doesn't work because the ceiling is 5%" is a sentence a model can lift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quoted the primary source instead of paraphrasing it.&lt;/strong&gt; Chatwoot's own affiliate page is the authority on Chatwoot's affiliate terms. Linking it beats asserting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stopped splitting my own signal.&lt;/strong&gt; I had four different affiliate codes across my site, one per content category, because I wanted to know which content converted. That experiment finished — one code produced everything, the other three produced nothing in three weeks — so I collapsed them into one. Four competing answers on one domain is a weaker signal than one answer repeated, and I'd been doing it to myself.&lt;/p&gt;

&lt;p&gt;I don't know yet whether any of it moves the needle. That's the honest ending.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you actually want Chatwoot
&lt;/h2&gt;

&lt;p&gt;Since it would be silly to write all this and be coy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosting is free.&lt;/strong&gt; The Community Edition has no agent cap and no conversation cap. If you're comfortable with Docker, you do not need a discount code, because there's nothing to discount. This is genuinely what I'd recommend to most people reading dev.to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hosted version&lt;/strong&gt; has a free tier for up to 2 agents, then starts at $19/agent/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you go hosted&lt;/strong&gt;, the working code is &lt;code&gt;ACHIYAVS&lt;/code&gt; for 5% off at &lt;a href="https://www.chatwoot.com/?via=achiya-automation" rel="noopener noreferrer"&gt;Chatwoot Cloud&lt;/a&gt;, and yes, I earn a commission on it. On the $19 tier that discount is about $0.95 per agent per month. It is not a reason to choose anything. Take the 15-day trial first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd rather tell you the code is worth a dollar than tell you it's worth 70% and waste your time at checkout.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you want to reproduce the measurement: search your own brand or code in quotes with &lt;code&gt;&amp;amp;nfpr=1&lt;/code&gt; appended to the Google URL to disable autocorrect, logged out. The number of distinct domains that come back is the number the models are effectively counting.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My fix for a data-loss bug sat in a green PR for four days. Every install in that window still had the bug.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Tue, 21 Jul 2026 11:12:56 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-fix-for-a-data-loss-bug-sat-in-a-green-pr-for-four-days-every-install-in-that-window-still-had-2jj5</link>
      <guid>https://dev.to/achiya-automation/my-fix-for-a-data-loss-bug-sat-in-a-green-pr-for-four-days-every-install-in-that-window-still-had-2jj5</guid>
      <description>&lt;p&gt;On July 17 I opened the most serious issue my project has ever had, wrote the fix the same morning, and pushed it as a pull request. CI went green on Node 20, 22 and 24 within minutes. Zero review comments. &lt;code&gt;mergeStateStatus: CLEAN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then it sat there.&lt;/p&gt;

&lt;p&gt;I merged it today — July 21. Four days.&lt;/p&gt;

&lt;p&gt;For those four days the fix existed, was correct, was tested, and was &lt;em&gt;visible to nobody&lt;/em&gt;. Every &lt;code&gt;npx safari-mcp&lt;/code&gt; in that window installed the version with the bug. The npm counter says roughly 1,400 people a week pull this thing. The fix was done and the users still had the bug, and those two facts sat side by side without touching each other.&lt;/p&gt;

&lt;p&gt;I want to be precise about why, because the interesting part isn't "I was busy."&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt; lets an AI agent drive the Safari you're already logged into. That's the value and that's the danger: you're using the browser at the same time as the agent. The one promise the project makes is &lt;em&gt;the agent never touches a tab it didn't open.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That promise had a hole. When the tracked tab index ran past the end of the window — you closed a tab, or tore one into its own window — the resolver clamped the index to the last tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tabCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[Safari MCP] Tab ghost proactive fix: clamping to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tabCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last tab in the window is not our tab. It's whatever &lt;em&gt;you&lt;/em&gt; happen to have open there. So at the exact moment the code discovers it no longer knows where its tab is, it points the session at one of yours — and logs the words "proactive fix" while doing it.&lt;/p&gt;

&lt;p&gt;The fix is four lines: fail closed. Drop ownership, return &lt;code&gt;null&lt;/code&gt;, let the next call open a fresh tab.&lt;/p&gt;

&lt;p&gt;Small diff. Green CI. Four days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually held it
&lt;/h2&gt;

&lt;p&gt;Not review capacity. I'm the only reviewer; there was no queue.&lt;/p&gt;

&lt;p&gt;Not risk. The change makes an unsafe path safe; the worst case of shipping it is that the agent opens a redundant tab.&lt;/p&gt;

&lt;p&gt;What held it was that &lt;strong&gt;merging was the fourth step of a five-step ritual and only the first step was automated.&lt;/strong&gt; Merge, bump version, write the changelog entry, cut a GitHub release, and only then does the Publish workflow fire and npm gets the fix. Steps 2–4 are me, at a keyboard, in a mood to do release chores.&lt;/p&gt;

&lt;p&gt;CI told me the code was good. Nothing told me the code was &lt;em&gt;stuck&lt;/em&gt;. Those are different signals and I only had one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell I ignored
&lt;/h2&gt;

&lt;p&gt;Here's the part that made me write this instead of quietly shipping.&lt;/p&gt;

&lt;p&gt;While cutting today's release I opened &lt;code&gt;CHANGELOG.md&lt;/code&gt; and found the previous release, v2.15.3, had &lt;strong&gt;no entry at all&lt;/strong&gt;. Shipped July 15. The release commit touched exactly two files: &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;package-lock.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three real fixes went out that day — clicks landing on the wrong tab, ARIA combobox typeahead never loading, a focus-helper queue that poisoned itself after a timeout, the last one contributed by an outside developer. Anyone reading the changelog to decide whether to upgrade saw nothing between 2.15.2 and 2.15.4. The contributor's fix was invisible.&lt;/p&gt;

&lt;p&gt;I didn't skip that entry on purpose. I skipped it because it's step 3 of the same manual ritual, and step 3 has no test.&lt;/p&gt;

&lt;p&gt;So the pattern isn't "a PR was slow." The pattern is: &lt;strong&gt;the parts of shipping that a machine watches are reliable, and the parts that only a human watches decay — silently, and in the same direction every time.&lt;/strong&gt; My CI has 57 tests. My release process has zero.&lt;/p&gt;

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

&lt;p&gt;There's a well-worn instinct in solo open source that goes: &lt;em&gt;don't automate the release, you'll ship something bad by accident.&lt;/em&gt; Manual = careful.&lt;/p&gt;

&lt;p&gt;I believed that. Look at what manual actually bought me:&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;Automated&lt;/th&gt;
&lt;th&gt;Manual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code correctness&lt;/td&gt;
&lt;td&gt;57 tests, 3 Node versions&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packaging&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;packaging.test.mjs&lt;/code&gt; fails CI if an imported file isn't published&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry sync&lt;/td&gt;
&lt;td&gt;server.json version synced + published on release&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Merge decision&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;4-day gap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Changelog entry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;silently missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cutting the release&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;mood-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every column with a machine in it held. Every column with only me in it drifted. The "careful" half is the half that failed, and it failed quietly, which is worse than failing loudly.&lt;/p&gt;

&lt;p&gt;The honest version of the instinct isn't &lt;em&gt;manual is careful.&lt;/em&gt; It's &lt;em&gt;manual feels careful because you were paying attention at the moment you did it, and you have no record of the moments you weren't.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm changing
&lt;/h2&gt;

&lt;p&gt;Not full auto-release-on-merge. I still want a human deciding when a release exists, because "these three fixes belong together" is a judgment call and I don't have a robot with taste.&lt;/p&gt;

&lt;p&gt;What I want is for the &lt;em&gt;gaps&lt;/em&gt; to be loud:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A changelog check in CI.&lt;/strong&gt; If the diff touches &lt;code&gt;safari.js&lt;/code&gt; or &lt;code&gt;index.js&lt;/code&gt; and doesn't touch &lt;code&gt;CHANGELOG.md&lt;/code&gt;, CI complains. That's the test step 3 never had. It's ten lines and it would have caught the 2.15.3 hole on the day it happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stale-green-PR alarm.&lt;/strong&gt; Any PR that is &lt;code&gt;CLEAN&lt;/code&gt; + passing + zero-review-comments for more than 48 hours gets surfaced, loudly, wherever I'll actually see it. Not to force a merge — to make "still holding this" a decision I re-make rather than a default I drift into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release notes generated from the changelog&lt;/strong&gt;, so the GitHub release and the changelog can't disagree — one source, two renders.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of that is clever. That's the point. The failure wasn't clever either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I'd actually generalize
&lt;/h2&gt;

&lt;p&gt;If you maintain something alone, look at your last five releases and ask a narrow question: &lt;em&gt;which steps between "merged" and "a user can install it" have no automated observer?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Not "which steps are manual" — plenty of manual steps are fine. Which manual steps would fail &lt;strong&gt;without producing a symptom you'd notice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A missing changelog entry produces no error. A green PR sitting for four days produces no error. A release you didn't cut produces no error. They produce the &lt;em&gt;absence&lt;/em&gt; of something, and absence doesn't page anybody.&lt;/p&gt;

&lt;p&gt;My whole test suite is built to catch code that does the wrong thing. Not one of those 57 tests can catch code that does the right thing where nobody can reach it.&lt;/p&gt;




&lt;p&gt;v2.15.4 is on npm now — the tab-index path fails closed, and the 2.15.3 changelog entry has been backfilled. Source: &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;github.com/achiya-automation/safari-mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the gap in your pipeline that has no observer?&lt;/strong&gt; I'm genuinely collecting these — the ones I find myself are always the boring ones, and the interesting ones seem to come from other people's setups.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
    <item>
      <title>I filed a critical bug against my own tool. Then I read the code — and my own root cause was wrong.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Fri, 17 Jul 2026 07:19:32 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-filed-a-critical-bug-against-my-own-tool-then-i-read-the-code-and-my-own-root-cause-was-wrong-3692</link>
      <guid>https://dev.to/achiya-automation/i-filed-a-critical-bug-against-my-own-tool-then-i-read-the-code-and-my-own-root-cause-was-wrong-3692</guid>
      <description>&lt;p&gt;Three days ago I filed the most serious issue my project has ever had — against myself.&lt;/p&gt;

&lt;p&gt;The tool 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 an AI agent drive the Safari you're already logged into. That premise is the whole value proposition, and it's also the whole danger: &lt;strong&gt;the user is using this browser at the same time as the agent.&lt;/strong&gt; The single promise the project makes is "the agent never touches a tab it didn't open."&lt;/p&gt;

&lt;p&gt;The issue was that the promise had broken. Two of a user's tabs ended up displaying pages the agent had loaded. Nothing was closed — the back-history survived — but scroll position, in-page state, anything unsaved: gone.&lt;/p&gt;

&lt;p&gt;I wrote up the incident, traced the root cause, and proposed a fix. Then today I opened the file to actually write that fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My root cause was wrong.&lt;/strong&gt; The fix I proposed had already shipped, three months ago. And the real bug was sitting four lines below it, wearing the word "fix" in its own log message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I claimed
&lt;/h2&gt;

&lt;p&gt;Here's what I wrote in the issue:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;_ownedTabs&lt;/code&gt; (and every &lt;code&gt;tabIndex&lt;/code&gt; parameter) is a positional handle to a mutable, user-controlled list. Positional handles are only valid as long as nobody else mutates the list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the proposed fix:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Track ownership by a &lt;strong&gt;stable identity&lt;/strong&gt;, not a position. On &lt;code&gt;safari_new_tab&lt;/code&gt;, inject a sentinel into the page (e.g. &lt;code&gt;window.__safariMcpTabId = "&amp;lt;uuid&amp;gt;"&lt;/code&gt;), and resolve &lt;code&gt;tabIndex&lt;/code&gt; → real tab by scanning windows for the matching sentinel.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Confident. Specific. Reasonable. I even left a follow-up comment calling the sentinel design "the plan of record."&lt;/p&gt;

&lt;p&gt;I wrote all of that from my memory of the architecture. I did not open &lt;code&gt;safari.js&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually in the file
&lt;/h2&gt;

&lt;p&gt;The sentinel already existed. It had been there since v2.8.3, released April 14 — a release literally titled &lt;em&gt;"bulletproof tab tracking via &lt;code&gt;window.__mcpTabMarker&lt;/code&gt;."&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ========== TAB IDENTITY MARKER ==========&lt;/span&gt;
&lt;span class="c1"&gt;//  - window.name           : survives EVERY navigation (full loads, redirects,&lt;/span&gt;
&lt;span class="c1"&gt;//                            cross-origin). The browser preserves window.name by&lt;/span&gt;
&lt;span class="c1"&gt;//                            design — the bulletproof identity that index/URL lack.&lt;/span&gt;
&lt;span class="c1"&gt;//  - window.__mcpTabMarker : survives SPA / same-document routing (secondary marker).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had written the fix I was now proposing. I'd written the &lt;em&gt;comment explaining why it was the right fix&lt;/em&gt;. I'd forgotten I'd done it.&lt;/p&gt;

&lt;p&gt;So the interesting question stopped being "why is ownership positional" — it isn't — and became &lt;strong&gt;"if identity resolution is already there, how did a user's tab still get navigated?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three exits, one of them guesses
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resolveActiveTab()&lt;/code&gt; is the function that answers "which tab is ours, right now?" It has a strategy ladder, and what matters is how each rung &lt;em&gt;fails&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 1 — the marker scan.&lt;/strong&gt; Loop every tab, ask each one whether &lt;code&gt;window.name&lt;/code&gt; matches our marker. Found it? That's our tab, whatever index it's sitting at. This is identity, and it's correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 2 — no URL to fall back on:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;hasOwnedTab&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabMarker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identity lost → drop the index. &lt;strong&gt;Fails closed.&lt;/strong&gt; ✅&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 3 — the URL scan comes back empty:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;hasOwnedTab&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabMarker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[Safari MCP] Tab identity lost (marker + URL unresolved) — clearing index to avoid targeting the user&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;s tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same call, same instinct. &lt;strong&gt;Fails closed.&lt;/strong&gt; ✅&lt;/p&gt;

&lt;p&gt;And then, four lines later, in the same block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tabCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[Safari MCP] Tab ghost proactive fix: index &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt; tabCount &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tabCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, clamping to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tabCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabCount&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;Read that carefully, because I didn't for three months.&lt;/p&gt;

&lt;p&gt;We tracked tab 8. The window now has 7 tabs — because the user closed one, or tore one into its own window. Our index is out of range. That is the &lt;em&gt;exact&lt;/em&gt; moment the code has learned it no longer knows where our tab is.&lt;/p&gt;

&lt;p&gt;And it responds by &lt;strong&gt;clamping the index to &lt;code&gt;tabCount&lt;/code&gt;&lt;/strong&gt;: tab 7. The last tab in the window. A tab we have never seen, that belongs to the user, chosen for no reason other than that it's the highest index that won't throw.&lt;/p&gt;

&lt;p&gt;Then it logs the words &lt;strong&gt;"proactive fix."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the bug. Not positional ownership — a fail-open sitting between two fail-closed branches, in the safety-critical path, describing itself as a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it got there
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git log -S&lt;/code&gt; puts the clamp at March 31. The identity marker landed April 14.&lt;/p&gt;

&lt;p&gt;The clamp is &lt;strong&gt;two weeks older than the mechanism that made it obsolete.&lt;/strong&gt; It's from the era when the index was genuinely all we had, when "out of range" produced ugly AppleScript errors and clamping made them stop.&lt;/p&gt;

&lt;p&gt;And that's the actual lesson, the one that generalizes past my weird little macOS project:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you add a better mechanism, the old heuristic does not remove itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The v2.8.3 work added identity resolution and correctly rewired the branches it was looking at — the two &lt;code&gt;hasOwnedTab &amp;amp;&amp;amp; !activeTabMarker&lt;/code&gt; guards &lt;em&gt;are&lt;/em&gt; the new thinking, and they fail closed because in April I understood the stakes. The clamp wasn't rewired, because it didn't look like an ownership decision. It looked like input validation. It looked like the &lt;em&gt;careful&lt;/em&gt; line. It had a bounds check and an error log.&lt;/p&gt;

&lt;p&gt;Every upgrade leaves fossils like this. The dangerous ones aren't the code that looks scary. They're the code that looks like it's on your side.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Proactive fix" is a confession
&lt;/h2&gt;

&lt;p&gt;Here's the tell I want to hand you, because it's cheap and it's reusable.&lt;/p&gt;

&lt;p&gt;The clamp was written to fix a &lt;em&gt;symptom&lt;/em&gt;: an index pointing past the end of the array. It makes that symptom disappear by &lt;strong&gt;inventing a plausible value&lt;/strong&gt;. &lt;code&gt;tabCount&lt;/code&gt; isn't a computed answer to "where is our tab" — it's the nearest number that doesn't crash.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Any code that converts "I don't know" into a plausible value is a fail-open wearing a fix's clothes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once you have that phrasing, you start seeing them everywhere. &lt;code&gt;?? 0&lt;/code&gt; on a total that should have been fetched. &lt;code&gt;catch {}&lt;/code&gt; around the call that establishes permission. &lt;code&gt;|| user[0]&lt;/code&gt; when the lookup missed. Clamping an index into range. Each one takes a state where the honest answer is &lt;em&gt;stop&lt;/em&gt; and launders it into a value the next line will happily use.&lt;/p&gt;

&lt;p&gt;The bounds check is real. The clamp is the bug. They're on the same line, and that's exactly why it survived three months of me reading past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix actually is
&lt;/h2&gt;

&lt;p&gt;It's a deletion, not the sentinel architecture I proposed. That branch has to do what its two neighbours already do — when the index is out of range, identity is lost, so drop it and make the caller re-anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tabCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;_st&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// fail closed, like every other exit&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callers already handle this. There's a &lt;code&gt;_assertNotFallingBackToUserTab()&lt;/code&gt; that throws a clear &lt;em&gt;"Tab tracking lost — re-run &lt;code&gt;safari_new_tab&lt;/code&gt; to recover"&lt;/em&gt;. The recovery path was built. The clamp was just routing around it.&lt;/p&gt;

&lt;p&gt;The cost is honest: sessions that used to get lucky will now throw. In a tool whose entire promise is &lt;em&gt;"we don't touch your tabs,"&lt;/em&gt; an error message is the correct output for "I don't know which tab is mine." A guess is not.&lt;/p&gt;

&lt;p&gt;It's &lt;a href="https://github.com/achiya-automation/safari-mcp/pull/59" rel="noopener noreferrer"&gt;PR #59&lt;/a&gt;, open against &lt;a href="https://github.com/achiya-automation/safari-mcp/issues/54" rel="noopener noreferrer"&gt;issue #54&lt;/a&gt; — where I've also left my original root-cause analysis standing, wrong, with a correction under it. The drift between what I remembered and what shipped &lt;em&gt;is&lt;/em&gt; the bug; editing the evidence out seemed like the wrong move.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I keep thinking about
&lt;/h2&gt;

&lt;p&gt;I filed a detailed, confident, well-structured bug report about code I wrote, and got the root cause wrong — because I reasoned from my mental model instead of from the file. The mental model was a year of accumulated intent. The file was what actually shipped. Those had quietly drifted apart, and the gap between them is precisely where the bug lived.&lt;/p&gt;

&lt;p&gt;If I'd handed that issue to an AI agent — or a new contributor — they'd have implemented the sentinel I asked for. Diligently. It already existed. The clamp would still be there, and the user's tabs would still be getting navigated, and the issue would be closed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most expensive thing in that whole chain was my confidence.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Have you found one of these in your own code — a "fix" that was actually inventing an answer? I'd genuinely like to collect the pattern. Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt; is MIT-licensed and drives your real, logged-in Safari on macOS. It has 97 tools, and — once #59 lands — one fewer place where it guesses which tab is yours.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>A stranger fixed my bug. Then I found out he fixed the wrong half — and it still worked.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 12 Jul 2026 07:16:34 +0000</pubDate>
      <link>https://dev.to/achiya-automation/a-stranger-fixed-my-bug-then-i-found-out-he-fixed-the-wrong-half-and-it-still-worked-309g</link>
      <guid>https://dev.to/achiya-automation/a-stranger-fixed-my-bug-then-i-found-out-he-fixed-the-wrong-half-and-it-still-worked-309g</guid>
      <description>&lt;p&gt;Someone opened a pull request against my Safari MCP server last week. Three functions, same treatment each, clean write-up, all CI green. It fixed a real bug — his repro was solid, mine reproduced it too.&lt;/p&gt;

&lt;p&gt;Then I sat down to write it up, built a 40-line model of the thing to make sure I understood it, and discovered that &lt;strong&gt;half of his patch does nothing at all.&lt;/strong&gt; Not "does something subtle." Nothing. The two hunks he described as the fix are behaviorally identical to the code they replace.&lt;/p&gt;

&lt;p&gt;The half he &lt;em&gt;didn't&lt;/em&gt; emphasize is the one that closes the bug. And the reason neither of us could tell — the reason I couldn't tell about my own code — turns out to be the actual story here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol: positional, ID-free, and one mistake from disaster
&lt;/h2&gt;

&lt;p&gt;My server talks to a small Swift helper for the things JavaScript can't do on a Mac: focus an app, hide a window, synthesize a real OS-level click. Newline-delimited JSON over stdin/stdout. Request and response are correlated &lt;strong&gt;positionally&lt;/strong&gt; — there are no request IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// callbacks waiting for responses&lt;/span&gt;

&lt;span class="nx"&gt;_helperProc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;_buf&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;_buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// first reply belongs to first waiter&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works exactly as long as one invariant holds:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every callback in the queue corresponds to a request that was actually sent and whose reply is still coming.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Violate that once and the queue is off by one. Reply N goes to waiter N+1. Forever — there is nothing in the protocol that can ever resynchronize it. And because every waiter is a &lt;code&gt;setTimeout&lt;/code&gt;-guarded promise, the symptom is never an error. It's a hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two ways to abandon a request
&lt;/h2&gt;

&lt;p&gt;A helper call looks like this (this is the real pre-fix code, lightly trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_helperGetFrontApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_withHelperLock&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// path 1: gave up waiting&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                       &lt;span class="c1"&gt;// ← pushed BEFORE the write&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;_helperProc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"getFrontApp":true}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                &lt;span class="c1"&gt;// path 2: write blew up&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;There are two ways to walk away from a request, and they are &lt;strong&gt;not&lt;/strong&gt; the same, which is the whole point:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path 1 — the timeout.&lt;/strong&gt; The helper is slow; we stop waiting. But a reply is still coming. The dead &lt;code&gt;cb&lt;/code&gt; stays in the queue, gets shifted when the late line lands, sees &lt;code&gt;resolved === true&lt;/code&gt;, and returns without touching anything. It ate exactly one line — which is precisely correct, because exactly one line was owed. &lt;strong&gt;The queue stays aligned.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path 2 — the write failure.&lt;/strong&gt; &lt;code&gt;stdin.write&lt;/code&gt; throws (broken pipe: the helper died between our last call and this one). The request was &lt;strong&gt;never sent&lt;/strong&gt;, so no reply is ever coming. But look at the &lt;code&gt;catch&lt;/code&gt;: it calls &lt;code&gt;resolve(null)&lt;/code&gt; and &lt;strong&gt;never sets &lt;code&gt;resolved = true&lt;/code&gt;.&lt;/strong&gt; So &lt;code&gt;cb&lt;/code&gt; is still sitting in the queue, and it's still &lt;em&gt;armed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The next request comes in. Its reply arrives. &lt;code&gt;shift()&lt;/code&gt; hands it to the armed corpse of a request that was never sent. &lt;code&gt;resolved&lt;/code&gt; is false, so it consumes the line, resolves a promise nobody is listening to, and &lt;strong&gt;the caller who actually sent a request gets nothing&lt;/strong&gt; — it waits out its full timeout, and leaves its own callback behind when it does.&lt;/p&gt;

&lt;p&gt;That's not an off-by-one. That's a cascade. One failed write poisons the channel for the lifetime of the daemon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it instead of believing it
&lt;/h2&gt;

&lt;p&gt;I've been wrong about my own concurrency code before, so I stopped arguing with myself and modeled it — a positional queue, the same &lt;code&gt;resolved&lt;/code&gt; guard, the two abandonment paths, pre-fix and post-fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PRE-FIX   timeout   → B got: reply-to-B     ← already correct!
PRE-FIX   writefail → B got: TIMEOUT(B)     ← B's reply eaten by A's armed callback
POST-FIX  timeout   → B got: reply-to-B
POST-FIX  writefail → B got: reply-to-B     ← fixed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. The timeout path was &lt;strong&gt;never broken.&lt;/strong&gt; The patch's headline change — replacing the abandoned callback with an explicit no-op consumer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;   &lt;span class="c1"&gt;// no-op consumer for the late reply&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...replaces a callback that was &lt;em&gt;already&lt;/em&gt; a no-op consumer (because &lt;code&gt;cb&lt;/code&gt; self-guards on &lt;code&gt;resolved&lt;/code&gt;) with a callback that is &lt;em&gt;visibly&lt;/em&gt; a no-op consumer. Zero behavior change. It's a comment that happens to compile.&lt;/p&gt;

&lt;p&gt;The fix is the other hunk, the quiet one in the &lt;code&gt;catch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&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;// never sent → nothing is coming → drop the slot&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And note that the two hunks do &lt;strong&gt;opposite&lt;/strong&gt; things — one &lt;em&gt;keeps&lt;/em&gt; the slot, one &lt;em&gt;removes&lt;/em&gt; it — for reasons that are entirely non-obvious unless you have the positional invariant in your head. Swap them and you've built a worse bug than the one you set out to fix: &lt;code&gt;splice&lt;/code&gt; on timeout means the late reply gets shifted onto the next caller, who now gets someone else's answer and believes it. He got that distinction right in both directions, on his first try, in code he didn't write. That's the part I'm actually impressed by.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reason nobody could see it
&lt;/h2&gt;

&lt;p&gt;Here's what I found when I grepped my own file for this pattern. Seven helper functions. Every one of them open-codes the same request/timeout/queue dance. Four carry some version of the no-op-consumer line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="mi"&gt;764&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;_helperConsecutiveTimeouts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// late reply ⇒ alive&lt;/span&gt;
&lt;span class="mi"&gt;825&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;   &lt;span class="c1"&gt;// no-op consumer for a late reply&lt;/span&gt;
&lt;span class="mi"&gt;863&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;   &lt;span class="c1"&gt;// No-op consumer for late response&lt;/span&gt;
&lt;span class="mi"&gt;912&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;_helperQueue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three don't. Those three are exactly the three he patched.&lt;/p&gt;

&lt;p&gt;So the honest reading isn't "I forgot the fix in three places." It's worse and more interesting: &lt;strong&gt;I wrote a defensive line four times without ever writing down what it defends against&lt;/strong&gt;, and in the three functions where I skipped it, the code was &lt;em&gt;accidentally fine anyway&lt;/em&gt; — while a genuinely broken path sat two lines below, in every single one of the seven.&lt;/p&gt;

&lt;p&gt;The invariant — &lt;em&gt;an abandoned slot must consume exactly one reply, unless nothing was ever sent&lt;/em&gt; — is stated in exactly zero of those seven functions. Not in a comment, not in a name, not in a type. It lives only in whatever I happened to be holding in my head on the afternoon I wrote each one. A contributor reading this file has no way to check his patch against the rule, because the rule isn't there. He had to reconstruct it from the wreckage — and he reconstructed it correctly, which is why his &lt;code&gt;catch&lt;/code&gt; hunk lands even though his description of &lt;em&gt;why&lt;/em&gt; doesn't.&lt;/p&gt;

&lt;p&gt;That's the failure. Not the missing line. The missing &lt;strong&gt;place to put the line.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy-paste isn't a style problem, it's a memory problem
&lt;/h2&gt;

&lt;p&gt;I don't care much about DRY as an aesthetic. A little duplication beats a bad abstraction, and I'll take three copies of a five-line function over an inheritance hierarchy every day of the week.&lt;/p&gt;

&lt;p&gt;But that argument is about &lt;em&gt;code you can see&lt;/em&gt;. This is about something else:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A rule that exists in N copies has to be &lt;strong&gt;re-derived from scratch&lt;/strong&gt; by whoever writes copy N+1 — including you, next year, at 11pm.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Seven functions, seven independent recollections of an unwritten rule. I got the cosmetic half right four times out of seven and the load-bearing half wrong seven times out of seven. That is not a discipline problem you fix by being more careful. It's a coin flip you have to win every time, forever, and the odds get worse with every function added.&lt;/p&gt;

&lt;p&gt;The correct shape was always one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The helper protocol is POSITIONAL — replies are matched to requests by queue order,&lt;/span&gt;
&lt;span class="c1"&gt;// not by id. So an abandoned request must leave the queue in a consistent state:&lt;/span&gt;
&lt;span class="c1"&gt;//   • timed out  → a reply IS still coming → leave a no-op consumer to eat exactly one line&lt;/span&gt;
&lt;span class="c1"&gt;//   • write failed → nothing was ever sent → remove the slot entirely&lt;/span&gt;
&lt;span class="c1"&gt;// Get this backwards and every subsequent reply goes to the wrong caller, silently, forever.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_helperRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onLine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbandon&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven callers, one invariant, stated once, in the only place where it can be found by the person who needs it. Copy eight — written by someone who never read this article — gets it for free.&lt;/p&gt;

&lt;p&gt;That refactor is mine to do, and it's the actual fix. His patch fixed the three instances. The reason there were three instances to fix is still sitting in my file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this says about test suites
&lt;/h2&gt;

&lt;p&gt;54 tests on this file. All green. Not one of them ever failed a write.&lt;/p&gt;

&lt;p&gt;Of course they didn't — you have to go out of your way to break a pipe in a test, and nobody writes that test until the bug that needs it has already shipped. The bug lived in the intersection of two things a test suite naturally avoids: the error path of the transport, and &lt;em&gt;state that outlives a single call&lt;/em&gt;. My tests all assert what one request returns. The bug is only visible in what the &lt;strong&gt;next&lt;/strong&gt; one returns.&lt;/p&gt;

&lt;p&gt;He didn't find it by reading my code looking for bugs. He found it because &lt;code&gt;safari_new_tab&lt;/code&gt; hung on his machine, and he kept going past the symptom until he hit a queue that was one slot off.&lt;/p&gt;




&lt;p&gt;Do you have a positional protocol in production — FIFO correlation, no request IDs? I want to know whether "an abandoned slot must eat exactly one reply" is folklore that everyone rediscovers the hard way, or whether the real lesson is just &lt;em&gt;don't build a protocol without request IDs&lt;/em&gt;. Because I'm now fairly sure the invariant I'm about to carefully centralize is one I shouldn't need to have at all.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;achiya-automation/safari-mcp&lt;/a&gt; — a Safari MCP server that drives your real, logged-in browser. The PR is &lt;a href="https://github.com/achiya-automation/safari-mcp/pull/53" rel="noopener noreferrer"&gt;#53&lt;/a&gt;, by &lt;a href="https://github.com/jrepp" rel="noopener noreferrer"&gt;@jrepp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>debugging</category>
      <category>opensource</category>
    </item>
    <item>
      <title>One command adds import, WhatsApp drip and campaigns to self-hosted Chatwoot</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 05 Jul 2026 13:12:46 +0000</pubDate>
      <link>https://dev.to/achiya-automation/one-command-adds-import-whatsapp-drip-and-campaigns-to-self-hosted-chatwoot-mkh</link>
      <guid>https://dev.to/achiya-automation/one-command-adds-import-whatsapp-drip-and-campaigns-to-self-hosted-chatwoot-mkh</guid>
      <description>&lt;p&gt;I run a self-hosted &lt;a href="https://www.chatwoot.com/" rel="noopener noreferrer"&gt;Chatwoot&lt;/a&gt; for support, and I genuinely love it. But every time I wanted to actually &lt;em&gt;grow&lt;/em&gt; on it, I hit the same wall.&lt;/p&gt;

&lt;p&gt;Import a few thousand contacts? Hand-write API calls, or click them in one at a time. Run WhatsApp follow-up sequences? Not built in. Send a bulk campaign with a real variable preview, or attach a video over WhatsApp's 16 MB limit? Nope.&lt;/p&gt;

&lt;p&gt;And every off-the-shelf "fix" was the same shape: a separate SaaS, a second server, or a subdomain — each with its own login and its own copy of my customers' data.&lt;/p&gt;

&lt;p&gt;So I built the missing layer and open-sourced it: &lt;strong&gt;&lt;a href="https://github.com/achiya-automation/chatwoot-power-tools" rel="noopener noreferrer"&gt;chatwoot-power-tools&lt;/a&gt;&lt;/strong&gt; (MIT).&lt;/p&gt;

&lt;h2&gt;
  
  
  One command, same-origin, no second server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://github.com/achiya-automation/chatwoot-power-tools/archive/refs/heads/main.tar.gz | &lt;span class="nb"&gt;tar &lt;/span&gt;xz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;chatwoot-power-tools-main &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;bash install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer detects your existing Chatwoot Docker Compose stack, provisions a least-privilege DB role, starts one small sidecar container (&lt;code&gt;cwpt-engine&lt;/code&gt;) next to your own containers, adds a single reverse-proxy route, and injects a dashboard script. Everything it adds is served &lt;strong&gt;same-origin&lt;/strong&gt; under one &lt;code&gt;/chatwoot-addons/*&lt;/code&gt; path — no subdomain, no CORS, no extra account, and no customer data ever leaving your box.&lt;/p&gt;

&lt;p&gt;It's a plain, readable Bash installer (no opaque binary piped to root), it's &lt;code&gt;--dry-run&lt;/code&gt;-previewable, and &lt;code&gt;--uninstall&lt;/code&gt; reverses everything while preserving your data and any existing &lt;code&gt;DASHBOARD_SCRIPTS&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;📥 Smart contact import&lt;/strong&gt; — a CSV/Excel wizard styled to look native, detects columns bilingually (Hebrew + English headers), flags duplicates &lt;em&gt;before&lt;/em&gt; import, and maps onto custom attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🔁 WhatsApp drip sequences&lt;/strong&gt; — automated template-message sequences managed from inside Chatwoot. Enroll a lead by setting a conversation attribute; messages then send at the intervals you configure, automatically skipping quiet hours, Shabbat and Jewish holidays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✨ Dashboard upgrades&lt;/strong&gt; — a "Sequences" sidebar item, variable chips + a live preview on the native campaign modal, and client-side video compression (WebCodecs) so you can send video past WhatsApp's 16 MB limit with no server-side transcode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'm most proud of: least-privilege by design
&lt;/h2&gt;

&lt;p&gt;The engine talks to your Chatwoot only through the API, and its database role is deliberately tiny. It gets &lt;code&gt;SELECT&lt;/code&gt; on the handful of tables it reads, plus &lt;code&gt;UPDATE&lt;/code&gt; on a &lt;strong&gt;single column&lt;/strong&gt; — &lt;code&gt;contacts.custom_attributes&lt;/code&gt;. That's it.&lt;/p&gt;

&lt;p&gt;It literally &lt;em&gt;cannot&lt;/em&gt; read or change names, phones, emails, or anything else. A bug in the engine can't touch them, because the grant doesn't exist. The role's password is generated on your server with &lt;code&gt;openssl rand&lt;/code&gt; and written only to your Chatwoot &lt;code&gt;.env&lt;/code&gt; — it never enters logs, command output, or git. No telemetry, no third parties.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fully bilingual, automatically
&lt;/h2&gt;

&lt;p&gt;The entire UI localizes to each agent's own Chatwoot language — Hebrew (RTL) or English (LTR) — detected automatically, no configuration. Same screen, either way.&lt;/p&gt;

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

&lt;p&gt;It's MIT, self-hosted Docker Compose only (not Chatwoot Cloud), with a full CI suite. If you self-host Chatwoot, I'd genuinely love your feedback — and issues/PRs are welcome.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/achiya-automation/chatwoot-power-tools" rel="noopener noreferrer"&gt;github.com/achiya-automation/chatwoot-power-tools&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Still deciding between self-hosted and Cloud?
&lt;/h2&gt;

&lt;p&gt;This tool only makes sense if you're self-hosting, so it's worth saying plainly where that stops being the right call. Self-hosting is free and unlimited, but somebody has to own the server, the upgrades and the backups. If that somebody doesn't exist on your team, Chatwoot Cloud starts at $19/agent/month and the Hacker tier is free for up to 2 agents — I wrote up &lt;a href="https://achiya-automation.com/en/blog/chatwoot-vs-intercom/" rel="noopener noreferrer"&gt;the full pricing and where Intercom is genuinely the better buy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One warning if you go that route: search for a Chatwoot coupon and you'll find codes advertised at 20%, 50%, even 70% off. None of them work. Chatwoot's affiliate programme caps the customer discount at 5% — that's the ceiling, published on &lt;a href="https://www.chatwoot.com/affiliate-program" rel="noopener noreferrer"&gt;their own affiliate page&lt;/a&gt;. My code is &lt;code&gt;ACHIYAVS&lt;/code&gt; and it gives exactly that 5%, on monthly or yearly, on every paid tier.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: &lt;code&gt;ACHIYAVS&lt;/code&gt; is my affiliate code — I earn a commission if you use it, and you pay 5% less than list. Self-hosting with the tool above earns me nothing, and it's still what I'd recommend if you have the ops capacity.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>opensource</category>
      <category>docker</category>
      <category>whatsapp</category>
    </item>
    <item>
      <title>Exactly-Once by Default: How Durable Execution Changed the Way I Build Automations</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 02 Jul 2026 11:21:39 +0000</pubDate>
      <link>https://dev.to/achiya-automation/exactly-once-by-default-how-durable-execution-changed-the-way-i-build-automations-2gbm</link>
      <guid>https://dev.to/achiya-automation/exactly-once-by-default-how-durable-execution-changed-the-way-i-build-automations-2gbm</guid>
      <description>&lt;p&gt;In the &lt;a href="https://dev.to/achiya-automation/i-deleted-my-no-code-automation-platform-and-rewrote-34-workflows-in-typescript-emh"&gt;previous article&lt;/a&gt; I described moving 34 production automations off a visual no-code platform and rewriting them in TypeScript. The single feature that made that migration worth the effort was &lt;strong&gt;durable execution with exactly-once semantics&lt;/strong&gt;. This post is the deep-dive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a crash in the middle
&lt;/h2&gt;

&lt;p&gt;Here's a scenario every automation eventually hits. A workflow receives a new lead, sends them a welcome message, then writes them to the CRM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send welcome message&lt;/li&gt;
&lt;li&gt;Save to CRM&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now imagine the process crashes &lt;em&gt;exactly&lt;/em&gt; between step 1 and step 2 — a deploy, an OOM kill, a dropped node. What happens on restart?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-run the whole thing&lt;/strong&gt; → the lead gets the welcome message twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't re-run it&lt;/strong&gt; → the lead never lands in the CRM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both outcomes are wrong. This is the at-least-once vs at-most-once dilemma, and in a system doing real side effects (sending messages, charging cards, creating records) it is not academic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual fix, and why it hurts
&lt;/h2&gt;

&lt;p&gt;Most tools give you retry-on-failure. But retry alone re-runs side effects. To get &lt;em&gt;exactly-once&lt;/em&gt; you build it yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate an idempotency key per lead.&lt;/li&gt;
&lt;li&gt;Before each side effect, check "did I already do this?" against some store.&lt;/li&gt;
&lt;li&gt;Persist progress after each step so a restart knows where to resume.&lt;/li&gt;
&lt;li&gt;Repeat this bookkeeping for every workflow you ever write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It works, but it's tedious, easy to get subtly wrong, and it clutters every automation with plumbing that has nothing to do with the business logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How DBOS makes it the default
&lt;/h2&gt;

&lt;p&gt;DBOS flips this: durability is the baseline, not a feature you assemble. You annotate ordinary TypeScript functions. A &lt;strong&gt;workflow&lt;/strong&gt; orchestrates; &lt;strong&gt;steps&lt;/strong&gt; are the units that do side effects and get checkpointed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DBOS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@dbos-inc/dbos-sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Onboarding&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;DBOS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;welcomeLead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Lead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Onboarding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendWelcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// step 1&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Onboarding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveToCRM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// step 2&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;DBOS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;sendWelcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Lead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;whatsapp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Welcome aboard!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;DBOS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;saveToCRM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Lead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As the workflow runs, DBOS records the completion of each step in Postgres. From the docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If a workflow is interrupted for any reason (e.g., an executor restarts or crashes), when your program restarts the workflow automatically resumes execution from the last completed step."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And crucially:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Steps are tried at least once but are never re-executed after they complete."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So in our crash scenario: &lt;code&gt;sendWelcome&lt;/code&gt; already completed and was recorded. On restart, DBOS &lt;strong&gt;skips it&lt;/strong&gt; and resumes at &lt;code&gt;saveToCRM&lt;/code&gt;. The welcome message is not sent twice; the CRM write finally happens. Exactly-once, with zero idempotency bookkeeping in my code.&lt;/p&gt;

&lt;p&gt;No separate workflow server, no queue broker to babysit — just your program and Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule to internalize
&lt;/h2&gt;

&lt;p&gt;Durability isn't free magic — there's a contract. The &lt;strong&gt;workflow function must be deterministic&lt;/strong&gt;: given the same recorded step results, replaying it must take the same path. So anything non-deterministic — network calls, random values, reading the clock, DB writes — belongs &lt;strong&gt;inside a step&lt;/strong&gt;, never loose in the workflow body. Steps are the checkpointed boundary; the workflow is the recomposable script that ties them together.&lt;/p&gt;

&lt;p&gt;Once that clicks, the mental model is clean: &lt;em&gt;workflow = the plan, steps = the effects&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this replaced
&lt;/h2&gt;

&lt;p&gt;On the visual platform, I got retry and error branches, but exactly-once across a crash was something I had to design per flow — manual idempotency keys and "already done?" checks. Here it's the substrate. My code shrank to the business logic, and the reliability guarantee got &lt;em&gt;stronger&lt;/em&gt;, not weaker.&lt;/p&gt;

&lt;p&gt;That reliability is also what I sell to clients: fewer leads slipping through the cracks, no duplicate messages, no half-finished processes. (See the client-facing angle in the LinkedIn series.)&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on how I built it
&lt;/h2&gt;

&lt;p&gt;I'm one person, and wiring durable execution into 34 real automations is a lot of surface area. I did it in pairing with &lt;strong&gt;Claude Code&lt;/strong&gt; — it turned "I understand exactly-once in theory" into workflows running in production, TypeScript module by TypeScript module. The barrier between a concept and a shipped system is thinner than it's ever been.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://docs.dbos.dev/typescript/tutorials/workflow-tutorial" rel="noopener noreferrer"&gt;DBOS Workflows tutorial&lt;/a&gt; · &lt;a href="https://docs.dbos.dev/typescript/reference/workflows-steps" rel="noopener noreferrer"&gt;Workflows &amp;amp; Steps reference&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How do you handle mid-workflow crashes today — hand-rolled idempotency, an outbox, something else? Curious what patterns people have settled on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>backend</category>
      <category>tutorial</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Deleted My No-Code Automation Platform and Rewrote 34 Workflows in TypeScript</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 02 Jul 2026 11:20:45 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-deleted-my-no-code-automation-platform-and-rewrote-34-workflows-in-typescript-emh</link>
      <guid>https://dev.to/achiya-automation/i-deleted-my-no-code-automation-platform-and-rewrote-34-workflows-in-typescript-emh</guid>
      <description>&lt;p&gt;A few weeks ago I deleted two years of work.&lt;/p&gt;

&lt;p&gt;Thirty-four automations that run every day — for me and for my clients. WhatsApp AI bots, lead capture from Facebook and web forms, PDF report generation, calendar booking, CRM sync, notifications. All of them built the same way: box after box, dragged and dropped onto a visual canvas.&lt;/p&gt;

&lt;p&gt;I deleted the lot and rewrote them in code.&lt;/p&gt;

&lt;p&gt;This isn't a "no-code is bad" post. The visual platform I used (n8n) is genuinely excellent and open-source, and it let me ship fast for years. But as the system grew, I started fighting the tooling instead of building. So I want to walk through &lt;em&gt;why&lt;/em&gt; code-first won for my use case, the numbers that came out of it, and — importantly — the parts that did &lt;strong&gt;not&lt;/strong&gt; improve, because I have no interest in hype.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "code-first" actually changed
&lt;/h2&gt;

&lt;p&gt;Here's the before/after, minus the buzzwords.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenance.&lt;/strong&gt; Before, every change meant opening a browser, hunting for the right node inside a diagram, and dragging. Now every change is a line of code and a git commit. Full history, real diffs, code review, and instant rollback when something breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building.&lt;/strong&gt; Before, each new automation was assembled from scratch on the canvas. Now I compose typed, reusable modules. Faster, and consistent across the whole system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Control.&lt;/strong&gt; The logic is mine, written explicitly. No magic behind a box, no surprises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No lock-in.&lt;/strong&gt; It's standard code in an open format. It runs anywhere; it isn't married to any one platform's JSON schema.&lt;/p&gt;

&lt;p&gt;I built on &lt;strong&gt;DBOS&lt;/strong&gt; — a framework that runs workflows written as ordinary TypeScript, backed by Postgres, with durable execution built in. More on that below.&lt;/p&gt;

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

&lt;p&gt;The headline that still makes me smile:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory: from ~1.4GB down to ~150MB. Roughly 10×.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same 34 automations, same work, a tenth of the footprint. At idle the new stack sits around &lt;strong&gt;48MB&lt;/strong&gt;. A smaller, cheaper server now runs the same load with plenty of room to grow.&lt;/p&gt;

&lt;p&gt;A couple more measured figures:&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;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;~1,167 workflows/second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-workflow overhead&lt;/td&gt;
&lt;td&gt;~30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle memory&lt;/td&gt;
&lt;td&gt;~48MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory vs. old stack&lt;/td&gt;
&lt;td&gt;~150MB vs ~1.4GB (~10×)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where did the memory go before? To scale under load I had to add worker processes and a queue layer — each one eating more RAM. Now the base is so light that most of the time there's simply nothing to talk about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part (because I hate hype)
&lt;/h2&gt;

&lt;p&gt;The bots themselves are &lt;strong&gt;not&lt;/strong&gt; faster to respond. Here's why: when a bot talks to a large language model, the LLM sets the pace, not the infrastructure around it. The model's "thinking" takes what it takes, and no runtime swap changes that.&lt;/p&gt;

&lt;p&gt;So what did I actually gain? A server that breathes, far higher concurrency, and lower infrastructure cost. &lt;strong&gt;Stability and efficiency — not the latency of a single reply.&lt;/strong&gt; If someone tells you a runtime swap made their AI bot "instant," be skeptical.&lt;/p&gt;

&lt;h2&gt;
  
  
  How one person did all this
&lt;/h2&gt;

&lt;p&gt;Translating 34 live automations to code, keeping behavior identical, testing each one, and shipping to production without a single client noticing — that is not a one-person job. Historically it's a team, over weeks.&lt;/p&gt;

&lt;p&gt;I did it solo, working with &lt;strong&gt;Claude Code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In practice it was a dialogue with a tool that reads code like a senior engineer: it helped me understand each existing automation, rewrite it in TypeScript, catch bugs before production, and verify each flow against the original. I direct, decide, and approve; it does the heavy lifting, fast.&lt;/p&gt;

&lt;p&gt;And the migration itself was invisible to the outside world. A &lt;strong&gt;Caddy&lt;/strong&gt; reverse proxy routes all the inbound traffic — WhatsApp, webhooks, forms — so every sender kept hitting the same endpoints. I swapped the engine mid-flight; nobody felt a thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The real story here isn't DBOS versus n8n. It's that the ceiling moved.&lt;/p&gt;

&lt;p&gt;For years we all learned to work inside limits: "too complex to hand-write," "needs a team," "not worth the weeks." A lot of those limits just quietly disappeared, and our habits haven't caught up yet.&lt;/p&gt;

&lt;p&gt;So the question I keep asking myself now isn't "how do I do what I already do, a bit faster?" It's &lt;strong&gt;"what would I build if the technical barrier weren't there?"&lt;/strong&gt; Half of my someday-list turns out to be within reach today.&lt;/p&gt;

&lt;p&gt;Next article: a technical deep-dive into the one feature that made this migration worth it — exactly-once durable execution — with real code.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you moved something from no-code to code-first? What pushed you over the edge? I'd genuinely like to hear it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>automation</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Apple shipped an official Safari MCP. I read all 17 tools. Here's why I'm keeping mine.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 02 Jul 2026 07:15:08 +0000</pubDate>
      <link>https://dev.to/achiya-automation/apple-shipped-an-official-safari-mcp-i-read-all-17-tools-heres-why-im-keeping-mine-32l3</link>
      <guid>https://dev.to/achiya-automation/apple-shipped-an-official-safari-mcp-i-read-all-17-tools-heres-why-im-keeping-mine-32l3</guid>
      <description>&lt;p&gt;Apple shipped the tool I've spent a year building as open source.&lt;/p&gt;

&lt;p&gt;For about ten minutes on Tuesday night, I thought I was cooked. Safari Technology Preview 247 dropped with an official Safari MCP server, built by the WebKit team, the people who actually make the browser. I maintain a scrappy AppleScript version of the same idea. David, meet Goliath's in-house team.&lt;/p&gt;

&lt;p&gt;So I did the only thing that made sense. I read all 17 of their tools, one by one. Somewhere around tool #9 I stopped feeling cooked. By the end I knew I wasn't deleting my repo, and the reason is a single sentence Apple wrote themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, credit where it's due
&lt;/h2&gt;

&lt;p&gt;The design is clean. It runs on &lt;code&gt;safaridriver&lt;/code&gt;, the WebDriver binary already shipping inside Safari, so setup is one command. It runs entirely on your machine. No page content, no screenshots, nothing phones home to Apple. The 17 tools cover the boring-but-essential debugging loop: open a URL, read the DOM, click things, watch the network tab, grab the console, screenshot the result.&lt;/p&gt;

&lt;p&gt;If what you want is an agent that debugs how a page renders in WebKit, this is a first-party, well-built way to get it. And honestly? It's the best validation I could ask for. A year ago people asked me why anyone would let an AI drive a browser. Apple just answered that for me.&lt;/p&gt;

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

&lt;p&gt;Here's the line, straight from Apple's own docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The Safari MCP server does not have access to your personal information in Safari (e.g. AutoFill or other browser activity)."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that again if you build automation. &lt;code&gt;safaridriver&lt;/code&gt; spins up a clean, isolated WebDriver session. Fresh window. A "controlled by automation" banner across the top. None of your logins. None of your cookies. Not the twelve tabs you already have open.&lt;/p&gt;

&lt;p&gt;For a debugging tool, that's the right call. Reproducible, sandboxed, no personal state leaking into a test run. I'd have designed it the same way.&lt;/p&gt;

&lt;p&gt;It's also the exact problem I built my tool to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why mine exists at all
&lt;/h2&gt;

&lt;p&gt;I never built safari-mcp to debug rendering. I built it because I wanted an agent to drive the Safari I'm &lt;em&gt;already&lt;/em&gt; logged into: my Gmail, my GitHub, my Ahrefs dashboard, my bank. No re-auth, no fresh profile, no QR code. It runs on native AppleScript in the background, on the stable Safari you already have, on any Mac. No Technology Preview required.&lt;/p&gt;

&lt;p&gt;That's the whole difference. Apple's server opens a sterile room and hands your agent a key. Mine walks into the room you're already sitting in.&lt;/p&gt;

&lt;p&gt;Here's the honest scorecard I dropped into my README:&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;safari-mcp&lt;/th&gt;
&lt;th&gt;Apple &lt;code&gt;safaridriver --mcp&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your real logins / cookies&lt;/td&gt;
&lt;td&gt;✅ Your actual Safari&lt;/td&gt;
&lt;td&gt;⚠️ Isolated automation session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on&lt;/td&gt;
&lt;td&gt;✅ Stable Safari, every Mac&lt;/td&gt;
&lt;td&gt;❌ Technology Preview 247 only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background, no focus steal&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Dedicated automation window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;~17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookies / localStorage / IndexedDB&lt;/td&gt;
&lt;td&gt;✅ 10 tools&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network mocking + throttling&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Read-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official Apple support&lt;/td&gt;
&lt;td&gt;❌ Community, MIT&lt;/td&gt;
&lt;td&gt;✅ Apple, WebDriver-standard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I checked two of those rows on my own machine before writing this, because I didn't want to bluff. Stable &lt;code&gt;safaridriver&lt;/code&gt; in Safari 26.5 has &lt;code&gt;--port&lt;/code&gt;, &lt;code&gt;--bidi&lt;/code&gt;, &lt;code&gt;--enable&lt;/code&gt;, &lt;code&gt;--diagnose&lt;/code&gt;. No &lt;code&gt;--mcp&lt;/code&gt;. It only exists in the Preview today. And the isolated session really does mean no logins. That's the wall, and I built safari-mcp to climb over it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So am I changing anything? Yes. Three things, none of them "switch."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Positioning.&lt;/strong&gt; Apple didn't kill safari-mcp. They told the world what it's for. Theirs is the clean-room debugger; mine drives the browser you live in. I rewrote my README to say that out loud instead of pretending we compete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A safaridriver backend, eventually.&lt;/strong&gt; My tool already runs two engines under the hood, a Safari extension and AppleScript. Bolting on a third opt-in WebDriver backend, for people who genuinely want a sterile session, isn't a rewrite. It's a weekend. But it waits until &lt;code&gt;--mcp&lt;/code&gt; reaches stable Safari, because right now it only lives in the Preview.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stealing their best idea.&lt;/strong&gt; WebDriver synthesizes input events the official, rock-solid way. My native-click path leans on CGEvent, which macOS quietly breaks every other release (ask me how I know). Apple just handed me a sturdier fallback. I'd be silly not to take it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;When the company that makes the browser ships an official version of your side project, the reflex is to panic. Don't. "Official" and "replacement" are different words. I read all 17 tools before I reacted, and the honest comparison turned out to be better marketing than any launch-day panic post could have been.&lt;/p&gt;

&lt;p&gt;Apple built the sterile room. I built the tool for the room you already live in. Both should exist.&lt;/p&gt;

&lt;p&gt;I just know which one I'll actually reach for on a Tuesday.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;safari-mcp is open source (MIT): &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;github.com/achiya-automation/safari-mcp&lt;/a&gt;. 96 tools, &lt;code&gt;npx safari-mcp&lt;/code&gt;, no Chrome, native macOS. I write about the things I build at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Genuine question, because I keep flip-flopping on it: would you ever trade your logged-in browser for a clean automation session? What would have to be true to make that worth it?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>opensource</category>
      <category>apple</category>
    </item>
    <item>
      <title>My MCP server had 32 green tests. Not one of them had ever called a tool.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 29 Jun 2026 07:18:15 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-mcp-server-had-32-green-tests-not-one-of-them-had-ever-called-a-tool-4jp8</link>
      <guid>https://dev.to/achiya-automation/my-mcp-server-had-32-green-tests-not-one-of-them-had-ever-called-a-tool-4jp8</guid>
      <description>&lt;p&gt;For weeks my CI badge was green and I believed it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt; is an open-source tool that lets an AI coding agent drive a real, logged-in Safari — click, type, read the page, switch tabs. It registers 96 tools. The test suite ran on every push across three Node versions and came back &lt;strong&gt;32 passed, 0 failed.&lt;/strong&gt; Green is green. I shipped on it.&lt;/p&gt;

&lt;p&gt;Then I went to extract a chunk of &lt;code&gt;index.js&lt;/code&gt; into its own module, and while staring at the diff I asked a question I should have asked months earlier:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Which of these 32 tests would fail if I broke the security boundary?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer was &lt;strong&gt;none of them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests actually tested
&lt;/h2&gt;

&lt;p&gt;I read the suite line by line. Two of the tests carried almost all the weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;server starts and lists all registered tools&lt;/code&gt; — boots the server, asserts the tool count.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;valid schemas + unique names&lt;/code&gt; — every tool has a schema, no duplicate names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest were string-escaping and JS-injection helpers. All useful. All real. And all of them answered the same kind of question: &lt;strong&gt;does the thing exist and is it shaped correctly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not one of them answered: &lt;strong&gt;does calling it do the right thing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's the test-suite equivalent of checking into a hotel by confirming the building has 96 doors with correct room numbers — and never once trying a key in a lock.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary nobody was watching
&lt;/h2&gt;

&lt;p&gt;The most security-critical code in Safari MCP is tab ownership. The rule is simple to say and easy to get subtly wrong: the agent may only touch tabs &lt;em&gt;it&lt;/em&gt; opened. It must never navigate, click, or read a tab the human opened — that's someone's half-written email, their banking session, their unsaved work.&lt;/p&gt;

&lt;p&gt;That logic lived in a tangle of module-local state: a map of owned tabs, a TTL so stale entries expire, a blank-URL sentinel for tabs mid-load, a matcher that decides whether &lt;code&gt;https://app.example.com/org&lt;/code&gt; is "the same" tab as &lt;code&gt;https://app.example.com/org-evil&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Read that last one again. &lt;code&gt;/org&lt;/code&gt; vs &lt;code&gt;/org-evil&lt;/code&gt;. If the matcher is even slightly too loose — a &lt;code&gt;startsWith&lt;/code&gt; where it needed a path-boundary check — the agent could decide it "owns" a look-alike tab and start typing into it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There was not a single test exercising that comparison.&lt;/strong&gt; The suite was 100% green the whole time the security boundary had zero behavioral coverage. A regression there wouldn't have turned CI red. It would have turned CI &lt;em&gt;green and wrong&lt;/em&gt; — the worst color a test suite can be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "green and wrong" is worse than red
&lt;/h2&gt;

&lt;p&gt;A red build is honest. It stops you. The failure is the feature.&lt;/p&gt;

&lt;p&gt;A green build that proves nothing gives you the &lt;em&gt;feeling&lt;/em&gt; of safety without the substance — and you make decisions on that feeling. You refactor confidently. You merge contributor PRs confidently. You tell users the boundary holds. Every one of those is a small bet placed on a test that was never actually watching the thing you care about.&lt;/p&gt;

&lt;p&gt;This is the same failure mode I keep running into in this project, wearing a different costume each time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A macOS API that &lt;a href="https://dev.to/achiya-automation/my-tool-said-clicked-safari-never-saw-it-macos-26-quietly-broke-a-system-api-1f3e"&gt;accepted my click and silently delivered it nowhere&lt;/a&gt; — "success" that did nothing.&lt;/li&gt;
&lt;li&gt;A README that &lt;a href="https://dev.to/achiya-automation/my-readme-said-80-tools-my-code-had-96-nobody-noticed-for-weeks-1f3e"&gt;claimed 80 tools while the code had 96&lt;/a&gt; — a fact nobody had pinned down.&lt;/li&gt;
&lt;li&gt;And now a test suite that reported confidence it hadn't earned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is always the same: &lt;strong&gt;the system doesn't fail loudly. It quietly does less, and the signal you're trusting keeps saying "fine."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: make the boundary fail loudly
&lt;/h2&gt;

&lt;p&gt;Before extracting anything, I wrote the tests that should have existed from day one — behavioral tests that &lt;em&gt;call the ownership logic and assert on its decisions&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/org&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; own &lt;code&gt;/org-evil&lt;/code&gt; (the path-boundary case).&lt;/li&gt;
&lt;li&gt;An entry past its TTL is no longer owned.&lt;/li&gt;
&lt;li&gt;The blank-URL sentinel is treated as in-flight, not as a match.&lt;/li&gt;
&lt;li&gt;Ownership survives the kind of state round-trip the refactor was about to perform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nine of them. The suite went from 32 to 41 (it's 46 today, after a later round for macOS compatibility). More importantly: now if I loosen that matcher by one character, a test goes red and &lt;em&gt;names the boundary in the failure message.&lt;/em&gt; The security rule finally has a tripwire.&lt;/p&gt;

&lt;p&gt;Only &lt;strong&gt;then&lt;/strong&gt; did I do the refactor — extract the state layer into its own module — and the new tests held identity across the move, which is exactly the confidence I'd been pretending to have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I'd give past-me
&lt;/h2&gt;

&lt;p&gt;Counting your tools is not testing your tools. Schema validation is not behavior. A green suite tells you what it checks — and stays silent about everything it doesn't, in the most reassuring tone possible.&lt;/p&gt;

&lt;p&gt;So the question to ask of any test suite, especially one you've been trusting:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is the single worst thing that could break in this codebase — and would a test go red if it did?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the honest answer is "no," your CI badge isn't lying. You just never asked it the right question.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Safari MCP is open source — the ownership tests are in &lt;a href="https://github.com/achiya-automation/safari-mcp/blob/main/test/ownership-state.test.mjs" rel="noopener noreferrer"&gt;&lt;code&gt;test/ownership-state.test.mjs&lt;/code&gt;&lt;/a&gt; if you want to see what "test the boundary" looks like in practice. More on what I'm building at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the security boundary in your project that your test suite has never once exercised?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>opensource</category>
      <category>mcp</category>
      <category>devtools</category>
    </item>
    <item>
      <title>My tool said "clicked." Safari never saw it. macOS 26 quietly broke a system API.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 25 Jun 2026 15:04:26 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-tool-said-clicked-safari-never-saw-it-macos-26-quietly-broke-a-system-api-1gei</link>
      <guid>https://dev.to/achiya-automation/my-tool-said-clicked-safari-never-saw-it-macos-26-quietly-broke-a-system-api-1gei</guid>
      <description>&lt;p&gt;I maintain an open-source MCP server that lets AI coding agents drive real Safari on macOS. One of its tools sends a &lt;em&gt;native&lt;/em&gt; mouse click — an OS-level &lt;code&gt;CGEvent&lt;/code&gt;, not a JavaScript &lt;code&gt;element.click()&lt;/code&gt; — because some forms (Vue/React with anti-bot checks, OAuth consent screens) reject anything that isn't &lt;code&gt;isTrusted: true&lt;/code&gt;. For two years that tool worked.&lt;/p&gt;

&lt;p&gt;Then a user on macOS 26 filed a bug, and it took me an embarrassingly long time to believe what I was reading:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The MCP returns &lt;code&gt;Native clicked: BUTTON "Next" at screen (x, y)&lt;/code&gt;. But the click listener on the page never fires. &lt;code&gt;window.__clicks&lt;/code&gt; stays empty.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tool said it clicked. The page swears nothing happened. &lt;strong&gt;Both were telling the truth.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The most expensive kind of bug: the one that succeeds
&lt;/h2&gt;

&lt;p&gt;Here's the failure mode that cost me a weekend. The API call &lt;em&gt;returned success.&lt;/em&gt; No exception, no error code, no permission dialog. &lt;code&gt;CGEvent.postToPid(safariPID)&lt;/code&gt; took my event, said "sure," and dropped it on the floor.&lt;/p&gt;

&lt;p&gt;A bug that throws is a gift — it points at itself. A bug that silently succeeds sends you hunting everywhere except the actual cause. So I hunted.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility permission?&lt;/strong&gt; Granted. Verified &lt;code&gt;auth_value=2&lt;/code&gt; in the TCC database for the exact helper binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code-signing identity stable?&lt;/strong&gt; Yes — signed with a fixed identifier so the grant survives reinstalls. (An earlier macOS bug &lt;em&gt;had&lt;/em&gt; silently revoked it; I'd already fixed that.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coordinates wrong?&lt;/strong&gt; No. &lt;code&gt;document.elementFromPoint(x, y)&lt;/code&gt; returned the exact &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; I was aiming at, to within a pixel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did Apple remove the private window-targeting fields?&lt;/strong&gt; No. &lt;code&gt;kCGMouseEventWindowUnderMousePointer&lt;/code&gt; and its can-handle-this-event sibling are &lt;em&gt;still public&lt;/em&gt; in the macOS 26.5 SDK headers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single thing that's supposed to make a synthetic click land was correct. And the click still didn't land.&lt;/p&gt;

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

&lt;p&gt;macOS 26 (Tahoe) tightened the &lt;em&gt;delivery&lt;/em&gt; semantics of &lt;code&gt;CGEvent.postToPid&lt;/code&gt; for processes that render sandboxed WebKit content. The private fields are still accepted at the API surface — that's why there's no error — but the event never crosses the boundary into Safari's &lt;code&gt;WebContent&lt;/code&gt; process. It's authorized, it's well-formed, and it goes nowhere.&lt;/p&gt;

&lt;p&gt;This is the gap that breaks debugging: the &lt;strong&gt;API contract&lt;/strong&gt; ("post this event to that PID") still holds, while the &lt;strong&gt;behavioral contract&lt;/strong&gt; ("and the target will receive it") quietly does not. Your code is correct against the documentation. The documentation is correct about the API. Neither is correct about reality on this OS version.&lt;/p&gt;

&lt;p&gt;And nothing in the stack tells you which macOS you're on, because for two years it never mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix wasn't a permission. It was a fact.
&lt;/h2&gt;

&lt;p&gt;My first instinct was wrong: keep chasing the grant. Try a different event tap. Re-sign the binary again. That's the trap — treating an OS behavior change as a misconfiguration you can fix with one more checkbox.&lt;/p&gt;

&lt;p&gt;The real fix was to stop pretending the environment is uniform and &lt;strong&gt;surface the one fact that disambiguates the whole bug class&lt;/strong&gt;: the macOS version itself.&lt;/p&gt;

&lt;p&gt;My server has a &lt;code&gt;doctor&lt;/code&gt; command — run it first when "clicks don't work even with permissions granted." It checked Safari, Apple Events, the helper daemon, Accessibility, Screen Recording, codesigning… and never printed the OS version. The single most relevant number for a "native input silently fails" report was missing.&lt;/p&gt;

&lt;p&gt;So I added a small, pure function — no I/O, unit-tested directly — that classifies the version and flags the risky range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;macosCompatNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;major&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;risky&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;macOS version: unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;risky&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;risky&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`macOS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ⚠ CGEvent native clicks/keys may silently no-op on `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`macOS 26+ even with Accessibility granted (issue #29) — for trust-gated `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`forms prefer JS evaluation or extension-based clicks.`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`macOS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — CGEvent native input supported.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;risky&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&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;Then &lt;code&gt;doctor&lt;/code&gt; calls it as &lt;em&gt;best-effort&lt;/em&gt; — &lt;code&gt;sw_vers&lt;/code&gt; is macOS-only and absent in CI sandboxes, so it's wrapped in a &lt;code&gt;try/catch&lt;/code&gt; that can never block the rest of the diagnostics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stdout&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;execFileAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sw_vers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-productVersion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;osLine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;macosCompatNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* sw_vers unavailable — skip the line, the other checks still stand */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire change. It doesn't &lt;em&gt;fix&lt;/em&gt; the regression — I can't patch Apple's event delivery. What it does is convert a multi-hour phantom-permission hunt into a single line at the top of the diagnostic output: &lt;em&gt;you're on a version where this API path is known to no-op; reach for the JavaScript or extension path instead.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I keep relearning
&lt;/h2&gt;

&lt;p&gt;Three things stuck:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A success that does nothing is worse than a failure that screams.&lt;/strong&gt; When you wrap a platform API, the dangerous case isn't the one that errors — it's the one that returns OK and silently misbehaves. Assume your dependencies can lie politely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Put the environment in the diagnostics.&lt;/strong&gt; Every "works on my machine" bug is really "my machine differs from yours in a way neither of us is looking at." The cheapest fix is to make your tool &lt;em&gt;print the difference.&lt;/em&gt; The OS version cost me a weekend precisely because nothing surfaced it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Detect-and-warn beats assume-and-fail.&lt;/strong&gt; I can't make &lt;code&gt;postToPid&lt;/code&gt; work on Tahoe. I &lt;em&gt;can&lt;/em&gt; make sure nobody else spends a weekend re-deriving why it doesn't.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The native click still doesn't land on macOS 26 — that's Apple's to change, and I'm tracking it. But now the very first thing the tool tells you is the truth about where you're standing. Sometimes the best you can ship isn't a fix. It's an honest map.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is from &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt;, an open-source MCP server for native Safari automation on macOS (no Chrome, no WebDriver). The full &lt;code&gt;macosCompatNote&lt;/code&gt; + &lt;code&gt;doctor&lt;/code&gt; change is on &lt;code&gt;main&lt;/code&gt;. I write about the unglamorous edges of browser automation and indie automation work at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the worst "the API said success but did nothing" bug you've hit — and how long before you stopped blaming your own code?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>debugging</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>How Far You Can Actually Customize Chatwoot (Self-Hosted)</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 24 Jun 2026 12:17:44 +0000</pubDate>
      <link>https://dev.to/achiya-automation/how-far-you-can-actually-customize-chatwoot-self-hosted-2nif</link>
      <guid>https://dev.to/achiya-automation/how-far-you-can-actually-customize-chatwoot-self-hosted-2nif</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://achiya-automation.com/en/blog/chatwoot-customization/" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most "customer support platform" comparisons end the same way: pick a SaaS, accept its limits, pay per seat forever. This one doesn't. Chatwoot is open-source, and when you self-host it, "customization" stops meaning "which toggles did the vendor expose" and starts meaning "what does your team actually need." I've been running Chatwoot in production for clients long enough to know where that line really is — so here's the honest map of how far you can push it.&lt;/p&gt;

&lt;p&gt;There's a specific reason I keep recommending Chatwoot over a closed platform like Intercom, and it isn't price. It's that with a closed SaaS, your customization ceiling is whatever the vendor decided to put in the settings page. With self-hosted Chatwoot you have the source code, the database, and the deployment — so the ceiling is essentially "what can you build." This article is a practical tour of that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Widget&lt;/strong&gt; — colors, position, locale, behavior, and custom CSS are all configurable; on self-hosted you can patch the widget strings and launcher icon directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;White-label&lt;/strong&gt; — &lt;code&gt;INSTALLATION_NAME&lt;/code&gt;, &lt;code&gt;BRAND_NAME&lt;/code&gt;, &lt;code&gt;BRAND_URL&lt;/code&gt; remove Chatwoot branding (self-hosted only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two APIs&lt;/strong&gt; — the &lt;strong&gt;Application API&lt;/strong&gt; works inside one account (conversations, contacts, messages); the &lt;strong&gt;Platform API&lt;/strong&gt; manages accounts, users, and bots across the whole install&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks&lt;/strong&gt; — events like &lt;code&gt;conversation_created&lt;/code&gt; and &lt;code&gt;message_created&lt;/code&gt; push to n8n / your CRM / custom logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard apps&lt;/strong&gt; — embed your own web app inside the agent inbox via an iframe, with conversation context passed in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted unlocks&lt;/strong&gt; — direct DB access, custom code/forks, no API rate limits, custom channels, full rebranding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A note before the technical part: every fact below maps to Chatwoot's official developer documentation at &lt;a href="https://developers.chatwoot.com" rel="noopener noreferrer"&gt;developers.chatwoot.com&lt;/a&gt;. I run this stack daily, but I'd rather you trust the docs than trust me — so check anything load-bearing for your project against them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Widget Customization
&lt;/h2&gt;

&lt;p&gt;The website live-chat widget is the most visible surface, and it's also the easiest to bend. The standard embed is the Chatwoot SDK script plus a settings object you define &lt;strong&gt;before&lt;/strong&gt; the script loads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chatwootSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// any locale Chatwoot ships, e.g. "he", "ar", "fr"&lt;/span&gt;
    &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// "left" or "right"&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expanded_bubble&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// "standard" or "expanded_bubble"&lt;/span&gt;
    &lt;span class="na"&gt;launcherTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Chat with us&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;darkMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// "light" or "auto"&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.chatwoot.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// your self-hosted URL&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementsByTagName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/packs/js/sdk.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chatwootSDK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;websiteToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_WEBSITE_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The widget color and avatar come from the inbox settings in the dashboard, so non-developers can change them without touching code. Beyond that, the SDK gives you runtime methods to make the widget &lt;em&gt;aware of who's chatting&lt;/em&gt; — which is where it stops being a generic bubble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Identify the logged-in user so conversations attach to the right contact&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$chatwoot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USER_IDENTIFIER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Jane Doe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jane@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Attach structured context that shows up on the agent side&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$chatwoot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomAttributes&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;business&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signup_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-01-15&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Route or segment by setting a conversation label&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$chatwoot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Switch locale at runtime (e.g. when the user changes site language)&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$chatwoot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;he&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;setCustomAttributes&lt;/code&gt; call is the underrated one: whatever you pass shows up next to the conversation for the agent, so support sees the customer's plan, account age, or cart value without asking. On a closed platform you'd be limited to whatever attributes the vendor supports; here it's just key-value data you define.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where self-hosting changes the game:&lt;/strong&gt; the widget strings and the launcher icon are part of the source. On the installs I run, the default widget copy assumed a &lt;em&gt;team&lt;/em&gt; ("we're online"), but the business is a solo operator — so the right phrasing was singular ("I'm online"). On Cloud you'd file a feature request and wait. On self-hosted I patched the locale file and swapped the launcher SVG with an idempotent script that re-applies automatically on every container start, so it survives version upgrades with zero manual steps. That's the difference between &lt;em&gt;configuring&lt;/em&gt; and &lt;em&gt;owning&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. White-Label / Rebranding
&lt;/h2&gt;

&lt;p&gt;This is the cleanest example of "self-hosted only." Chatwoot exposes branding through environment variables:&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;# Replaces the product name across the dashboard and system emails&lt;/span&gt;
&lt;span class="nv"&gt;INSTALLATION_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Acme Support"&lt;/span&gt;

&lt;span class="c"&gt;# Brand name + URL used in branding references&lt;/span&gt;
&lt;span class="nv"&gt;BRAND_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Acme"&lt;/span&gt;
&lt;span class="nv"&gt;BRAND_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://acme.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set those, restart, and the dashboard, the page titles, and the transactional emails reflect &lt;em&gt;your&lt;/em&gt; brand instead of Chatwoot's. For an agency reselling support-as-a-service, or a company that simply doesn't want a third-party product name in front of staff and customers, this matters. Combined with a custom domain and the widget tweaks above, a client never has to know which open-source project is underneath.&lt;/p&gt;

&lt;p&gt;To be precise about the boundary: &lt;strong&gt;full rebranding is a self-hosted capability.&lt;/strong&gt; Chatwoot Cloud keeps its own branding — which is fair, it's their hosted product. If white-label is a hard requirement, that requirement alone decides self-hosted for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Application API vs Platform API
&lt;/h2&gt;

&lt;p&gt;This distinction trips people up, so it's worth getting right because it determines what you can automate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Application API&lt;/strong&gt; (you'll also see it called the Client/Agent API) is authenticated with a &lt;strong&gt;user access token&lt;/strong&gt; and operates &lt;strong&gt;inside a single account&lt;/strong&gt;. It's the workhorse for integrations — listing and creating conversations, sending messages, managing contacts, applying labels, reading reports. Example: posting an outgoing message into a conversation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://app.chatwoot.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api_access_token: USER_ACCESS_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "content": "Thanks for reaching out — an agent will be with you shortly.",
    "message_type": "outgoing"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Platform API&lt;/strong&gt; sits a level above accounts. It's authenticated with a &lt;strong&gt;Platform App access token&lt;/strong&gt; and is used to create and manage &lt;strong&gt;accounts, users, and agent bots across the whole installation&lt;/strong&gt;. This is what you reach for when you're provisioning tenants — for example, spinning up a fresh account for every new client and creating their first admin user programmatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://app.chatwoot.com/platform/api/v1/accounts"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"api_access_token: PLATFORM_APP_TOKEN"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "New Client Workspace" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mental model: &lt;strong&gt;Application API = act within an account; Platform API = manage the accounts themselves.&lt;/strong&gt; Most day-to-day automation lives in the Application API. The Platform API is the multi-tenant / provisioning layer — and having it at all is part of why Chatwoot scales from "one inbox" to "a platform you run for many clients."&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Webhooks and Automation
&lt;/h2&gt;

&lt;p&gt;Webhooks are the integration backbone. Chatwoot can fire an HTTP POST on account events — &lt;code&gt;conversation_created&lt;/code&gt;, &lt;code&gt;message_created&lt;/code&gt;, &lt;code&gt;conversation_status_changed&lt;/code&gt;, &lt;code&gt;conversation_updated&lt;/code&gt;, and more — to any URL you register. That's the hook that lets external logic react to what's happening in the inbox.&lt;/p&gt;

&lt;p&gt;The pattern I use constantly: Chatwoot → &lt;a href="https://n8n.io/get-started/?ref=achiya" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; → back into Chatwoot.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;message_created&lt;/code&gt; webhook hits an n8n webhook node.&lt;/li&gt;
&lt;li&gt;n8n inspects the payload (sender, content, conversation, inbox), runs whatever logic the client needs — classify intent, look something up in a CRM, call an LLM.&lt;/li&gt;
&lt;li&gt;n8n calls the &lt;strong&gt;Application API&lt;/strong&gt; to post the reply, add a label, or update a contact attribute.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That loop is how you build an AI first-responder, a CRM sync, or a routing rule that's smarter than anything a settings page offers — without a vendor-specific "app." Because it's just webhooks and REST, the same approach connects Chatwoot to &lt;a href="https://n8n.io/get-started/?ref=achiya" rel="noopener noreferrer"&gt;n8n&lt;/a&gt;, Make, your own backend, or all three.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automation rules and macros (no code required)
&lt;/h3&gt;

&lt;p&gt;Not everything needs a webhook. Chatwoot ships two built-in tools that cover a lot of ground:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automation rules&lt;/strong&gt; — event-driven &lt;em&gt;if/then&lt;/em&gt; logic configured in the dashboard. On conversation creation you can auto-assign to a team, add a label, send a canned reply, or set an attribute based on conditions. No code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Macros&lt;/strong&gt; — a saved sequence of actions an agent runs with one click (e.g. "send the refund template, label it &lt;code&gt;billing&lt;/code&gt;, resolve"). Great for repetitive multi-step handling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule of thumb: use automation rules and macros for in-app workflow, and reach for webhooks + the API when you need external data or real logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Custom Integrations and Dashboard Apps
&lt;/h2&gt;

&lt;p&gt;This is the feature that most surprises people coming from closed platforms. Chatwoot lets you embed &lt;strong&gt;your own web application inside the agent dashboard&lt;/strong&gt; as a Dashboard App. You register a URL, and Chatwoot renders it in an iframe in a panel beside the conversation — and it passes the current conversation and contact context to your app via a &lt;code&gt;postMessage&lt;/code&gt; event.&lt;/p&gt;

&lt;p&gt;Concretely: your app listens for the context Chatwoot sends in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside your embedded dashboard app&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Chatwoot posts the conversation + contact context here&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;appContext&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// data.data.conversation and data.data.contact are available&lt;/span&gt;
      &lt;span class="nf"&gt;renderCustomerPanel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ignore non-Chatwoot messages */&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;Now your agents see &lt;em&gt;your&lt;/em&gt; tooling — order history, a subscription manager, internal notes from another system — right next to the chat, without leaving Chatwoot. On a closed SaaS you'd be waiting for an official integration or a marketplace app. Here you build the panel you actually want and point Chatwoot at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Why Self-Hosted Unlocks What Cloud/SaaS Can't
&lt;/h2&gt;

&lt;p&gt;Pulling the thread together — here's what specifically becomes possible once you own the deployment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct database access.&lt;/strong&gt; Chatwoot stores everything in PostgreSQL. For reporting beyond the built-in dashboards, you can query the database directly or pipe it into a BI tool. Closed SaaS exposes only what its export API allows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom code and forks.&lt;/strong&gt; It's open-source. If a behavior doesn't fit, you can change it and run your fork. The widget-string and launcher patches I described above are exactly this — small, surgical, and impossible on a hosted product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No API rate limits.&lt;/strong&gt; Self-hosted, the API is bounded by your server, not by a vendor's throttle. Heavy syncs and high-volume bots don't hit a per-plan ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom channels via the API channel.&lt;/strong&gt; Beyond the built-in channels, Chatwoot's API channel lets you pipe messages from any source into a Chatwoot inbox and send replies back out — so a proprietary or niche messaging system becomes just another inbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full white-label.&lt;/strong&gt; As covered above — your brand, end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data residency and control.&lt;/strong&gt; Every conversation lives on infrastructure you choose. For regulated industries or strict data-protection regimes, that's often non-negotiable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a knock on hosted products. It's a description of a different trade: Cloud trades flexibility for zero maintenance; self-hosted trades maintenance for total control. Which one is "right" depends entirely on whether the customizations above are nice-to-haves or requirements for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork in the road: Cloud or self-hosted?
&lt;/h2&gt;

&lt;p&gt;Both are legitimate. Pick by what you're optimizing for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want it managed, with zero maintenance&lt;/strong&gt; → Chatwoot Cloud is the pragmatic choice — the vendor handles updates, backups, and uptime, and you still get the API, webhooks, and automation rules. Readers here get &lt;strong&gt;5% off&lt;/strong&gt; Chatwoot Cloud: &lt;a href="https://www.chatwoot.com/?via=achiya-automation" rel="noopener noreferrer"&gt;chatwoot.com (5% off)&lt;/a&gt;, coupon &lt;code&gt;ACHIYADEV&lt;/code&gt;.
&amp;gt; &lt;em&gt;Affiliate disclosure: that's an affiliate link — if you subscribe through it, I may earn a commission at no extra cost to you (the coupon gives you 5% off either way). I recommend Chatwoot because I run it in production, not because of the link.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need the customizations in this article&lt;/strong&gt; — white-label, custom code, dashboard apps, direct DB access, no rate limits → that's self-hosted, and it's exactly what I set up and manage for clients. One-time setup, no recurring SaaS seat fees.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A Realistic Word of Caution
&lt;/h2&gt;

&lt;p&gt;Customization is a capability, not a to-do list. The most common mistake I see is treating "we &lt;em&gt;can&lt;/em&gt; change everything" as "we &lt;em&gt;should&lt;/em&gt; change everything." Every fork you maintain is a thing you have to re-test on upgrade; every webhook is a thing that can fail at 2 a.m. The discipline that makes self-hosted Chatwoot pay off is restraint: start with the dashboard settings, use automation rules and macros before you write code, keep custom patches small and idempotent, and only reach for a fork when configuration genuinely can't get you there. Done that way, customization is an asset. Done carelessly, it's a maintenance bill.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Achiya Cohen, founder of &lt;a href="https://achiya-automation.com/en/" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt;. I build WhatsApp bots and business automation, and I run self-hosted, customized Chatwoot in production for clients. If you want open-source customer support tailored to how your business actually works — &lt;a href="https://achiya-automation.com/en/contact/" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;. And if managed Cloud fits you better, &lt;a href="https://www.chatwoot.com/?via=achiya-automation" rel="noopener noreferrer"&gt;start here for 5% off&lt;/a&gt; with code &lt;code&gt;ACHIYADEV&lt;/code&gt; (affiliate link).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chatwoot</category>
      <category>opensource</category>
      <category>selfhosted</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
