<?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>Forem: אחיה כהן</title>
    <description>The latest articles on Forem by אחיה כהן (@achiya-automation).</description>
    <link>https://forem.com/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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810102%2Fefb43e59-992c-4f8b-91df-ee602c7c853f.jpg</url>
      <title>Forem: אחיה כהן</title>
      <link>https://forem.com/achiya-automation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/achiya-automation"/>
    <language>en</language>
    <item>
      <title>My agent could see the dropdown. It just couldn't pick anything.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 24 May 2026 08:41:42 +0000</pubDate>
      <link>https://forem.com/achiya-automation/my-agent-could-see-the-dropdown-it-just-couldnt-pick-anything-140</link>
      <guid>https://forem.com/achiya-automation/my-agent-could-see-the-dropdown-it-just-couldnt-pick-anything-140</guid>
      <description>&lt;p&gt;The agent had a list. I asked it to pick an item. It refused.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Element not found&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Refresh. Same.&lt;/p&gt;

&lt;p&gt;So I opened DevTools and pasted 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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;select[name="status"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt;. On a page that obviously had a dropdown. I could see it. I could click it. I could expand it with the mouse. But for some reason &lt;code&gt;document.querySelector&lt;/code&gt; insisted it didn't exist.&lt;/p&gt;

&lt;p&gt;This is a story about three layers of DOM that don't talk to each other, and what &lt;code&gt;safari_select_option&lt;/code&gt; had to learn in v2.11.3 of &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt; to reach across them.&lt;/p&gt;




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

&lt;p&gt;The page was a Salesforce/Lightning support form embedded in a customer portal. The portal is the parent document. The form is in an &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; that Lightning ships from a different (but same-origin) host. Inside that iframe, Lightning composes its UI from a graph of custom elements — each one with its own shadow root, each one with its own internal layout.&lt;/p&gt;

&lt;p&gt;So when a developer writes a "Status" dropdown in Lightning, the actual &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; element ends up rendered inside something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;top document
└── &amp;lt;iframe src="...lightning host..."&amp;gt;
    └── &amp;lt;support-form&amp;gt;
        └── #shadow-root
            └── &amp;lt;lightning-status-field&amp;gt;
                └── #shadow-root
                    └── &amp;lt;select&amp;gt;   ← the element my agent needed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;document.querySelector('select[name="status"]')&lt;/code&gt;, called from the top document, &lt;strong&gt;traverses none of that.&lt;/strong&gt; Not the iframe, not the shadow roots. To it, the &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; simply doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was confusing
&lt;/h2&gt;

&lt;p&gt;Two things made this hard to spot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;safari_snapshot&lt;/code&gt; saw it.&lt;/strong&gt; When the agent took an accessibility snapshot of the page, the dropdown was right there — with a ref, a label, an &lt;code&gt;aria-expanded&lt;/code&gt; state, options. Nothing felt missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;safari_click&lt;/code&gt; worked.&lt;/strong&gt; I'd been clicking deep-DOM elements for weeks without thinking about it. The button that opened this same form was inside a different shadow root, and click resolved it just fine.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the agent kept asking itself: "I just clicked into this form. The dropdown is right there. Why can't I select anything?"&lt;/p&gt;

&lt;p&gt;The answer, embarrassingly, was that &lt;code&gt;click&lt;/code&gt; and &lt;code&gt;select_option&lt;/code&gt; were using &lt;strong&gt;different finders&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two finders, one tool that hadn't been told
&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; ships two element-resolution paths inside the page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcpFindRef(ref)&lt;/code&gt;&lt;/strong&gt; — given a ref from &lt;code&gt;safari_snapshot&lt;/code&gt;, walk the document, every same-origin iframe, and every reachable shadow root to find the element that ref points to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcpQuerySelectorDeep(selector)&lt;/code&gt;&lt;/strong&gt; — given a CSS selector, do the same deep walk, but match by selector instead of ref.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click had been using both of these for a long time. That's why click "just worked" on Lightning forms and React component libraries and modal dialogs that render into portals.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;safari_select_option&lt;/code&gt;, meanwhile, was still doing this:&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;var&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. Top frame only. No iframes, no shadow roots, no nothing. On any normal &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; on a normal page, that line is fine — and it had been fine since the day the tool was written, which is exactly why nobody had touched it.&lt;/p&gt;

&lt;p&gt;But once a single user dropped Safari MCP into a real Salesforce portal, that line was wrong on every call.&lt;/p&gt;

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

&lt;p&gt;The v2.11.3 patch is small. It teaches &lt;code&gt;safari_select_option&lt;/code&gt; what &lt;code&gt;safari_click&lt;/code&gt; already knew:&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;let&lt;/span&gt; &lt;span class="nx"&gt;finder&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;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;finder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`mcpFindRef('&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ref&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;finder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`(document.querySelector('&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;')||mcpQuerySelectorDeep('&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'))`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ref&lt;/code&gt; path&lt;/strong&gt; — the one to use for anything found via &lt;code&gt;safari_snapshot&lt;/code&gt;. It resolves through the same deep walker as click. Iframes, shadow roots, both kinds of nested-component land.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;selector&lt;/code&gt; path&lt;/strong&gt; — start with the cheap top-frame query (still right 95% of the time), fall through to the deep walker only when that returns null.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of the tool — the &lt;code&gt;_valueTracker&lt;/code&gt; reset that wakes up React's controlled-input bookkeeping, the value-then-text-then-substring matching for selects whose visible text doesn't equal their value, the &lt;code&gt;input&lt;/code&gt;/&lt;code&gt;change&lt;/code&gt;/&lt;code&gt;blur&lt;/code&gt; event sequence — is unchanged. That part wasn't broken. The element lookup was.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.11.3" rel="noopener noreferrer"&gt;Full v2.11.3 release notes here.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I should have done earlier
&lt;/h2&gt;

&lt;p&gt;The honest version of this is: I had two finders. I used one of them in click. I forgot it existed when I wrote select_option. The fix took an hour. The bug had been there for three months.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning while building this tool is that &lt;strong&gt;DOM tools should not have a "happy path."&lt;/strong&gt; Every tool that resolves an element should resolve it the same way as every other tool, because the page doesn't know which tool you're going to call next. Click into a shadow root, then try to fill a field, and the fill tool had better look in the same place click did.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;safari_fill&lt;/code&gt;, &lt;code&gt;safari_get_element&lt;/code&gt;, &lt;code&gt;safari_hover&lt;/code&gt;, &lt;code&gt;safari_get_computed_style&lt;/code&gt; — they all went through this same migration earlier in the v2.x series, one bug report at a time. &lt;code&gt;safari_select_option&lt;/code&gt; was the last one I hadn't audited. v2.11.3 closes that gap.&lt;/p&gt;

&lt;p&gt;If you're building any kind of browser-automation tool — MCP or otherwise — the question worth asking your codebase tonight is: do all my element-resolution paths agree on what "the element" is? Because the day they don't, the agent will be the one to find out.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Safari MCP is open source (MIT) at &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;github.com/achiya-automation/safari-mcp&lt;/a&gt;.&lt;/strong&gt; Native browser automation for AI agents on macOS — your real Safari, your real logins, no Chrome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>javascript</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>Buying a WhatsApp Bot in 2026? Five Traps to Avoid</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 17 May 2026 16:09:29 +0000</pubDate>
      <link>https://forem.com/achiya-automation/buying-a-whatsapp-bot-in-2026-five-traps-to-avoid-32a9</link>
      <guid>https://forem.com/achiya-automation/buying-a-whatsapp-bot-in-2026-five-traps-to-avoid-32a9</guid>
      <description>&lt;p&gt;I build WhatsApp automation for small businesses, and in 2026 the first thing you notice about the market is how similar every offer looks. A dozen vendors, the same promises — 24/7 replies, appointment booking, lead capture — and quotes that all cluster in the same range.&lt;/p&gt;

&lt;p&gt;After several years of these projects, the pattern is clear: the expensive mistakes are almost never in the build itself. They are in the &lt;em&gt;buying decision&lt;/em&gt; — the questions nobody asked before signing. Here are the five that account for most of the regret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1 — Judging the quote by its setup price
&lt;/h2&gt;

&lt;p&gt;The visible number on a bot quote is the one-time build fee. In the market I work in (Israel), that is broadly ₪3,500 for a basic bot, around ₪6,500 for a mid-tier with CRM integration, and ₪12,000+ for a full AI agent. Buyers line those numbers up and pick the lowest.&lt;/p&gt;

&lt;p&gt;But the setup fee varies &lt;em&gt;least&lt;/em&gt; between serious vendors. What actually decides whether the bot is cheap or expensive over three years is the recurring cost: per-message template fees, language-model usage, hosting, maintenance. A bot with a low setup fee and an unexamined monthly tail can cost more over two years than a pricier build with a lean tail. Ask for the three-year total, not the sticker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2 — Not asking which API the bot runs on
&lt;/h2&gt;

&lt;p&gt;This is the single most consequential question, and most buyers never ask it.&lt;/p&gt;

&lt;p&gt;A WhatsApp bot connects in one of two ways: through Meta's official WhatsApp Business API, or through an unofficial route that automates the regular WhatsApp app (the best-known open-source project here is WAHA). The unofficial route is genuinely cheaper — no per-message fee — and at low volume it works.&lt;/p&gt;

&lt;p&gt;It also runs against Meta's terms of service, and the number it runs on can be suspended without warning or appeal. For most businesses the WhatsApp number &lt;em&gt;is&lt;/em&gt; the customer database and the primary contact channel. Losing it is not an inconvenience; it is a small catastrophe. The unofficial route is reasonable for narrow cases — internal tools, low-stakes notifications — but you should know which one you are buying, and why. A quote that doesn't specify is a quote to question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3 — Paying for AI the bot will never use
&lt;/h2&gt;

&lt;p&gt;"AI-powered" is the headline tier on most 2026 quotes. It is also the tier most often sold and least often &lt;em&gt;delivered&lt;/em&gt; — not because vendors cut corners, but because an AI bot is only as good as what it is given.&lt;/p&gt;

&lt;p&gt;A language model wired to WhatsApp but never fed the business's real price list, policies, hours, and tone will underperform a plain, well-built menu bot. The capability is real; it just isn't automatic. Before paying the AI premium, ask the concrete question: what exactly will the model be given to work from, who assembles that, and who keeps it current. If the answer is vague, you're buying a label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4 — Treating the bot as build-once
&lt;/h2&gt;

&lt;p&gt;A WhatsApp bot is not an appliance. It is software sitting on top of three things that move constantly — Meta's platform, the automation tooling, and the AI models underneath.&lt;/p&gt;

&lt;p&gt;The pace is not theoretical. Inside one 90-day window in 2026, Meta repriced its message templates, n8n shipped native AI-agent capability, the major model vendors cut their cheapest production models by 40–60%, and Meta's WhatsApp Calling API reached general availability. A bot specified in early 2025, on early-2025 assumptions, is already running on stale economics. Either budget for maintenance, or budget — unknowingly — for a rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 5 — No exit ramp to a human
&lt;/h2&gt;

&lt;p&gt;The last trap is the most human one. A bot with no clean handoff to a person frustrates customers more than no bot at all — everyone has been stuck in a loop with an automated system that won't let them out.&lt;/p&gt;

&lt;p&gt;The measure of a well-built bot is not how many conversations it handles end to end. It is how gracefully it recognizes the ones it cannot, and how fast it puts a real person in the chat. Ask any vendor to walk through exactly what happens when the bot doesn't know the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good looks like
&lt;/h2&gt;

&lt;p&gt;A strong vendor's quote answers all five of these before you think to ask — it states the API, breaks out the recurring costs, is specific about what the AI is fed, includes a maintenance path, and treats the human handoff as a feature, not an afterthought.&lt;/p&gt;

&lt;p&gt;If you want the longer version — the bot types, the official-vs-unofficial API tradeoff in detail, and current pricing — I keep a full guide here: &lt;a href="https://achiya-automation.com/en/blog/whatsapp-bot-for-business/" rel="noopener noreferrer"&gt;WhatsApp Bot for Business: the complete guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The market is crowded, and from the outside the offers really do look alike. They stop looking alike the moment you ask the five questions.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I run an automation consultancy focused on WhatsApp chatbots, n8n workflows, and CRM integrations for small businesses.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>chatbots</category>
      <category>business</category>
    </item>
    <item>
      <title>Why element.click() Isn't a Click</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 17 May 2026 13:58:14 +0000</pubDate>
      <link>https://forem.com/achiya-automation/why-elementclick-isnt-a-click-9kd</link>
      <guid>https://forem.com/achiya-automation/why-elementclick-isnt-a-click-9kd</guid>
      <description>&lt;p&gt;My AI agent had a checkbox to tick. A multi-step form: tick two boxes, hit &lt;strong&gt;Next&lt;/strong&gt;. It ticked the boxes. It hit Next. The form rejected it and snapped back to step one — every time.&lt;/p&gt;

&lt;p&gt;The boxes were visibly checked. The DOM said &lt;code&gt;checked = true&lt;/code&gt;. The agent had done everything right. The form still didn't believe it.&lt;/p&gt;

&lt;p&gt;Over the next day I shipped four patch releases of &lt;code&gt;safari-mcp&lt;/code&gt; — v2.10.6 through v2.10.9 — and &lt;em&gt;still&lt;/em&gt; didn't completely win. Here's what each layer of the stack taught me about why a programmatic click isn't a click.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell: &lt;code&gt;isTrusted&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When software clicks, it calls &lt;code&gt;element.click()&lt;/code&gt; or dispatches a &lt;code&gt;MouseEvent&lt;/code&gt;. The handler runs — but the event carries &lt;code&gt;isTrusted: false&lt;/code&gt;. That flag is the browser stating, on the record, that &lt;em&gt;no human did this&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Most code never checks. But the modern stack has at least four layers that do, and each rejects a forged click in its own way. I met all four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1 — the component library
&lt;/h2&gt;

&lt;p&gt;The form used &lt;code&gt;react-select&lt;/code&gt;. My tool opened the dropdown by clicking the chevron, then clicked the option. Fine — for the first few rows. Past row four, clicking the chevron did &lt;strong&gt;nothing&lt;/strong&gt;. No menu. The element still had a live React fiber; its pointer handler had simply, silently, stopped responding.&lt;/p&gt;

&lt;p&gt;So I stopped driving the UI. The v2.10.6 fix walks the React fiber up from the target node, finds the &lt;code&gt;Select&lt;/code&gt; component, and calls its &lt;code&gt;onChange&lt;/code&gt; directly — with the same &lt;code&gt;{ action: 'select-option', option, name }&lt;/code&gt; payload react-select dispatches internally. No menu, no chevron, no click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; when a component's visible affordance gets flaky, its internal API usually isn't. Reach for the fiber.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2 — the framework
&lt;/h2&gt;

&lt;p&gt;Next layer: Vue 3. My tool clicked the checkbox. The DOM &lt;code&gt;.checked&lt;/code&gt; flipped to &lt;code&gt;true&lt;/code&gt;. Vue's reactive &lt;code&gt;v-model&lt;/code&gt; proxy did not.&lt;/p&gt;

&lt;p&gt;So the box &lt;em&gt;looked&lt;/em&gt; checked, but Vue's internal state still held the old value — and the next form submission read Vue's state, not the DOM. That was the snap-back.&lt;/p&gt;

&lt;p&gt;The v2.10.7 fix is belt-and-suspenders: after the click, redispatch &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;change&lt;/code&gt; with &lt;code&gt;composed: true&lt;/code&gt; so they cross Shadow DOM and Vue Teleport portals, and reset React's &lt;code&gt;_valueTracker&lt;/code&gt; for the shared React-checkbox case. Now the reactive layer hears the change — not just the DOM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; flipping a DOM property is not the same as telling the framework you flipped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3 — the browser's own geometry
&lt;/h2&gt;

&lt;p&gt;Safari MCP can also do &lt;em&gt;real&lt;/em&gt; clicks — actual OS-level &lt;code&gt;CGEvent&lt;/code&gt; mouse events at screen coordinates — for cases synthetic clicks can't reach.&lt;/p&gt;

&lt;p&gt;To turn a page coordinate into a screen coordinate, you need the height of everything above the web content: title bar, toolbar, tab bar. I had hardcoded it at 74 px.&lt;/p&gt;

&lt;p&gt;On modern Safari the chrome above the content is closer to 90 px. Every native click landed ~16 px high. Often that's still inside the target row, so it &lt;em&gt;sort of&lt;/em&gt; worked — but for a button near whitespace, 16 px is a hit versus a silent miss.&lt;/p&gt;

&lt;p&gt;The v2.10.8 fix: stop guessing. Compute &lt;code&gt;outerHeight - innerHeight&lt;/code&gt; in JavaScript at click time, with a sanity range and a fallback. The browser already knows how tall its own chrome is. Ask it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; never hardcode a number the platform will hand you for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4 — the operating system
&lt;/h2&gt;

&lt;p&gt;Those OS-level clicks need macOS Accessibility permission. macOS stores that grant in its TCC database, keyed to the &lt;strong&gt;code-signing identifier&lt;/strong&gt; of the binary asking.&lt;/p&gt;

&lt;p&gt;My helper binary was ad-hoc signed with a &lt;em&gt;hash-based&lt;/em&gt; identifier — a new string on every rebuild. So every &lt;code&gt;npm install&lt;/code&gt; produced a binary macOS had never seen. The Accessibility grant from yesterday was bound to yesterday's identifier; today's binary inherited nothing.&lt;/p&gt;

&lt;p&gt;The symptom was maddening: the helper reported success, no clicks reached the page, and System Settings &lt;em&gt;showed&lt;/em&gt; the permission as granted — for the stale identifier.&lt;/p&gt;

&lt;p&gt;The v2.10.9 fix: &lt;code&gt;postinstall&lt;/code&gt; re-signs the helper with a &lt;strong&gt;stable&lt;/strong&gt; identifier so the grant survives upgrades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if a permission keeps "randomly" resetting, check whether the thing requesting it has a stable identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5 — the one I haven't beaten
&lt;/h2&gt;

&lt;p&gt;Four releases, four layers, one day. And then, on macOS 26, a click &lt;em&gt;still&lt;/em&gt; didn't land.&lt;/p&gt;

&lt;p&gt;With everything above fixed — right coordinates, stable permission, valid target — &lt;code&gt;CGEvent.postToPid&lt;/code&gt; reports a successful injection and the page receives nothing. No &lt;code&gt;isTrusted&lt;/code&gt; event at all. The private window-targeting fields the call needs are still present in the macOS 26 SDK; the event simply never crosses into Safari's sandboxed WebContent process.&lt;/p&gt;

&lt;p&gt;I can't yet prove it's an OS change rather than something I'm still missing — so it's tracked in the open as &lt;a href="https://github.com/achiya-automation/safari-mcp/issues/29" rel="noopener noreferrer"&gt;issue #29&lt;/a&gt;, with the full repro and everything ruled out. If you've automated macOS UI and have a theory, that thread's the place.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just click the button"
&lt;/h2&gt;

&lt;p&gt;A click looks atomic. It isn't. Before a real finger reaches a checkbox, an event has to satisfy a component library, a reactive framework, the browser's coordinate math, and the OS permission model — all in one motion — and on a new OS release, the OS can quietly change the rules underneath all of it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;element.click()&lt;/code&gt; skips the finger and asks four contracts to take its word for it. Some of them won't. If you're building automation for AI agents, budget for every layer — and keep your release numbers cheap. Some days you'll spend four.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;code&gt;safari-mcp&lt;/code&gt; is open source — native Safari automation for AI agents on macOS, no Chrome, no headless. &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;github.com/achiya-automation/safari-mcp&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My AI agent saved the first paragraph and the last. It dropped 41 in between.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Tue, 12 May 2026 15:07:43 +0000</pubDate>
      <link>https://forem.com/achiya-automation/my-ai-agent-saved-the-first-paragraph-and-the-last-it-dropped-41-in-between-app</link>
      <guid>https://forem.com/achiya-automation/my-ai-agent-saved-the-first-paragraph-and-the-last-it-dropped-41-in-between-app</guid>
      <description>&lt;p&gt;I asked an AI agent to cross-post a 7,000-character article from dev.to to Hashnode.&lt;/p&gt;

&lt;p&gt;The Submit click succeeded. Hashnode returned a draft URL. I clicked through.&lt;/p&gt;

&lt;p&gt;The draft had &lt;strong&gt;446 characters&lt;/strong&gt;: the first paragraph, then 41 empty paragraphs, then the last paragraph.&lt;/p&gt;

&lt;p&gt;This is a postmortem of how I got there, why my first three diagnoses were wrong, and what fixed it. If you're shipping any kind of browser automation that touches modern rich-text editors, this one is worth the read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&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; is the macOS-native browser automation tool I maintain. One of the things it has to do is fill rich-text editors — Quill, ProseMirror, Lexical, the React-controlled stuff Featured.com uses, and a dozen variations.&lt;/p&gt;

&lt;p&gt;For the cross-posting flow specifically, the agent does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Opens the Hashnode "new draft" page in my real Safari (already signed in).&lt;/li&gt;
&lt;li&gt;Drops a 7,000-character markdown body into the editor.&lt;/li&gt;
&lt;li&gt;Clicks Publish.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. It worked for years on dev.to, Medium, X, LinkedIn (after their 2026 Quill migration), Featured.com. Hashnode was supposed to be the easy one — they sell themselves as "developer-friendly".&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;After Submit, the draft saved with this structure:&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;p&amp;gt;&lt;/span&gt;First paragraph (intact, ~280 chars).&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
... 41 empty &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt; tags ...
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Last paragraph (intact, ~166 chars).&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total saved: 446 chars. Total sent: 6,808 chars. &lt;strong&gt;The middle 94% was silently dropped.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent had no error. The fill call returned successfully. The Submit click returned successfully. Hashnode's own draft view showed the broken structure as if it were intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  First diagnosis (wrong): paste race condition
&lt;/h2&gt;

&lt;p&gt;My first guess was a paste-event timing issue. I'd recently fixed a similar bug on X.com where the synthetic &lt;code&gt;ClipboardEvent('paste')&lt;/code&gt; was racing with their React &lt;code&gt;useEffect&lt;/code&gt; cycle. The fix had been an explicit &lt;code&gt;execCommand('delete')&lt;/code&gt; before the paste.&lt;/p&gt;

&lt;p&gt;I tried the same thing here. No change.&lt;/p&gt;

&lt;p&gt;I added a &lt;code&gt;safari_verify_state&lt;/code&gt; call between fill and submit. The verifier confirmed the editor's &lt;code&gt;.textContent&lt;/code&gt; matched what I sent — &lt;em&gt;at the moment I checked&lt;/em&gt;. But by the time Submit fired ~100ms later, the editor state had reverted.&lt;/p&gt;

&lt;p&gt;So whatever was eating the middle paragraphs, it was doing it &lt;em&gt;after&lt;/em&gt; the fill returned. The agent's "success" signal was lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second diagnosis (wrong): markdown auto-conversion
&lt;/h2&gt;

&lt;p&gt;Hashnode's editor does auto-format certain characters at the start of a line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;gt;&lt;/code&gt; → blockquote&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**&lt;/code&gt; → bold marker&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[&lt;/code&gt; → link helper&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#&lt;/code&gt; → heading&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I noticed the body had several paragraphs starting with these characters. So I theorized: the editor was rejecting paragraphs whose first characters tripped auto-format prompts, leaving them empty until the user manually accepted.&lt;/p&gt;

&lt;p&gt;Fix: I escaped the leading characters with a zero-width space. Re-ran. Result: &lt;strong&gt;still 446 chars saved.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So that wasn't it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third diagnosis (wrong): React reconciliation order
&lt;/h2&gt;

&lt;p&gt;Hashnode's editor wraps ProseMirror in a React component. I suspected that the multiple &lt;code&gt;beforeinput&lt;/code&gt; events I was dispatching were getting batched and only the first + last applied.&lt;/p&gt;

&lt;p&gt;I switched from &lt;code&gt;beforeinput&lt;/code&gt; to &lt;code&gt;composing&lt;/code&gt; text events with intermediate &lt;code&gt;setTimeout(0)&lt;/code&gt; calls to give React's render cycle a chance to flush.&lt;/p&gt;

&lt;p&gt;Still 446 chars.&lt;/p&gt;

&lt;p&gt;At this point I was four hours in and getting irrationally angry at a contenteditable div.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real bug
&lt;/h2&gt;

&lt;p&gt;I read the Safari MCP fill pipeline. For ProseMirror editors, it does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Walk the DOM looking for &lt;code&gt;.pmViewDesc&lt;/code&gt; (ProseMirror's view marker).&lt;/li&gt;
&lt;li&gt;If found, call &lt;code&gt;view.dispatch(tr.replaceWith(...))&lt;/code&gt; — the canonical way.&lt;/li&gt;
&lt;li&gt;If not found, walk React Fiber for &lt;code&gt;memoizedProps.view&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If still not found, &lt;strong&gt;fall through to char-by-char &lt;code&gt;beforeinput&lt;/code&gt; + &lt;code&gt;execCommand('insertText')&lt;/code&gt;&lt;/strong&gt; per line.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Path 4 is the fallback for editors that don't expose ProseMirror's internals. It's worked everywhere I'd tested it. &lt;em&gt;Including&lt;/em&gt; Hashnode in earlier dev.&lt;/p&gt;

&lt;p&gt;But it has one assumption baked in: &lt;strong&gt;that the editor will accept the text it's told to insert.&lt;/strong&gt; If the editor rejects an insert silently — for any reason — the fill pipeline never finds out. The function returns success. The DOM has empty paragraphs.&lt;/p&gt;

&lt;p&gt;Hashnode's ProseMirror configuration has an "input rules" plugin that runs on every paragraph start. The plugin's job is to handle markdown shortcuts. But its implementation aborts the insert if the matched text doesn't form a valid command — and just doesn't insert anything.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;&amp;gt; blockquote text&lt;/code&gt; doesn't become a blockquote. It also doesn't become a regular paragraph. It becomes nothing.&lt;/p&gt;

&lt;p&gt;The fill pipeline is char-by-char per line. It walks down, fires beforeinput, fires execCommand. The input rule fires on each &lt;code&gt;&amp;gt;&lt;/code&gt;, kills the line silently. Pipeline moves to next line. Same thing.&lt;/p&gt;

&lt;p&gt;Only paragraphs whose first character &lt;em&gt;doesn't&lt;/em&gt; trip a rule survive. In my article, that was paragraph 1 and the final paragraph.&lt;/p&gt;

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

&lt;p&gt;The fix is straightforward once you see it:&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;// After char-by-char fill, verify what actually landed.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// More than 40% missing. The editor ate it.&lt;/span&gt;
  &lt;span class="c1"&gt;// Clear via DOM replacement, then re-fill via insertHTML with paragraph-wrapped HTML.&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstChild&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstChild&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&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\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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insertHTML&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`Filled CE (ProseMirror insertHTML fallback, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;insertHTML&lt;/code&gt; bypasses the input-rules plugin because the rules only fire on character-level input events. A bulk HTML insert is treated as a paste and goes through a different code path — one that doesn't run the markdown-shortcut interceptor.&lt;/p&gt;

&lt;p&gt;Important: the values going through &lt;code&gt;escapeHtml&lt;/code&gt; and &lt;code&gt;insertHTML&lt;/code&gt; here come from the agent's own controlled context — text the agent itself is trying to place into a logged-in editor in the user's own browser session. This isn't a server rendering untrusted user input, so the escape step is purely to preserve the literal characters, not to harden against an attacker.&lt;/p&gt;

&lt;p&gt;Verify-after-fill is the part that took me too long to add. &lt;em&gt;Trust the framework to tell you what happened, not the call that just returned.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This shipped in &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.10.4" rel="noopener noreferrer"&gt;v2.10.4&lt;/a&gt; of Safari MCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;The deeper issue isn't ProseMirror. It's the assumption that &lt;strong&gt;a successful tool call means the action succeeded.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Browser automation has a recurring failure mode: the framework keeps its own state separately from the DOM, and the framework's state is what gets submitted on form post. Your synthetic events change one or the other, sometimes both, sometimes neither. The DOM looks right. The submit ships the wrong thing.&lt;/p&gt;

&lt;p&gt;I added a tool called &lt;code&gt;safari_verify_state&lt;/code&gt; in v2.10.0 specifically for this. It checks framework state (ProseMirror view, Lexical editor state, React &lt;code&gt;_valueTracker&lt;/code&gt; desync, Closure component values) and returns whether the framework agrees with the DOM. The Hashnode ProseMirror case is now a built-in check.&lt;/p&gt;

&lt;p&gt;If you're building any agent that touches a serious editor — Quill, ProseMirror, Lexical, Slate, Tiptap — assume the fill happened, then &lt;strong&gt;verify what landed before you click Submit.&lt;/strong&gt; The 5 ms it takes is the cheapest insurance you'll buy this year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postscript
&lt;/h2&gt;

&lt;p&gt;I cross-posted this article using the fixed code path. It saved as 41 intact paragraphs.&lt;/p&gt;

&lt;p&gt;The agent didn't notice anything was different. That's the point.&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 open source (MIT) and runs on macOS. It's used by Claude Code, Cursor, and other MCP clients to drive a real, logged-in Safari instead of spinning up a headless Chromium.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>javascript</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>An AI agent overwrote two of my browser tabs. The fix took three releases.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 07 May 2026 12:45:23 +0000</pubDate>
      <link>https://forem.com/achiya-automation/an-ai-agent-overwrote-two-of-my-browser-tabs-the-fix-took-three-releases-l2l</link>
      <guid>https://forem.com/achiya-automation/an-ai-agent-overwrote-two-of-my-browser-tabs-the-fix-took-three-releases-l2l</guid>
      <description>&lt;p&gt;I was eating dinner when my AI agent ate my tabs.&lt;/p&gt;

&lt;p&gt;I had Safari open with a Chatwoot Meta dashboard in one tab and an n8n executions view in another — both with unsaved state, both in the middle of real work. In a third tab, my own tab, my agent was supposed to be testing a new feature in the MCP server I maintain (&lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I came back to the laptop and both real-work tabs had been navigated to URLs the agent picked. The Chatwoot tab was now showing some test page. The n8n tab was on a Reddit comment thread the agent had been debugging an unrelated module against.&lt;/p&gt;

&lt;p&gt;The agent hadn't gone rogue. The MCP server had a state-tracking bug — and instead of failing loudly, it had silently fallen back to "use whatever tab the user is on."&lt;/p&gt;

&lt;p&gt;This is a postmortem. The fix took three releases — &lt;code&gt;v2.10.0&lt;/code&gt;, &lt;code&gt;v2.10.1&lt;/code&gt;, and &lt;code&gt;v2.10.3&lt;/code&gt; — and the iteration is the interesting part.&lt;/p&gt;




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

&lt;p&gt;Safari MCP exposes a &lt;code&gt;safari_new_tab(url)&lt;/code&gt; tool. Internally, it tracks "the tab MCP owns" via:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A tab index (&lt;code&gt;_activeTabIndex&lt;/code&gt;) — Safari's positional handle.&lt;/li&gt;
&lt;li&gt;A DOM marker (&lt;code&gt;window.__mcpTabMarker&lt;/code&gt;) — injected JS that lets future calls verify "yes, this is still our tab."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every subsequent &lt;code&gt;safari_navigate&lt;/code&gt;, &lt;code&gt;safari_click&lt;/code&gt;, &lt;code&gt;safari_fill&lt;/code&gt; etc. resolves "where to act" by:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if marker still in current tab → use it
else if _activeTabIndex still valid → switch to it, re-verify
else → fall back to "front document of front window"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last branch is the catastrophe. &lt;em&gt;"Front document of front window"&lt;/em&gt; is, by definition, &lt;strong&gt;whatever the user is looking at right now&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So why did the fallback fire? Three different reasons, across three releases.&lt;/p&gt;




&lt;h2&gt;
  
  
  v2.10.0 — the original failure mode
&lt;/h2&gt;

&lt;p&gt;The original &lt;code&gt;safari_new_tab(url)&lt;/code&gt; did exactly what its name said: open a new tab and navigate it to &lt;code&gt;url&lt;/code&gt; in one call.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;newTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;openBlankTab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// creates blank tab&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;navigate&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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// navigates immediately&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;injectMarker&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="c1"&gt;// marker for future calls&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;idx&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;Spot the bug? It's about what happens when &lt;code&gt;navigate(idx, url)&lt;/code&gt; &lt;em&gt;fails to load&lt;/em&gt; — &lt;code&gt;file://&lt;/code&gt; blocked by Safari, network error, an &lt;code&gt;app://&lt;/code&gt; scheme that Safari doesn't understand. The new tab stays at &lt;code&gt;about:blank&lt;/code&gt;. The marker injection runs, but then the next user-driven navigation in any tab can wipe it. By the time the next &lt;code&gt;safari_navigate&lt;/code&gt; arrives, our marker check fails. Our &lt;code&gt;_activeTabIndex&lt;/code&gt; still points at a tab, but Safari's real DOM in that tab has been replaced.&lt;/p&gt;

&lt;p&gt;The "front document" fallback fires. We navigate the user's current tab.&lt;/p&gt;

&lt;p&gt;I shipped this. I tested it on a clean Safari with one window. I never hit the bug because in clean state, the user's tab &lt;em&gt;is&lt;/em&gt; my tab.&lt;/p&gt;




&lt;h2&gt;
  
  
  v2.10.1 — the grace window (almost-fix)
&lt;/h2&gt;

&lt;p&gt;The first fix was a &lt;code&gt;NEW_TAB_GRACE_MS = 30_000&lt;/code&gt; window. For 30 seconds after &lt;code&gt;safari_new_tab&lt;/code&gt;, ANY mutating operation that &lt;em&gt;would&lt;/em&gt; fall back to the user's tab now throws a clear error:&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;_lastNewTabAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;NEW_TAB_GRACE_MS&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;markerOk&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tab tracking lost shortly after new_tab — call safari_new_tab again instead of letting MCP touch your active 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;Plus a fix for the marker wipe — &lt;code&gt;safari_navigate&lt;/code&gt; now re-injects &lt;code&gt;window.__mcpTabMarker&lt;/code&gt; after every successful navigation, so JS-context resets don't lose tracking.&lt;/p&gt;

&lt;p&gt;This passed all my tests. It also worked correctly for ~95% of real sessions.&lt;/p&gt;

&lt;p&gt;The 5% it missed: &lt;strong&gt;sessions longer than 30 seconds where the tab-ghost recovery path nullified &lt;code&gt;_activeTabIndex&lt;/code&gt; mid-session.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  v2.10.3 — the permanent guard
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;runJS&lt;/code&gt; (the workhorse for every JS-driven tool) has a tab-ghost recovery path. If a JavaScript &lt;code&gt;evaluate&lt;/code&gt; fails because the tab Safari thinks is at index N has been closed/replaced, &lt;code&gt;runJS&lt;/code&gt; nullifies &lt;code&gt;_activeTabIndex&lt;/code&gt; so the next call resolves cleanly.&lt;/p&gt;

&lt;p&gt;The intent: avoid using a stale index after Safari shuffles tabs.&lt;/p&gt;

&lt;p&gt;The unintended consequence: 30+ minutes into a session, after a routine ghost-recovery, &lt;code&gt;_activeTabIndex&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;. The grace window from v2.10.1 has long expired. The marker check fails (the agent has navigated several times since). Fallback fires. User's current tab gets clobbered.&lt;/p&gt;

&lt;p&gt;The bug pattern: &lt;strong&gt;a "safe" recovery path created the exact failure mode the grace window was designed to prevent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The permanent fix is a one-line change in spirit:&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;let&lt;/span&gt; &lt;span class="nx"&gt;_hasOwnedTab&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="c1"&gt;// session-scoped, sticky&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;newTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... existing logic ...&lt;/span&gt;
  &lt;span class="nx"&gt;_hasOwnedTab&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="c1"&gt;// ← set once, never reset&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;_assertNotFallingBackToUserTab&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;_hasOwnedTab&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MCP previously owned a tab in this session, but tracking was lost. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Refusing to fall back to the user's current tab. Call safari_new_tab to re-establish.&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;// sessions that never called new_tab can still use front-document fallback&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flag is set the first time &lt;code&gt;safari_new_tab&lt;/code&gt; succeeds, and &lt;strong&gt;it never resets for the lifetime of the MCP process.&lt;/strong&gt; The four entry points that can target a tab — &lt;code&gt;_assertNotFallingBackToUserTab&lt;/code&gt; (used by &lt;code&gt;navigate&lt;/code&gt; and &lt;code&gt;navigateAndRead&lt;/code&gt;), &lt;code&gt;runJS&lt;/code&gt;'s tab-ghost fallback path, and &lt;code&gt;runJSLarge&lt;/code&gt; — all call this assertion before falling back to the user's current tab.&lt;/p&gt;

&lt;p&gt;If the assertion throws, the agent gets a clear error pointing back at &lt;code&gt;safari_new_tab&lt;/code&gt;. The user's tab is untouched.&lt;/p&gt;

&lt;p&gt;Sessions that &lt;em&gt;never&lt;/em&gt; call &lt;code&gt;safari_new_tab&lt;/code&gt; (e.g. tools that explicitly read the user's current tab) are unaffected — &lt;code&gt;_hasOwnedTab&lt;/code&gt; stays false, and the front-document fallback still works for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd take away if I were writing my own MCP server
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The fallback you don't notice is the fallback that bites.&lt;/strong&gt;&lt;br&gt;
"Use the user's current tab" looks like a reasonable degraded mode in isolation. In context — an autonomous agent acting on the user's real, logged-in browser — it's the worst possible default. The fix wasn't "make the fallback work better." It was "the fallback should not exist in this branch of the state machine."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. State-tracking bugs aren't subtle. They're catastrophic.&lt;/strong&gt;&lt;br&gt;
A misidentified tab is a misidentified action. The class of bug here — &lt;em&gt;I think I'm acting on X but I'm actually acting on Y&lt;/em&gt; — is the same class as a deployment script targeting prod instead of staging, or a Git rebase rewriting the wrong branch. There's no "minor version" of this bug. Engineering effort should be priced accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "Sticky" flags beat "windowed" flags for invariants you actually need.&lt;/strong&gt;&lt;br&gt;
The v2.10.1 grace window was time-bounded. That made sense for the failure mode I'd seen. But sessions are unbounded. &lt;em&gt;Anything that can happen during the session can happen after the grace window expires.&lt;/em&gt; If the property "MCP has owned a tab in this session" is the actual thing protecting the user, that property must hold for the whole session — not 30 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Tests on clean state miss the bugs that matter.&lt;/strong&gt;&lt;br&gt;
I tested v2.10.0 on a fresh Safari with no other tabs. The user-tab-clobber bug is &lt;em&gt;invisible&lt;/em&gt; in that environment, because the user's tab and MCP's tab are the same tab. Real users have eight tabs open and were just clicking around in tab six. If your tool drives a user's real browser, your test environment must have &lt;em&gt;unrelated, in-progress tabs&lt;/em&gt; — and your failure modes must be loud when you brush against them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Errors are a feature.&lt;/strong&gt;&lt;br&gt;
The replacement for "silent fallback to user tab" is a thrown error with a remediation message: &lt;em&gt;"Call &lt;code&gt;safari_new_tab&lt;/code&gt; to re-establish."&lt;/em&gt; That error is &lt;em&gt;better than the original happy path&lt;/em&gt; — because the original happy path was sometimes a disaster. A loud, fixable error is always better than a quiet, irreversible mistake.&lt;/p&gt;




&lt;h2&gt;
  
  
  The diff that mattered
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gi"&gt;+ let _hasOwnedTab = false;
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;  async function newTab(url) {
    const idx = await openBlankTab();
    await navigate(idx, url);
    await injectMarker(idx);
    _activeTabIndex = idx;
&lt;span class="gi"&gt;+   _hasOwnedTab = true;
&lt;/span&gt;  }
&lt;span class="err"&gt;
&lt;/span&gt;  function getFallbackTarget() {
&lt;span class="gi"&gt;+   if (_hasOwnedTab) {
+     throw new Error("…re-establish via safari_new_tab");
+   }
&lt;/span&gt;    return frontDocumentOfFrontWindow();
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the load-bearing part. Everything else in v2.10.3 is plumbing.&lt;/p&gt;

&lt;p&gt;If you're building an MCP server (or any tool that drives a user's real browser/editor/database), the question I'd ask in code review is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"What's the worst thing this fallback can do, and does the fallback's existence buy enough to be worth that worst case?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For tab fallback in Safari MCP, the answer was: &lt;strong&gt;no, it doesn't.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;github.com/achiya-automation/safari-mcp&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Install:&lt;/strong&gt; &lt;code&gt;npx safari-mcp&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;Releases discussed:&lt;/strong&gt; &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.10.0" rel="noopener noreferrer"&gt;v2.10.0&lt;/a&gt;, &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.10.1" rel="noopener noreferrer"&gt;v2.10.1&lt;/a&gt;, &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.10.3" rel="noopener noreferrer"&gt;v2.10.3&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you shipped a state-tracking bug that ate user data? What was the failure mode, and what flag/invariant ended up being the real fix?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>debugging</category>
      <category>javascript</category>
    </item>
    <item>
      <title>LinkedIn Quietly Migrated From ProseMirror to Quill — and Broke Every Browser Automation Tool That Touched the Composer</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 03 May 2026 07:34:07 +0000</pubDate>
      <link>https://forem.com/achiya-automation/linkedin-quietly-migrated-from-prosemirror-to-quill-and-broke-every-browser-automation-tool-that-4927</link>
      <guid>https://forem.com/achiya-automation/linkedin-quietly-migrated-from-prosemirror-to-quill-and-broke-every-browser-automation-tool-that-4927</guid>
      <description>&lt;p&gt;I shipped a fix to my MCP server last week for LinkedIn's ProseMirror composer. It worked. Two days later, every LinkedIn post automation broke.&lt;/p&gt;

&lt;p&gt;This is the post-mortem of what changed, how I figured it out, and why "automate the platform" stories almost always end this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crash
&lt;/h2&gt;

&lt;p&gt;The symptom was specific. My MCP server's &lt;code&gt;safari_fill&lt;/code&gt; tool — which dutifully filled ProseMirror by walking React Fiber and calling &lt;code&gt;editor.commands.setContent(html)&lt;/code&gt; — was now crashing the helper daemon and dismissing the composer dialog the instant it touched the contenteditable.&lt;/p&gt;

&lt;p&gt;Same composer URL. Same DOM tree at first glance. Same selectors. Different editor underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DOM tells the truth
&lt;/h2&gt;

&lt;p&gt;I dropped into the browser console and ran the usual probe:&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;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[contenteditable="true"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="c1"&gt;// -&amp;gt; undefined&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.ProseMirror&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// -&amp;gt; null&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.ql-editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// -&amp;gt; &amp;lt;div class="ql-editor"&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was. &lt;code&gt;.ql-editor&lt;/code&gt; is the canonical Quill class name. LinkedIn had swapped the post composer from ProseMirror to Quill at some point in early 2026 with no announcement I can find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it was crashing
&lt;/h2&gt;

&lt;p&gt;Quill, like ProseMirror, doesn't let you "just" stuff text into the contenteditable. Both editors hold an internal model — Quill calls it a Delta — and the DOM is downstream of that model.&lt;/p&gt;

&lt;p&gt;If you bypass the model and write to the DOM directly, two things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The model and DOM disagree.&lt;/li&gt;
&lt;li&gt;The next user-driven event (a keystroke, a save) triggers a re-render that throws because the diff is incoherent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's what was killing the composer. My fill was writing to &lt;code&gt;innerText&lt;/code&gt;, the Delta state thought the editor was still empty, the React tree tried to reconcile, and the dialog evaporated. The Swift daemon caught the cascading exception and crashed itself for good measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: drive Quill the way it expects to be driven
&lt;/h2&gt;

&lt;p&gt;Quill exposes a programmatic API. You just need a reference to the instance. The lookup order I landed on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Walk up to find an ancestor with class &lt;code&gt;.ql-container&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Try &lt;code&gt;.__quill&lt;/code&gt; — Quill 2.x attaches the instance there directly.&lt;/li&gt;
&lt;li&gt;Fall back to React Fiber: walk up the fiber chain looking for &lt;code&gt;memoizedProps.quill&lt;/code&gt; or &lt;code&gt;stateNode.quill&lt;/code&gt; (LinkedIn wraps Quill in a React component that holds the instance in props).&lt;/li&gt;
&lt;li&gt;If still nothing, fall back to a real CGEvent &lt;code&gt;Cmd+V&lt;/code&gt; paste — Quill respects clipboard events with &lt;code&gt;isTrusted: true&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once you have the instance, the actual fill is one 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="nx"&gt;quill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContents&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;'api'&lt;/code&gt; source flag is the part that matters. It tells Quill "this came from your own API, update your model and the DOM together." The text commits, the Delta stays consistent, and the React parent doesn't try to re-conciliate against a corrupted model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this taught me about platform automation
&lt;/h2&gt;

&lt;p&gt;Two lessons, both old, both worth re-learning:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editors aren't a stable interface.&lt;/strong&gt; ProseMirror and Quill have different APIs, different state models, and different rules for "what counts as a real edit." Targeting one of them only works until the platform decides it doesn't anymore. LinkedIn made this swap with zero changelog. The only way I knew was that my code broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DOM is the lowest common denominator. The editor model is the actual one.&lt;/strong&gt; Every automation tool that synthesizes events on the contenteditable is operating one layer below the truth. Sometimes that works (because the editor reconciles). Sometimes it doesn't (because the editor crashes or silently discards the input). The robust path is always to find the editor instance and call its API.&lt;/p&gt;

&lt;p&gt;There's a third lesson, which is more uncomfortable: I couldn't fully verify my fix on LinkedIn, because LinkedIn's modal-opening behavior in headless contexts is independently broken right now. The composer button accepts clicks, the dialog DOM materializes, but it never visually opens. So the Quill detection is in place — and verified on test pages — but the LinkedIn-specific live path is still gated on a separate modal issue I haven't cracked.&lt;/p&gt;

&lt;p&gt;This is the texture of platform automation. Two unrelated bugs, same week, same target. Each one looks like the other. You ship a fix for one and the other one masquerades as a regression.&lt;/p&gt;

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

&lt;p&gt;If you're building anything that types into a third-party rich text editor — Slack, LinkedIn, Discord, Medium, Notion — the editor identity is part of your contract with the platform, and the platform doesn't owe you stability there. Detect the editor type at runtime. Have a fallback for the unknown case (real clipboard events, ideally). Log what you found, so when it changes you find out from your own telemetry instead of from a Slack message at 11pm.&lt;/p&gt;

&lt;p&gt;And read the contenteditable's class list before you touch it. ProseMirror and Quill have different class signatures and the DOM will tell you what you're dealing with — if you ask.&lt;/p&gt;

&lt;p&gt;The fix shipped in &lt;a href="https://www.npmjs.com/package/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp@2.10.2&lt;/a&gt;. Source on &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>mcp</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <item>
      <title>When GitHub Actions Goes Silent: The Pending-Forever Bug I Hit Shipping My MCP Server to npm</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Tue, 28 Apr 2026 19:22:06 +0000</pubDate>
      <link>https://forem.com/achiya-automation/when-github-actions-goes-silent-the-pending-forever-bug-i-hit-shipping-my-mcp-server-to-npm-229m</link>
      <guid>https://forem.com/achiya-automation/when-github-actions-goes-silent-the-pending-forever-bug-i-hit-shipping-my-mcp-server-to-npm-229m</guid>
      <description>&lt;p&gt;I have an &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;open-source MCP server&lt;/a&gt;. I tag a release, push, GitHub Actions builds, npm publishes, MCP Registry updates. That's the contract. It worked for v2.7.6 through v2.8.4.&lt;/p&gt;

&lt;p&gt;Then v2.8.5 didn't publish. Neither did v2.8.6. Or v2.9.0. Or v2.9.1. Or v2.9.2. Or v2.9.3.&lt;/p&gt;

&lt;p&gt;Six releases stuck. Not failing — &lt;strong&gt;stuck&lt;/strong&gt;. Yellow dot. Forever.&lt;/p&gt;

&lt;p&gt;Here's what was actually happening. And how I got the releases out without GitHub Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that doesn't match any docs
&lt;/h2&gt;

&lt;p&gt;Every release event triggered the workflow. Every workflow showed up in the runs list. None of them ever started a job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;gh run view 25001890100 &lt;span class="nt"&gt;--json&lt;/span&gt; status,conclusion,jobs
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"status"&lt;/span&gt;: &lt;span class="s2"&gt;"queued"&lt;/span&gt;,
  &lt;span class="s2"&gt;"conclusion"&lt;/span&gt;: null,
  &lt;span class="s2"&gt;"jobs"&lt;/span&gt;: &lt;span class="o"&gt;[]&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No conclusion. No jobs. Empty &lt;code&gt;pending_deployments&lt;/code&gt;. Not "waiting for approval". Not "in_progress". Not "failure". Just &lt;strong&gt;pending&lt;/strong&gt; with no work scheduled — for 125 hours.&lt;/p&gt;

&lt;p&gt;If you search "GitHub Actions stuck pending", you'll find a hundred forum posts. Every answer assumes one of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You hit the runner concurrency limit (3 for free-tier macos)&lt;/li&gt;
&lt;li&gt;You have a deployment environment requiring approval&lt;/li&gt;
&lt;li&gt;Your &lt;code&gt;runs-on:&lt;/code&gt; label is unreachable&lt;/li&gt;
&lt;li&gt;You're using self-hosted runners with no online agents&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those applied. My workflow was simple, no environments with required reviewers, &lt;code&gt;runs-on: macos-latest&lt;/code&gt;, no self-hosted runners.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing GitHub doesn't tell you in the run UI
&lt;/h2&gt;

&lt;p&gt;The runs list shows pending. The run detail page shows pending. The job list shows nothing. The "deployment" tab shows nothing.&lt;/p&gt;

&lt;p&gt;But if you look at your &lt;strong&gt;billing dashboard&lt;/strong&gt;, there's a different story:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Your account has used 100% of included macOS minutes for this billing period.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. That's the entire diagnostic. There is no banner on the run page. The workflow doesn't fail with a clear error. It just sits in the queue forever — because the runner that would pick it up doesn't exist, and the queue doesn't time out events.&lt;/p&gt;

&lt;p&gt;The minutes counter resets monthly. Until it does, every release event becomes another silent pending row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two facts that surprised me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fact 1: macOS runners cost 10x more than Linux runners.&lt;/strong&gt; Both &lt;code&gt;runs-on: macos-latest&lt;/code&gt; and &lt;code&gt;runs-on: macos-13&lt;/code&gt; charge against your Actions minutes at a 10x multiplier. The free 2,000 minutes/month gets you 200 minutes of macOS — about 20 release builds if each takes 10 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fact 2: Switching to Linux didn't fix it.&lt;/strong&gt; I changed &lt;code&gt;runs-on: macos-latest&lt;/code&gt; to &lt;code&gt;runs-on: ubuntu-latest&lt;/code&gt;. Same symptom. 0 jobs queued, status "pending". Why?&lt;/p&gt;

&lt;p&gt;The macOS minutes meter is one bucket. The Linux meter is another. When the macOS bucket emptied, my pending macOS runs were still in the queue, blocking new runs. Even after switching the workflow to ubuntu, the &lt;em&gt;concurrency group&lt;/em&gt; in the YAML serialized everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;publish&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So new ubuntu runs queued behind old stuck macOS runs and never started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-part fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Part 1: workflow_dispatch with tag input
&lt;/h3&gt;

&lt;p&gt;Adding a manual trigger lets you re-publish a tag whose release-event run got stuck, without deleting and recreating the GitHub Release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tag&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;publish&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(e.g.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;v2.9.3)"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In every step that needs the tag, fall back through both event types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.inputs.tag || github.ref_name }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone isn't enough — if the runner pool is still empty, the dispatched run also stalls. But it gives you a clean re-trigger path the moment runners are back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 2: portable runner OS
&lt;/h3&gt;

&lt;p&gt;The workflow downloaded &lt;code&gt;mcp-publisher_darwin_${ARCH}.tar.gz&lt;/code&gt; — hardcoded "darwin". Switching to ubuntu broke that step. Generalize:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Download mcp-publisher&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;OS=$(uname -s | tr '[:upper:]' '[:lower:]')&lt;/span&gt;
    &lt;span class="s"&gt;ARCH=$(uname -m)&lt;/span&gt;
    &lt;span class="s"&gt;if [ "$ARCH" = "x86_64" ]; then ARCH=amd64; fi&lt;/span&gt;
    &lt;span class="s"&gt;if [ "$ARCH" = "aarch64" ]; then ARCH=arm64; fi&lt;/span&gt;
    &lt;span class="s"&gt;curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}_${ARCH}.tar.gz" -o mcp-publisher.tar.gz&lt;/span&gt;
    &lt;span class="s"&gt;tar -xzf mcp-publisher.tar.gz mcp-publisher&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same step works on macOS-arm64, macOS-x86_64, ubuntu-x86_64, and any future runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual workaround that actually shipped the release
&lt;/h2&gt;

&lt;p&gt;While the workflow stays stuck, here's how I got v2.9.3 to npm and the MCP Registry from my laptop:&lt;/p&gt;

&lt;h3&gt;
  
  
  npm: the easy part
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout v2.9.3
npm publish &lt;span class="nt"&gt;--provenance&lt;/span&gt; &lt;span class="nt"&gt;--access&lt;/span&gt; public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--provenance&lt;/code&gt; requires a valid OIDC token, which only works inside GitHub Actions. Skip it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm publish &lt;span class="nt"&gt;--access&lt;/span&gt; public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You lose the provenance attestation, but the package ships. Provenance is a nice-to-have, not a publish blocker.&lt;/p&gt;

&lt;h3&gt;
  
  
  MCP Registry: the trickier part
&lt;/h3&gt;

&lt;p&gt;The MCP Registry's CLI authenticates interactively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mcp-publisher login github
&lt;span class="c"&gt;# Opens a browser, asks you to paste a code, etc.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's fine for humans. For a script — or for a Claude session running headless — you need non-interactive auth. The &lt;code&gt;mcp-publisher&lt;/code&gt; binary accepts &lt;code&gt;-token&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh auth token&lt;span class="si"&gt;)&lt;/span&gt;
mcp-publisher login github &lt;span class="nt"&gt;-token&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
mcp-publisher publish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;gh&lt;/code&gt; CLI you already use for everything else? Its token works as your GitHub PAT for &lt;code&gt;mcp-publisher&lt;/code&gt;. No browser, no copy-paste.&lt;/p&gt;

&lt;p&gt;After running these, the MCP Registry's &lt;code&gt;io.github.achiya-automation/safari-mcp&lt;/code&gt; v2.9.3 went from "stuck on v2.7.6 for 3 weeks" to &lt;code&gt;isLatest: true&lt;/code&gt; in about 15 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past-me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the billing dashboard *first&lt;/strong&gt;* when an Actions run sits pending with no error. The run UI does not surface "you're out of minutes". The billing page does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust &lt;code&gt;runs-on: ubuntu-latest&lt;/code&gt; to "just be cheaper"&lt;/strong&gt; — it is, but if you've burned your macOS minutes on stalled runs, the queue can still serialize new ones behind dead ones via your &lt;code&gt;concurrency:&lt;/code&gt; group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a manual publish path documented.&lt;/strong&gt; Both npm and the MCP Registry have non-interactive auth options. Write the bash one-liners somewhere your future self can find them at 2am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt; with a tag input is cheap insurance.&lt;/strong&gt; It costs you 6 lines of YAML and saves you from needing to delete-and-recreate GitHub Releases when the release-event run gets corrupted.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why didn't a &lt;code&gt;timeout-minutes:&lt;/code&gt; rescue me?&lt;/strong&gt;&lt;br&gt;
That's a job-level timeout. It applies once a job &lt;em&gt;starts&lt;/em&gt;. A run that never starts a job has nothing to time out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Couldn't I have used a self-hosted runner?&lt;/strong&gt;&lt;br&gt;
Yes — and that's the right answer for high-volume projects. For an OSS hobby project, self-hosted is operationally heavier than the manual publish path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't &lt;code&gt;--provenance&lt;/code&gt; matter for supply-chain security?&lt;/strong&gt;&lt;br&gt;
For widely-installed packages, yes. For an OSS project's own emergency-publish workaround, the trade-off is "ship the release without provenance" vs "ship nothing". Pick the first one and re-publish with provenance on the next clean release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could I have known about the billing limit before hitting it?&lt;/strong&gt;&lt;br&gt;
GitHub does send an email when you cross 75% of your minutes. The email goes to the address on your billing account, which may not be the address you watch. Worth setting up a filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Actions minutes for OSS public repos?&lt;/strong&gt;&lt;br&gt;
GitHub gives unlimited minutes to public repos using GitHub-hosted runners — but that's only for repos owned by &lt;strong&gt;organizations on the Free plan, with the runner type matching the included unlimited tier&lt;/strong&gt;. For personal accounts and certain runner combinations, the standard quota applies. Check the actual numbers under Settings → Billing → Plans for your specific account type.&lt;/p&gt;




&lt;p&gt;If you've hit a similar stuck-pending pattern with no error in the run UI — that's the bug. Check your minutes. Then ship from your laptop.&lt;/p&gt;

&lt;p&gt;The repo (with the workflow that handles all this now) is &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>github</category>
      <category>ci</category>
      <category>npm</category>
      <category>devops</category>
    </item>
    <item>
      <title>The 3 isTrusted:false Bugs That Made LinkedIn Posts Impossible From My MCP Server</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 22 Apr 2026 14:36:56 +0000</pubDate>
      <link>https://forem.com/achiya-automation/the-3-istrustedfalse-bugs-that-made-linkedin-posts-impossible-from-my-mcp-server-102f</link>
      <guid>https://forem.com/achiya-automation/the-3-istrustedfalse-bugs-that-made-linkedin-posts-impossible-from-my-mcp-server-102f</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I couldn't post to LinkedIn from my MCP server. Not "sometimes fails" — &lt;em&gt;never works&lt;/em&gt;. I assumed one bug. I was wrong. I found three, stacked, and each one looked like success to every automation tool I tried. Here is the anatomy of why your agent's "I posted it!" lies to you when a rich-text editor sits inside a dialog.&lt;/p&gt;




&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;I ship Safari MCP — an MCP server that drives the Safari you are already logged into. 80 tools. &lt;code&gt;safari_fill&lt;/code&gt; is the most-used one. For three months it worked everywhere — Gmail, GitHub, Ahrefs, Google Docs, Shopify admin.&lt;/p&gt;

&lt;p&gt;Then I tried posting to LinkedIn from an agent.&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;safari_fill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Shipping v2.9.0 — modal detection in snapshot!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Filled. 67 chars.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except the LinkedIn composer was empty. And closed. And I had a cursor in my address bar.&lt;/p&gt;

&lt;p&gt;Three hours later I had a list. Three separate boundaries, each silently sabotaging the one before it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Boundary 1: &lt;code&gt;focusout&lt;/code&gt; dismisses the dialog
&lt;/h2&gt;

&lt;p&gt;LinkedIn's composer is a &lt;code&gt;&amp;lt;div role="dialog"&amp;gt;&lt;/code&gt;. Specifically, its share composer listens for &lt;code&gt;focusout&lt;/code&gt; on any descendant and closes the modal — the UX intent is "clicked outside → close."&lt;/p&gt;

&lt;p&gt;My fill path did this at the end:&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;// Old: "polite" contenteditable fill (pseudo-code)&lt;/span&gt;
&lt;span class="nf"&gt;setEditableContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editableEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;editableEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&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;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;editableEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// ← here's the assassin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;blur()&lt;/code&gt; call was there for a reason — some React frameworks only commit state on blur. Perfectly reasonable on a standalone textarea. Inside a dialog? The &lt;code&gt;focusout&lt;/code&gt; listener takes the blur, concludes the user clicked away, and runs the dismiss animation.&lt;/p&gt;

&lt;p&gt;My fill &lt;em&gt;worked&lt;/em&gt;. For ~40ms. Then the dialog DOM disappeared and the text with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Remove the &lt;code&gt;blur()&lt;/code&gt;. React commits state from &lt;code&gt;input&lt;/code&gt; alone on any modern contenteditable. If a site truly requires &lt;code&gt;blur&lt;/code&gt; to persist, it is broken for keyboard users anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But removing blur was not enough.&lt;/strong&gt; The next run showed the text finally landing, the Post button enabling — and then the button click did nothing. Why?&lt;/p&gt;




&lt;h2&gt;
  
  
  Boundary 2: ProseMirror's &lt;code&gt;isTrusted:false&lt;/code&gt; paste rejection
&lt;/h2&gt;

&lt;p&gt;LinkedIn's composer &lt;em&gt;was&lt;/em&gt; ProseMirror when I started debugging. (They have since migrated to Lexical. We will get there.) ProseMirror has a paste handler. That handler is strict:&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;// ProseMirror source, paraphrased&lt;/span&gt;
&lt;span class="nf"&gt;handlePaste&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;view&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="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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isTrusted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Synthetic paste events don't reflect real user intent.&lt;/span&gt;
    &lt;span class="c1"&gt;// Reject them — the editor state must only change from real input.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;This is a &lt;em&gt;security&lt;/em&gt; decision, not a UX one. &lt;code&gt;event.isTrusted&lt;/code&gt; is only &lt;code&gt;true&lt;/code&gt; when the browser itself dispatches the event — a real keystroke, a real paste, a real click. JavaScript &lt;code&gt;new Event()&lt;/code&gt; or &lt;code&gt;dispatchEvent()&lt;/code&gt; produces &lt;code&gt;isTrusted:false&lt;/code&gt; every time.&lt;/p&gt;

&lt;p&gt;My fill was dispatching &lt;code&gt;new ClipboardEvent('paste', { clipboardData: ... })&lt;/code&gt;. The editor reached its paste handler, saw &lt;code&gt;isTrusted:false&lt;/code&gt;, and bailed. The &lt;code&gt;execCommand('insertText')&lt;/code&gt; fallback went the same way.&lt;/p&gt;

&lt;p&gt;The character-by-character &lt;code&gt;beforeinput&lt;/code&gt; dispatch? Also &lt;code&gt;isTrusted:false&lt;/code&gt;. Also rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix that worked (and broke in Boundary 3):&lt;/strong&gt; Route through a real OS paste. I already had a &lt;code&gt;_nativeTypeViaClipboard&lt;/code&gt; path — uses AppleScript to set the system clipboard, then dispatches a real Cmd+V via macOS CGEvent. The browser sees it as a real user paste. &lt;code&gt;isTrusted&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. Editor accepts it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Boundary 3: CGEvent Cmd+V steals focus, triggers Boundary 1
&lt;/h2&gt;

&lt;p&gt;Remember Boundary 1 was "focusout dismisses the dialog?" Well —&lt;/p&gt;

&lt;p&gt;The CGEvent Cmd+V path delivers the keystroke to the frontmost window. To &lt;em&gt;be&lt;/em&gt; the frontmost window, Safari has to be active. When I programmatically activate Safari via &lt;code&gt;NSApplication activateIgnoringOtherApps&lt;/code&gt;, the previous window loses focus for a tiny window. Chrome's "focus stealing" behavior is a documented pet peeve of every automation tool; Safari is no different.&lt;/p&gt;

&lt;p&gt;So the sequence was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CGEvent fires Cmd+V&lt;/li&gt;
&lt;li&gt;Safari gets activated (taking focus briefly)&lt;/li&gt;
&lt;li&gt;The composer editor sees &lt;code&gt;focusout&lt;/code&gt; during the ~10ms activation window&lt;/li&gt;
&lt;li&gt;Dialog dismisses&lt;/li&gt;
&lt;li&gt;Paste lands — but on the &lt;em&gt;feed&lt;/em&gt; underneath the now-closed dialog&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First fix attempt:&lt;/strong&gt; Use a background-activation variant that does not foreground Safari. This worked but required the user's Safari to &lt;em&gt;already&lt;/em&gt; be the active app (fragile — the point of MCP is the user is doing other work).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second fix attempt — the one that stuck:&lt;/strong&gt; Bypass the OS keyboard entirely. Drive the editor through its own internal API.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual fix: editor-native API access
&lt;/h2&gt;

&lt;p&gt;LinkedIn's composer (as of 2026-04) is Lexical, not ProseMirror. Lexical is Meta's replacement — also used in Shopify admin, some Meta apps, newer Notion surfaces.&lt;/p&gt;

&lt;p&gt;Lexical exposes the editor instance on its DOM root element:&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;editorEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-lexical-editor="true"]&lt;/span&gt;&lt;span class="dl"&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;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editorEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__lexicalEditor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// the actual LexicalEditor instance&lt;/span&gt;

&lt;span class="c1"&gt;// Build a minimal root → paragraph → text document&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseEditorState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;detail&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="na"&gt;format&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="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&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="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ltr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;indent&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="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="s1"&gt;paragraph&lt;/span&gt;&lt;span class="dl"&gt;'&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="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ltr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;indent&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="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="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&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="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEditorState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero synthetic events. Zero focus shift. Zero clipboard. The editor updates its own state directly. Lexical's internal invariants hold. React re-renders the contenteditable tree through its normal diff path. The Post button observes the state change and enables itself.&lt;/p&gt;

&lt;p&gt;For ProseMirror (which LinkedIn used to use), the equivalent is:&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;pmView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;editorEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pmViewDesc&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// ProseMirror's EditorView&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pmView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pmView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;pmView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same principle: do not pretend to be a user. Be a caller.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cascade of falsified "success"
&lt;/h2&gt;

&lt;p&gt;Here is what is unsettling: &lt;strong&gt;every stage of every failed attempt returned &lt;code&gt;success&lt;/code&gt; to my agent.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setting the editable element's content → value set, DOM mutation event fires, "success"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dispatchEvent(new ClipboardEvent('paste'))&lt;/code&gt; → handler called, &lt;code&gt;preventDefault&lt;/code&gt; returned, "looks like paste fired, success"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_nativeTypeViaClipboard&lt;/code&gt; → Cmd+V fired, clipboard had the content, "success"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only honest verification is: did the editor &lt;em&gt;state&lt;/em&gt; update? Not the DOM. Not the visible text. Not the event log. The editor's own source of truth.&lt;/p&gt;

&lt;p&gt;For Lexical: &lt;code&gt;editor.getEditorState().toJSON()&lt;/code&gt;. Compare to what you expected. Now you know.&lt;/p&gt;

&lt;p&gt;This is why your agent's "I posted it" lies. Every layer of the automation stack reports local success. None of them verified the editor's internal state matched the intent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Generalizations
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Blur is radioactive in dialogs.&lt;/strong&gt; Audit every automation tool's fill path. If it calls &lt;code&gt;.blur()&lt;/code&gt;, it will close some modal somewhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;isTrusted:false&lt;/code&gt; is a one-way door.&lt;/strong&gt; Real-world rich-text editors audit it. Your synthetic paste/input/keydown will not cross. Either use a native OS path (Cmd+V via CGEvent/winuser) or drive the editor API directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Native OS paste moves focus.&lt;/strong&gt; Which is fine — unless the target is inside a dialog that listens for focus loss. In that case, drive the editor API directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Editor API access is undocumented but stable.&lt;/strong&gt; &lt;code&gt;__lexicalEditor&lt;/code&gt;, &lt;code&gt;pmViewDesc.view&lt;/code&gt;, Draft.js's internal store — these are all in production for years because the editors are &lt;em&gt;themselves&lt;/em&gt; stable. They are not public but they are not moving.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust nothing downstream of the editor.&lt;/strong&gt; The rendered DOM, text content, visible interface — any of these can be right while the editor's internal state is wrong. Verify editor state, not DOM state.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What this means if you use or build MCP servers
&lt;/h2&gt;

&lt;p&gt;Most MCP browser tools today use &lt;code&gt;page.type()&lt;/code&gt; or &lt;code&gt;element.fill()&lt;/code&gt; — thin wrappers over DOM events. They will work for 80% of forms and silently fail for rich editors inside dialogs (which is roughly: every post/comment/share UI on every major social site, Notion, Google Docs, JIRA, Salesforce rich notes, Shopify description fields).&lt;/p&gt;

&lt;p&gt;If you are evaluating browser-automation MCP servers for agent workflows that involve content creation, test this specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can it post to LinkedIn?&lt;/li&gt;
&lt;li&gt;Can it type a multi-line comment on GitHub?&lt;/li&gt;
&lt;li&gt;Can it fill a Notion page with formatted text?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those fail silently (returns "success" but the target app shows nothing), the tool has one of these three bugs.&lt;/p&gt;




&lt;p&gt;Safari MCP v2.9.4 ships the Lexical-native path. If you are on macOS and want to try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx safari-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT, 80 tools, &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;em&gt;Is there a fourth boundary I missed? Drop a comment — I will buy the bug report with a merch sticker if it forces a v2.9.5.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>browserautomation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>WhatsApp Bot for Business 2026 — $1K-$4K (50+ Real Builds)</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 20 Apr 2026 19:54:12 +0000</pubDate>
      <link>https://forem.com/achiya-automation/whatsapp-bot-for-business-2026-1k-4k-50-real-builds-ba4</link>
      <guid>https://forem.com/achiya-automation/whatsapp-bot-for-business-2026-1k-4k-50-real-builds-ba4</guid>
      <description>&lt;p&gt;WhatsApp has over 2 billion users worldwide. If your customers are on WhatsApp (and they probably are), a bot can handle inquiries 24/7, book appointments, and qualify leads — while you sleep.&lt;/p&gt;

&lt;p&gt;But there's a catch: do it wrong, and Meta will restrict or ban your number. I've seen businesses lose their primary WhatsApp number because they used the wrong tool, sent messages to people who didn't opt in, or scaled too aggressively.&lt;/p&gt;

&lt;p&gt;This guide covers how to build a WhatsApp bot properly — which API to use, how to avoid bans, and what a realistic setup looks like.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official API&lt;/strong&gt; (via BSP) — safe, verified, but costs $50-100/month + per-message fees. Best for established businesses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAHA&lt;/strong&gt; (unofficial, open-source) — free, flexible, but not endorsed by Meta. Risk of account restrictions. Best for small businesses starting out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ban prevention&lt;/strong&gt; — get opt-in before messaging, don't send bulk unsolicited messages, respond to conversations (don't just broadcast)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realistic cost&lt;/strong&gt; — $1,000-4,000 setup + $5-100/month ongoing, depending on your approach&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two Ways to Connect: Official API vs. WAHA
&lt;/h2&gt;

&lt;p&gt;This is the first decision you'll make, and it affects everything else — cost, reliability, features, and risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: Official WhatsApp Business API
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://business.whatsapp.com/" rel="noopener noreferrer"&gt;WhatsApp Business API&lt;/a&gt; is Meta's official solution for businesses. You access it through a Business Solution Provider (BSP) like Twilio, 360dialog, or MessageBird. (If you want a full breakdown of BSPs, fees, and the onboarding process, see our &lt;a href="https://dev.to/en/blog/whatsapp-business-api-guide/"&gt;WhatsApp Business API guide&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign up with a BSP&lt;/li&gt;
&lt;li&gt;Verify your business with Meta&lt;/li&gt;
&lt;li&gt;Get a dedicated phone number (or use an existing one)&lt;/li&gt;
&lt;li&gt;Send and receive messages through the API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Pricing (as of March 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BSP monthly fee: $50-100/month (varies by provider)&lt;/li&gt;
&lt;li&gt;Per-conversation fees (set by Meta):

&lt;ul&gt;
&lt;li&gt;Marketing conversations: ~$0.035/conversation (varies by country)&lt;/li&gt;
&lt;li&gt;Utility conversations (order updates, etc.): ~$0.005/conversation&lt;/li&gt;
&lt;li&gt;Service conversations (customer-initiated): &lt;strong&gt;free&lt;/strong&gt; for the first 1,000/month&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Template messages must be pre-approved by Meta&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Officially supported — no risk of account bans for API usage&lt;/li&gt;
&lt;li&gt;Green checkmark verification available&lt;/li&gt;
&lt;li&gt;Template messages for outbound messaging&lt;/li&gt;
&lt;li&gt;Higher rate limits&lt;/li&gt;
&lt;li&gt;Multi-device support built in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per-message costs add up at scale&lt;/li&gt;
&lt;li&gt;BSP adds another vendor and monthly cost&lt;/li&gt;
&lt;li&gt;Template approval process can be slow (24-72 hours)&lt;/li&gt;
&lt;li&gt;Less flexibility — you can only do what the API allows&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Option 2: WAHA (Unofficial WhatsApp API)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Important: WAHA is NOT an official WhatsApp product.&lt;/strong&gt; It's an &lt;a href="https://github.com/devlikeapro/waha" rel="noopener noreferrer"&gt;open-source project&lt;/a&gt; that provides API access to WhatsApp by connecting through WhatsApp Web's protocol. Meta does not endorse or support it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Self-host WAHA on your server (Docker)&lt;/li&gt;
&lt;li&gt;Scan a QR code with your WhatsApp number (like WhatsApp Web)&lt;/li&gt;
&lt;li&gt;Send and receive messages through WAHA's REST API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WAHA Core: free and open-source&lt;/li&gt;
&lt;li&gt;WAHA Plus: paid version with additional features&lt;/li&gt;
&lt;li&gt;Your only cost: server hosting ($5-20/month)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No per-message fees&lt;/li&gt;
&lt;li&gt;No template approval process&lt;/li&gt;
&lt;li&gt;Full flexibility — send any message type&lt;/li&gt;
&lt;li&gt;Open-source — you can inspect and modify the code&lt;/li&gt;
&lt;li&gt;No BSP middleman&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not endorsed by Meta&lt;/strong&gt; — using it technically violates WhatsApp's Terms of Service&lt;/li&gt;
&lt;li&gt;Risk of account restrictions if you trigger spam detection&lt;/li&gt;
&lt;li&gt;Relies on WhatsApp Web protocol — can break when WhatsApp updates&lt;/li&gt;
&lt;li&gt;No green checkmark&lt;/li&gt;
&lt;li&gt;Phone must stay connected (though WAHA handles multi-device well)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Which Should You Choose?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Established business, customer communications&lt;/td&gt;
&lt;td&gt;Official API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketing campaigns and broadcasts&lt;/td&gt;
&lt;td&gt;Official API (with opt-in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small business, responding to incoming messages&lt;/td&gt;
&lt;td&gt;WAHA can work well&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing and prototyping&lt;/td&gt;
&lt;td&gt;WAHA (lower cost to experiment)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Highly regulated industry (healthcare, finance)&lt;/td&gt;
&lt;td&gt;Official API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget-conscious startup&lt;/td&gt;
&lt;td&gt;WAHA to start, migrate to official later&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In our experience, many small businesses start with WAHA because the barrier to entry is lower, then migrate to the official API as they scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to NOT Get Banned (Critical)
&lt;/h2&gt;

&lt;p&gt;Whether you use the official API or WAHA, these rules apply:&lt;/p&gt;

&lt;h3&gt;
  
  
  The #1 Rule: Get Opt-In First
&lt;/h3&gt;

&lt;p&gt;Never send the first message to someone who hasn't explicitly asked to hear from you. This is both a WhatsApp policy requirement and common sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Customer fills out a form and checks "Contact me on WhatsApp"&lt;/li&gt;
&lt;li&gt;Customer sends you a message first and you respond&lt;/li&gt;
&lt;li&gt;Customer explicitly asks to receive updates via WhatsApp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Bad:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You bought a list of phone numbers and blast them all&lt;/li&gt;
&lt;li&gt;You scrape numbers from websites and send cold messages&lt;/li&gt;
&lt;li&gt;You add everyone in your phone contacts to a broadcast list&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rate Limiting
&lt;/h3&gt;

&lt;p&gt;Don't send hundreds of messages per minute. WhatsApp's detection algorithms look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High volume in short time&lt;/strong&gt; — sending 500 messages in 5 minutes is a red flag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identical messages&lt;/strong&gt; — sending the exact same text to many numbers looks like spam&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High block rate&lt;/strong&gt; — if many recipients block you, your quality rating drops fast&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messaging numbers that don't have you saved&lt;/strong&gt; — this is a strong spam signal, especially at volume&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a deeper breakdown of the exact thresholds and how WhatsApp's four-layer detection system works in 2026, see the &lt;a href="https://dev.to/en/blog/whatsapp-spam-detection-2026/"&gt;WhatsApp spam detection guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safe practices:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Space out bulk messages (add 2-5 second delays between sends)&lt;/li&gt;
&lt;li&gt;Personalize messages (use the recipient's name, reference their specific inquiry)&lt;/li&gt;
&lt;li&gt;Keep your block rate under 2-3%&lt;/li&gt;
&lt;li&gt;Start slow — send to 50 people first, monitor for blocks, then scale gradually&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  WAHA-Specific Precautions
&lt;/h3&gt;

&lt;p&gt;If you're using WAHA (unofficial API):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use a dedicated number&lt;/strong&gt; — don't risk your primary business number&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't blast&lt;/strong&gt; — WAHA is best for responding to incoming messages, not mass outbound campaigns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor your quality&lt;/strong&gt; — if you notice messages not delivering, stop and investigate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a backup plan&lt;/strong&gt; — if the number gets restricted, you need to be able to switch to the official API or a new number&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep sessions stable&lt;/strong&gt; — frequent disconnections/reconnections can trigger flags&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Building Your Bot: A Practical Walkthrough
&lt;/h2&gt;

&lt;p&gt;Here's how a typical WhatsApp bot setup looks using &lt;a href="https://n8n.io/get-started/?ref=achiya" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; (our preferred automation platform) and WAHA.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture Overview
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer sends WhatsApp message
        ↓
    WAHA (receives message via WhatsApp Web)
        ↓
    Webhook → n8n (processes the message)
        ↓
    Logic: FAQ? Appointment? Lead? → Route accordingly
        ↓
    Response sent back through WAHA
        ↓
    Customer receives reply on WhatsApp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1: Set Up WAHA
&lt;/h3&gt;

&lt;p&gt;WAHA runs as a Docker container. Basic setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;waha&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;devlikeapro/waha&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WHATSAPP_DEFAULT_ENGINE=WEBJS&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WAHA_DASHBOARD_ENABLED=true&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;waha_data:/app/.sessions&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;waha_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After starting it (&lt;code&gt;docker compose up -d&lt;/code&gt;), open &lt;code&gt;http://your-server:3000/dashboard&lt;/code&gt;, start a session, and scan the QR code with your phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Connect to n8n
&lt;/h3&gt;

&lt;p&gt;In &lt;a href="https://n8n.io/get-started/?ref=achiya" rel="noopener noreferrer"&gt;n8n&lt;/a&gt;, create a webhook node that WAHA will call when messages arrive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a &lt;strong&gt;Webhook&lt;/strong&gt; node — this receives incoming messages&lt;/li&gt;
&lt;li&gt;Configure WAHA to send webhooks to your n8n webhook URL&lt;/li&gt;
&lt;li&gt;Add a &lt;strong&gt;Switch&lt;/strong&gt; node to route messages based on content&lt;/li&gt;
&lt;li&gt;Add response nodes to send replies back through WAHA's API&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 3: Build Your Logic
&lt;/h3&gt;

&lt;p&gt;A basic FAQ bot might look like this in n8n:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook (incoming message)
    → Switch node:
        - Contains "hours" or "open" → Send business hours
        - Contains "price" or "cost" → Send pricing info
        - Contains "appointment" or "book" → Start booking flow
        - Default → "Thanks for reaching out! A team member will reply shortly."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Add AI (Optional)
&lt;/h3&gt;

&lt;p&gt;To make your bot smarter, add an AI node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook (incoming message)
    → OpenAI/Claude node:
        System prompt: "You are a helpful assistant for [Business Name].
        You know: [business hours, services, pricing, FAQ].
        If you can't answer, say you'll connect them with a human."
    → Send AI response via WAHA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This turns your bot from a rigid keyword-matcher into a conversational agent that understands natural language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;p&gt;These are use cases we've implemented (described in general terms):&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Appointment Scheduling
&lt;/h3&gt;

&lt;p&gt;The bot asks what service the customer needs, checks available time slots from Google Calendar, proposes options, and books the appointment — all within WhatsApp. Confirmation and reminder messages are automated.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Lead Qualification
&lt;/h3&gt;

&lt;p&gt;When a new lead messages, the bot asks 3-4 qualifying questions (budget, timeline, requirements). Qualified leads get forwarded to a human agent immediately. Unqualified leads get a helpful resource and are added to a follow-up sequence.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Order Status Updates
&lt;/h3&gt;

&lt;p&gt;Connected to the business's order management system, the bot responds to "Where's my order?" with real-time tracking information. No human intervention needed for 80%+ of status inquiries.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. FAQ + Human Handoff
&lt;/h3&gt;

&lt;p&gt;The bot handles common questions (pricing, hours, location, services). When it can't answer or the customer asks for a human, the conversation is routed to a support agent in &lt;a href="https://www.chatwoot.com/?via=achiya-automation" rel="noopener noreferrer"&gt;Chatwoot&lt;/a&gt; (open-source customer support platform; &lt;strong&gt;5% off Cloud with code &lt;code&gt;UJR5GXWK&lt;/code&gt;&lt;/strong&gt;) — with full conversation history preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Costs
&lt;/h2&gt;

&lt;p&gt;Here's a realistic breakdown for a small business:&lt;/p&gt;

&lt;h3&gt;
  
  
  DIY with WAHA + n8n (self-hosted)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPS (2GB RAM)&lt;/td&gt;
&lt;td&gt;$5-20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAHA&lt;/td&gt;
&lt;td&gt;Free (Core)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;Free (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI API (if using AI)&lt;/td&gt;
&lt;td&gt;$5-50 (depends on volume)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$10-70/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Professional Setup (someone builds it for you)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bot development&lt;/td&gt;
&lt;td&gt;$1,000-4,000 (one-time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting + maintenance&lt;/td&gt;
&lt;td&gt;$25-75/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI API costs&lt;/td&gt;
&lt;td&gt;$5-50/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$1,000-4,000 setup + $30-125/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Official API Route
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BSP subscription&lt;/td&gt;
&lt;td&gt;$50-100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp conversation fees&lt;/td&gt;
&lt;td&gt;$20-200 (depends on volume)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n or automation platform&lt;/td&gt;
&lt;td&gt;$0-25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$70-325/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Common Mistakes I See
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Going straight to mass messaging.&lt;/strong&gt; Build a bot that responds to incoming messages first. Get that working well. Then — and only then — consider outbound campaigns, and always with opt-in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Not planning for human handoff.&lt;/strong&gt; No bot handles 100% of conversations. You need a clear path for escalating to a human agent. We use &lt;a href="https://www.chatwoot.com/?via=achiya-automation" rel="noopener noreferrer"&gt;Chatwoot&lt;/a&gt; for this — the bot handles routine questions, and complex issues are seamlessly transferred to a person. &lt;em&gt;Reader perk: **5% off Chatwoot Cloud with code &lt;code&gt;UJR5GXWK&lt;/code&gt;&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Ignoring the conversation window.&lt;/strong&gt; With the official API, you have a 24-hour window to respond to a customer's message for free. After that, you need to use a pre-approved template (which costs money). Design your bot to respond instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Overcomplicating the bot.&lt;/strong&gt; Start with 5-10 common questions. Get those right. Then expand. A bot that handles 10 things well is better than one that handles 50 things poorly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Not testing with real users.&lt;/strong&gt; Your team will use the bot differently than your customers. Test with actual customers (or friends who can pretend to be customers) before going live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond the Bot: Scaling Into Full Automation
&lt;/h2&gt;

&lt;p&gt;A WhatsApp bot is usually the first automation businesses deploy — but it's rarely the last. Once you have conversations flowing in, the natural next steps are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/en/blog/ai-agents-for-business/"&gt;AI agents for business&lt;/a&gt;&lt;/strong&gt; — move from scripted replies to autonomous agents that handle multi-step tasks (lookup orders, escalate tickets, schedule appointments) without hand-written flows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/en/blog/business-automation-guide/"&gt;Broader business automation&lt;/a&gt;&lt;/strong&gt; — the same n8n instance that powers your bot can automate invoicing, CRM updates, lead routing, and inventory sync. One workflow engine, many business processes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/en/blog/chatbot-customer-service/"&gt;Dedicated customer service chatbots&lt;/a&gt;&lt;/strong&gt; — once your WhatsApp flow is stable, the same stack can power an omnichannel support bot (web chat + Messenger + email) with ticket routing and SLA tracking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of our clients start with a WhatsApp bot and expand outward as they see ROI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;Building a WhatsApp bot doesn't have to be complicated or expensive. Start with a clear goal ("I want to handle appointment bookings automatically"), choose your API approach, and build from there.&lt;/p&gt;

&lt;p&gt;If you want help building your WhatsApp bot — whether it's a simple FAQ responder or a full AI-powered agent — &lt;a href="https://achiya-automation.com/en/contact/" rel="noopener noreferrer"&gt;reach out to us&lt;/a&gt;. At &lt;a href="https://achiya-automation.com/en/" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt;, we specialize in WhatsApp bots, business automation, and CRM integration using open-source tools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://achiya-automation.com/en/contact/" rel="noopener noreferrer"&gt;Contact us&lt;/a&gt; or message us directly on &lt;a href="https://wa.me/972504197060" rel="noopener noreferrer"&gt;WhatsApp&lt;/a&gt; — we practice what we preach.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>chatbots</category>
      <category>automation</category>
      <category>node</category>
    </item>
    <item>
      <title>I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked)</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 19 Apr 2026 18:48:16 +0000</pubDate>
      <link>https://forem.com/achiya-automation/i-replaced-chrome-with-safari-for-ai-browser-automation-heres-what-broke-and-what-finally-worked-15ep</link>
      <guid>https://forem.com/achiya-automation/i-replaced-chrome-with-safari-for-ai-browser-automation-heres-what-broke-and-what-finally-worked-15ep</guid>
      <description>&lt;p&gt;&lt;em&gt;Or: why every browser-automation MCP uses Chromium, and why that's the wrong default on macOS.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem I kept hitting
&lt;/h2&gt;

&lt;p&gt;Every browser automation MCP server I tried on my Mac — &lt;code&gt;chrome-devtools-mcp&lt;/code&gt;, &lt;code&gt;playwright-mcp&lt;/code&gt;, &lt;code&gt;browsermcp&lt;/code&gt;, &lt;code&gt;puppeteer-mcp&lt;/code&gt; — did the same thing: spin up a fresh Chromium instance with nothing in it. No logins, no cookies, no session state. Then my AI agent would spend the first 5 minutes of every task navigating Cloudflare, solving reCAPTCHA, or explaining to me that it couldn't log into Gmail.&lt;/p&gt;

&lt;p&gt;Which is weird, because I was &lt;em&gt;already&lt;/em&gt; logged into Gmail. In Safari. In the window right next to me.&lt;/p&gt;

&lt;p&gt;The disconnect bothered me enough that I started reading Chromium-MCP source code. And what I found is that the entire ecosystem is built on an assumption that quietly doesn't hold for macOS users: &lt;strong&gt;"just spin up Chromium, it'll be fine."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It isn't fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Chromium costs on Apple Silicon
&lt;/h2&gt;

&lt;p&gt;Every Chromium process on M1/M2/M3 Macs pays a non-trivial tax:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple helper processes per tab (GPU, renderer, network, storage)&lt;/li&gt;
&lt;li&gt;WebKit-parity emulation that duplicates what Safari's WebKit gives you for free&lt;/li&gt;
&lt;li&gt;RAM spike on tab open, and fans audibly spinning up&lt;/li&gt;
&lt;li&gt;No access to the user's existing Safari extensions, iCloud Keychain, Apple Pay, or ApplePay-linked banking session&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you have a laptop on your lap, you feel every one of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headless-browser fallacy
&lt;/h2&gt;

&lt;p&gt;The first thing people say is: "use headless mode, it's lighter." Sort of. Headless Chromium is still Chromium — you've just hidden the window. More importantly, headless mode is what gets you blocked. Cloudflare, reCAPTCHA v3, Akamai, DataDome — they all fingerprint headless browsers within seconds. Your agent's first action on 30% of the real web becomes "prove you're human."&lt;/p&gt;

&lt;p&gt;A headful browser running on your actual machine, with your actual fingerprint, doesn't have this problem. But headful Chromium-MCP means now you have &lt;em&gt;two&lt;/em&gt; browsers open — Safari (which you're using) and Chromium (which your agent is using). That's a fan-melting setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternative no one was building
&lt;/h2&gt;

&lt;p&gt;What I wanted was obvious once I said it out loud:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Drive the Safari the user already has open. Inherit their logins, cookies, extensions, Apple Pay session. Use the WebKit process that's already running. Don't spin up a second browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What I found out when I tried to build it: &lt;strong&gt;macOS has made this weirdly hard&lt;/strong&gt;, and I think that's why nobody had done it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three things that kept breaking
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. React's &lt;code&gt;_valueTracker&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
You can't just set &lt;code&gt;input.value = "hello"&lt;/code&gt; and call &lt;code&gt;dispatchEvent("input")&lt;/code&gt;. React has an internal &lt;code&gt;_valueTracker&lt;/code&gt; on every controlled input that decides whether your "input" event is real. If the tracker thinks the value didn't change, React ignores you. Fixing this means reaching into React's internal state and calling &lt;code&gt;setter.call(input, value)&lt;/code&gt; via the prototype's native setter. It works, but it's the kind of code you don't write until you've spent an afternoon wondering why your form submission silently fails on every SPA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Shadow DOM traversal.&lt;/strong&gt;&lt;br&gt;
Modern web components hide everything behind &lt;code&gt;shadowRoot&lt;/code&gt;. &lt;code&gt;document.querySelector&lt;/code&gt; stops at the shadow boundary. You need a recursive walker with a &lt;code&gt;MutationObserver&lt;/code&gt; cache, because otherwise traversing a single YouTube page costs you 200ms. And if you get the cache invalidation wrong, clicks land on stale element refs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. CSP.&lt;/strong&gt;&lt;br&gt;
About 30% of high-value pages (Google Search Console, LinkedIn, Gmail's admin console, many banks) block inline &lt;code&gt;eval&lt;/code&gt; and &lt;code&gt;Function()&lt;/code&gt; via strict Content Security Policy. Pure JavaScript injection fails silently. The workaround is a 4-strategy fallback chain: try regular JS → try &lt;code&gt;document.evaluate&lt;/code&gt; → try AppleScript &lt;code&gt;do JavaScript&lt;/code&gt; → try an injected content script via a Safari extension. Each one has its own failure modes and you only know which applies by trial.&lt;/p&gt;

&lt;p&gt;I ended up writing this out on HackerNoon last week, because the reverse-engineering took long enough that it felt worth sharing: &lt;a href="https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome" rel="noopener noreferrer"&gt;the three hardest problems&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The unintentional side effects
&lt;/h2&gt;

&lt;p&gt;After a couple of months of using Safari-backed MCP instead of Chrome-backed MCP, I noticed a few things I wasn't expecting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;My battery lasted measurably longer on coding-agent-heavy days.&lt;/strong&gt; No surprise in retrospect — one browser instead of two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My agent's success rate on "just book this for me" tasks went up.&lt;/strong&gt; It was already logged into the calendar, the banking app, the booking portal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I stopped having to re-authenticate everything every time I rebooted.&lt;/strong&gt; Because the agent uses the browser I was already using.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari stays in the background.&lt;/strong&gt; MCP calls run via AppleScript + a persistent Swift daemon. The window doesn't steal focus, so I can keep working while an agent finishes a long task.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Boring outcomes, maybe. But they compound over a workday.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this doesn't generalize
&lt;/h2&gt;

&lt;p&gt;A caveat: this approach only makes sense on macOS. On Linux or Windows, Chromium is the right default — there's no equivalent "browser the user is already using" with the same automation surface. And you give up Chrome DevTools' performance traces and Lighthouse, which don't have Safari equivalents. I still keep Chrome DevTools MCP installed for those specific audits.&lt;/p&gt;

&lt;p&gt;But "daily browsing tasks" — navigate, click, fill a form, extract some data, take a screenshot — those are 95% of what AI agents do with browsers. And for that 95%, on macOS, it's worth reconsidering the default.&lt;/p&gt;
&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;The project is called &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt;. It's MIT-licensed, one &lt;code&gt;npx&lt;/code&gt; command to install, and works with Claude Code, Claude Desktop, Cursor, Windsurf, and VS Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx safari-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;80 tools covering the full MCP surface — navigation, clicks, forms, screenshots, network mocking, cookies, accessibility snapshots, performance metrics. The README covers setup for each MCP client.&lt;/p&gt;

&lt;p&gt;If you've been feeling the Chromium tax on Apple Silicon, maybe give this a try. And if it works for you, a star on GitHub helps other macOS developers find it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written after a few months of running Safari MCP as my primary browser automation tool on an M3 MacBook Air. Your mileage will vary — I'd love to hear what breaks for you.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; mcp, claude, macos, webautomation, webdev&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>claude</category>
      <category>macos</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Tried to Auto-Launch My MCP Server Using My MCP Server. It Found Its Own Bug.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Tue, 14 Apr 2026 20:04:02 +0000</pubDate>
      <link>https://forem.com/achiya-automation/i-tried-to-auto-launch-my-mcp-server-using-my-mcp-server-it-found-its-own-bug-494n</link>
      <guid>https://forem.com/achiya-automation/i-tried-to-auto-launch-my-mcp-server-using-my-mcp-server-it-found-its-own-bug-494n</guid>
      <description>&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;p&gt;I built &lt;strong&gt;safari-mcp&lt;/strong&gt;, an MCP server that lets AI agents drive Safari natively on macOS. This week I shipped a discoverability push for it: post the launch announcement to Hacker News, X, LinkedIn, and Reddit. Naturally, I tried to automate the campaign &lt;strong&gt;using safari-mcp itself&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It worked for HN. It worked for X. Then LinkedIn started running clicks on a completely different tab — Catchpoint Internet Performance Monitoring, which I'd never visited. Three windows, a URL prefix match, and a 500 ms cache TTL conspired to teach me a lesson about tab identity.&lt;/p&gt;

&lt;p&gt;Here's the detective story, the root cause, and the fix that ships in &lt;strong&gt;v2.8.3&lt;/strong&gt; today.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup: Eating My Own Dog Food
&lt;/h2&gt;

&lt;p&gt;I had four launch targets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Show HN&lt;/strong&gt; — submit the link, post a first comment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X (Twitter)&lt;/strong&gt; — a single thread that quotes the article&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn&lt;/strong&gt; — a Hebrew-English bilingual long-form post&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reddit r/ClaudeAI&lt;/strong&gt; — a tool-launch-with-context post&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd just shipped a &lt;a href="https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome" rel="noopener noreferrer"&gt;HackerNoon technical deep-dive&lt;/a&gt; about how I built browser automation for a browser that has no Chrome DevTools Protocol. The launch was the natural follow-on. And of course I was going to drive it through safari-mcp — what's the point of building a Safari automation tool if you don't use it for your own launch?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Eat your own dog food at launch — bugs surface fast." — me, after this incident.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Round 1: HN and X Worked Beautifully
&lt;/h2&gt;

&lt;p&gt;The HN submission flow was textbook. Open &lt;code&gt;news.ycombinator.com/submit&lt;/code&gt;, fill the title and URL inputs, call &lt;code&gt;form.submit()&lt;/code&gt; via injected JS, follow the redirect, find the new item ID via &lt;code&gt;submitted?id=&amp;lt;user&amp;gt;&lt;/code&gt;. About 8 seconds end-to-end.&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;// Verify the form is real, not some other tab&lt;/span&gt;
&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;hasTitleInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[name="title"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;hasUrlInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[name="url"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// → {"url":"https://news.ycombinator.com/submit","hasTitleInput":true,"hasUrlInput":true}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filled both inputs. Called &lt;code&gt;form.submit()&lt;/code&gt;. Got redirected to &lt;code&gt;/newest&lt;/code&gt;. Walked back to &lt;code&gt;/submitted?id=Achiyacohen&lt;/code&gt; and confirmed the new post sat at #1 with 1 point. &lt;strong&gt;Live.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;X was even smoother. The compose textbox in &lt;code&gt;x.com/home&lt;/code&gt; is a contenteditable with &lt;code&gt;aria-label="Post text"&lt;/code&gt;. I filled it with the thread text, found the &lt;code&gt;button[data-testid="tweetButtonInline"]&lt;/code&gt;, dispatched a React-aware pointer event sequence (mousedown → mouseup → click), and watched the textbox empty itself. Verified by reading the user's profile timeline 30 seconds later: the tweet was there, with my exact text and a fresh &lt;code&gt;status/2044134672683110740&lt;/code&gt; URL. &lt;strong&gt;Live.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two for two. I was feeling good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: Then LinkedIn Got Weird
&lt;/h2&gt;

&lt;p&gt;LinkedIn's "Start a post" button (in Hebrew: "כתבו פוסט") is a &lt;code&gt;div&lt;/code&gt; with class names like &lt;code&gt;_73dfa4c8 ed6e5932 _1d1c97a4&lt;/code&gt;. I found it, dispatched the same React-aware click sequence, and waited for the compose modal to appear.&lt;/p&gt;

&lt;p&gt;It didn't.&lt;/p&gt;

&lt;p&gt;I called &lt;code&gt;safari_evaluate&lt;/code&gt; to check whether &lt;code&gt;[contenteditable="true"]&lt;/code&gt; had appeared anywhere on the page. The result came back &lt;strong&gt;empty&lt;/strong&gt; — zero contenteditable elements. That was strange. Even the LinkedIn feed itself has search inputs and other interactive elements. So I asked the page for its URL and title to make sure I was in the right place.&lt;/p&gt;

&lt;p&gt;The response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API Monitoring | Catchpoint Internet Performance Monitoring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.catchpoint.com/application-experience/api-monitoring?utm_campaign=Hackernoon-TOFU-billboard"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Catchpoint. &lt;strong&gt;I'd never visited Catchpoint.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Suspicion: Tab Tracking
&lt;/h2&gt;

&lt;p&gt;The first hypothesis was that safari-mcp's tab tracking had drifted. The MCP keeps a cached &lt;code&gt;_activeTabIndex&lt;/code&gt; in memory and uses it for all subsequent operations on a tab it opened. The cache has a TTL of 500 ms, after which &lt;code&gt;resolveActiveTab&lt;/code&gt; re-verifies by URL prefix matching.&lt;/p&gt;

&lt;p&gt;I called &lt;code&gt;safari_list_tabs&lt;/code&gt; and got 12 tabs in the profile window — but with the LinkedIn tab right where I expected it. So the cache and the actual tab layout agreed: tab 12 was LinkedIn.&lt;/p&gt;

&lt;p&gt;Then why was &lt;code&gt;safari_evaluate&lt;/code&gt; returning Catchpoint?&lt;/p&gt;

&lt;h2&gt;
  
  
  Detective Work: There Are Three Windows
&lt;/h2&gt;

&lt;p&gt;I dropped down to raw AppleScript to bypass the MCP layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight applescript"&gt;&lt;code&gt;&lt;span class="k"&gt;tell&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;application&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Safari"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;wCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;windows&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Total windows: "&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;wCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;linefeed&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;repeat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;wCount&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Window "&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;": "&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;tabs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;" tabs"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;linefeed&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"  name: "&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;linefeed&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"  tab1: "&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;tab&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;linefeed&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;repeat&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nb"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;tell&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total windows: 3
Window 1: 2 tabs
  name: אישי — Documenso
  tab1: https://mail.google.com/mail/u/0/#starred/...
Window 2: 12 tabs
  name: אוטומציות — API Monitoring | Catchpoint Internet Performance Monitoring
  tab1: https://hackernoon.com/login?redirect=app
Window 3: 3 tabs
  name: אישי — תוכנה קלה לשליחה למחשב מרחוק - Claude
  tab1: https://claude.ai/recents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three windows. Two profiles ("אישי" / Personal and "אוטומציות" / Automation). Safari MCP was correctly targeting &lt;strong&gt;Window 2&lt;/strong&gt; ("אוטומציות"), where my LinkedIn tab actually lived as tab 12. So far so good.&lt;/p&gt;

&lt;p&gt;The Catchpoint URL? It was tab 5 of Window 2 — a tab the user (me) had clicked open earlier from a HackerNoon ad without thinking. It was sitting there idle. And somehow &lt;code&gt;safari_evaluate&lt;/code&gt; was hitting it instead of tab 12.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Bug: Resolve Cache + URL Prefix
&lt;/h2&gt;

&lt;p&gt;I traced through &lt;code&gt;resolveActiveTab&lt;/code&gt; line by 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveActiveTab&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;_activeTabURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_activeTabIndex&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;safeUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_activeTabURL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&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;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_activeTabURL&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;osascriptFast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    tell application "Safari"
      set w to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;getTargetWindowRef&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;
      set tabCount to count of tabs of w

      // Strategy 1: verify cached index still matches URL
      try
        if tabCount &amp;gt;= &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; then
          if URL of tab &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 w starts with "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safeUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" then
            return &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;
          end if
        end try
      end try

      // Strategy 2: search all tabs by URL prefix
      repeat with i from tabCount to 1 by -1
        if URL of tab i of w starts with "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safeUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" then return i
      end repeat

      // Strategy 3: search by domain (returns negative — partial match)
      repeat with i from tabCount to 1 by -1
        if URL of tab i of w contains "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" then return -(i)
      end repeat

      return "0:" &amp;amp; tabCount
    end tell
  `&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&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 bug was right there in the strategies. When I navigated LinkedIn to &lt;code&gt;https://www.linkedin.com/feed/&lt;/code&gt;, that became &lt;code&gt;_activeTabURL&lt;/code&gt;. Then LinkedIn's React router silently rewrote the URL to &lt;code&gt;https://www.linkedin.com/feed/?shareActive=true&lt;/code&gt; because of the query parameter I'd passed. Strategy 1 — the fast path — failed because &lt;code&gt;URL of tab 12 starts with "https://www.linkedin.com/feed/"&lt;/code&gt;... wait, that should still match. The new URL starts with the old prefix.&lt;/p&gt;

&lt;p&gt;So why did it fail?&lt;/p&gt;

&lt;p&gt;The actual cause was even more subtle: a &lt;strong&gt;different&lt;/strong&gt; Safari instance, in a &lt;strong&gt;different&lt;/strong&gt; profile window, had completed an HTTP redirect that rewrote the URL to a &lt;em&gt;shorter&lt;/em&gt; form. AppleScript's &lt;code&gt;URL of tab&lt;/code&gt; was returning the post-redirect URL, which &lt;strong&gt;did not start with&lt;/strong&gt; my saved &lt;code&gt;_activeTabURL&lt;/code&gt; because &lt;code&gt;_activeTabURL&lt;/code&gt; had query parameters that the post-redirect URL didn't.&lt;/p&gt;

&lt;p&gt;Strategy 1 fell through. Strategy 2 (full URL search across all tabs) also fell through for the same reason. Strategy 3 (domain search) found... a tab in the wrong profile window? No — it found Catchpoint. &lt;strong&gt;Why?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because of how I'd extracted the domain:&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;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_activeTabURL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&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="s1"&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="c1"&gt;// "www.linkedin.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the AppleScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight applescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;tab&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ow"&gt;contains&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${domain}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;contains&lt;/code&gt; is a substring match. &lt;code&gt;Catchpoint&lt;/code&gt;'s ad URL was &lt;code&gt;https://www.catchpoint.com/.../?utm_campaign=Hackernoon-TOFU-billboard&amp;amp;utm_source=hackernoon&amp;amp;utm_medium=paidsocial&lt;/code&gt;. Did it contain &lt;code&gt;www.linkedin.com&lt;/code&gt;? No.&lt;/p&gt;

&lt;p&gt;Wait, then how did it match?&lt;/p&gt;

&lt;p&gt;After two more hours of tracing, I found the actual cause. The MCP server runs as a singleton, but Claude Code occasionally spawns a second instance for ~40 ms during connection negotiation. That second instance had its own &lt;code&gt;_activeTabIndex&lt;/code&gt; state, and &lt;strong&gt;it had set the index to point at Catchpoint&lt;/strong&gt; because it saw Catchpoint as the active tab when it briefly took over. When the original instance came back, it read the wrong index from a stale cache check that hadn't yet been invalidated by the singleton kill code.&lt;/p&gt;

&lt;p&gt;The 500 ms cache window was just long enough for that race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: window.__mcpTabMarker
&lt;/h2&gt;

&lt;p&gt;URL prefix matching is fragile. Domain matching is fragile. Cached indices are fragile. What's not fragile?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A unique identifier injected into the page's JavaScript context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The new fix: every &lt;code&gt;safari_new_tab&lt;/code&gt; writes a unique marker into &lt;code&gt;window.__mcpTabMarker&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`MCP_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SESSION_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&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="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;osascriptFast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`tell application "Safari" to do JavaScript "window.__mcpTabMarker='&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tabMarker&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'" in tab &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="nf"&gt;getTargetWindowRef&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;_activeTabMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabMarker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker survives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same-tab navigation&lt;/strong&gt; — &lt;code&gt;window.__mcpTabMarker&lt;/code&gt; lives in the JS realm, which persists across &lt;code&gt;location.href = ...&lt;/code&gt; if the new URL is same-origin. For cross-origin navigations it gets wiped, which is fine because that's a deliberate context boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash changes&lt;/strong&gt; — &lt;code&gt;location.hash = "#x"&lt;/code&gt; doesn't reload the JS context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pushState&lt;/code&gt; and &lt;code&gt;replaceState&lt;/code&gt;&lt;/strong&gt; — single-page-app routers don't reset the realm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query string mutations&lt;/strong&gt; — same as above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirects within the same origin&lt;/strong&gt; — still in the same realm.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;resolveActiveTab&lt;/code&gt; now tries the marker &lt;strong&gt;first&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveActiveTab&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Strategy 1: window.__mcpTabMarker (bulletproof)&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;_activeTabMarker&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checkScript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`(function(){return window.__mcpTabMarker==='&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safeMarker&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'?'1':'0'})()`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Check cached index first (fast path)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matchAtCached&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;osascriptFast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`tell application "Safari" to do JavaScript "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;checkScript&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" in tab &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="nf"&gt;getTargetWindowRef&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;matchAtCached&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_activeTabIndex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Cached index doesn't match — scan all tabs in profile window&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;osascriptFast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`tell application "Safari" to return count of tabs of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;getTargetWindowRef&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;for &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;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="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;m&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;osascriptFast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`tell application "Safari" to do JavaScript "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;checkScript&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" in tab &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;getTargetWindowRef&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&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;_activeTabIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&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="c1"&gt;// Strategy 2: URL prefix (fallback for tabs created before the marker was set)&lt;/span&gt;
  &lt;span class="c1"&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 marker check costs about 5 ms per tab via the persistent &lt;code&gt;osascriptFast&lt;/code&gt; daemon. On a tab list of 12 tabs, the worst case is 60 ms — slower than the previous "check cached index" path, but &lt;strong&gt;correct&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I also dropped the resolve cache from 500 ms to 100 ms. The check is cheap enough that the tighter cache buys us correctness without measurable latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bypass Tool I Built While Debugging
&lt;/h2&gt;

&lt;p&gt;While I was tracing the bug, I needed a way to test changes against Safari without restarting the MCP server (which would require restarting the Claude Code session). So I wrote a &lt;strong&gt;Python wrapper&lt;/strong&gt; that calls &lt;code&gt;osascript&lt;/code&gt; directly, with one job: find a tab by URL prefix in a specific window, then run JS in that exact tab.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_js&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url_prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;js_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;js_clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strip_line_comments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;js_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;js_escaped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;js_clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\\\&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subprocess&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="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;osascript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;
tell application &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Safari&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
  set tCount to count of tabs of window &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
  set foundIdx to 0
  repeat with i from 1 to tCount
    if URL of tab i of window &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; starts with &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url_prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; then
      set foundIdx to i
      exit repeat
    end if
  end repeat
  if foundIdx = 0 then return &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ERROR_NO_TAB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
  set jsOut to do JavaScript &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;js_escaped&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; in tab foundIdx of window &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
  return &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tab:w&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &amp;amp; foundIdx &amp;amp; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &amp;amp; jsOut
end tell
&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bypassed every layer of the MCP and gave me direct, predictable access to whichever tab I wanted in whichever window I wanted. Three rules I learned writing it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AppleScript's &lt;code&gt;result&lt;/code&gt; is a reserved word.&lt;/strong&gt; Don't name your variable &lt;code&gt;result&lt;/code&gt;. Use &lt;code&gt;jsOut&lt;/code&gt; or &lt;code&gt;output&lt;/code&gt; or anything else. The error message you get is "המשתנה result אינו מוגדר" if your system locale is Hebrew, which is unhelpful unless you happen to know that &lt;code&gt;result&lt;/code&gt; is taken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;do JavaScript&lt;/code&gt; returns immediately for any expression that's not a synchronously-resolved value.&lt;/strong&gt; Promises return undefined. Async functions return their &lt;code&gt;[[PromiseState]]&lt;/code&gt; representation, which AppleScript silently coerces to "missing value", which then triggers "המשתנה X אינו מוגדר" downstream. Workaround: write the result to &lt;code&gt;window.__myResult&lt;/code&gt; from a &lt;code&gt;.then()&lt;/code&gt; callback, then poll for it with a second &lt;code&gt;do JavaScript&lt;/code&gt; call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hebrew text in shell variables breaks AppleScript.&lt;/strong&gt; When you &lt;code&gt;bash -c "osascript -e '...$VAR...'"&lt;/code&gt;, the UTF-8 round-trip through shell substitution corrupts Hebrew bytes. The fix is to call &lt;code&gt;osascript -&lt;/code&gt; with the script on stdin, in Python or Ruby or any language that handles UTF-8 natively.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How LinkedIn Was Actually Posted
&lt;/h2&gt;

&lt;p&gt;After all that, I still couldn't get LinkedIn's compose modal to open via clicks, even with the bypass tool. LinkedIn's React event handlers check &lt;code&gt;event.isTrusted&lt;/code&gt;, which is &lt;code&gt;false&lt;/code&gt; for any event dispatched by user JavaScript. Synthetic clicks just get dropped on the floor.&lt;/p&gt;

&lt;p&gt;So I gave up on the modal entirely and used &lt;strong&gt;LinkedIn's own voyager API&lt;/strong&gt; directly:&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;var&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/JSESSIONID="&lt;/span&gt;&lt;span class="se"&gt;?([^&lt;/span&gt;&lt;span class="sr"&gt;";&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;csrf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.linkedin.com/voyager/api/contentcreation/normShares&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;csrf-token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;csrf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json; charset=UTF-8&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;accept&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/vnd.linkedin.normalized+json+2.1&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;x-restli-protocol-version&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;2.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;visibleToConnectionsOnly&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;commentaryV2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;postBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FEED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;allowedCommentersScope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ALL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;postState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUBLISHED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;media&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__mcpLinkedinResult&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;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&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;500&lt;/span&gt;&lt;span class="p"&gt;)});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;csrf-token&lt;/code&gt; header is just the value of the &lt;code&gt;JSESSIONID&lt;/code&gt; cookie that LinkedIn sets during login. Once you're authenticated, the API accepts your request and returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;urn&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;urn:li:share:7449905229468274688&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;toastCtaText&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;צפייה בפוסט&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;mainToastText&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;פרסום הפוסט הצליח.&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"פרסום הפוסט הצליח"&lt;/strong&gt; — "Post published successfully". The bypass worked. LinkedIn was live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Reddit Taught Me
&lt;/h2&gt;

&lt;p&gt;Reddit was my one failure. The user account in window 1 (Personal profile) was logged in. The form on &lt;code&gt;old.reddit.com/r/ClaudeAI/submit&lt;/code&gt; filled correctly. The CSRF token (&lt;code&gt;uh&lt;/code&gt; field) was present. I built a &lt;code&gt;FormData&lt;/code&gt; POST to &lt;code&gt;/api/submit&lt;/code&gt;, included all the required fields, and fired it.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"json"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s2"&gt;"BAD_CAPTCHA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"That was a tricky one. Why don't you try that again."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"captcha"&lt;/span&gt;&lt;span class="p"&gt;]]}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reddit's &lt;code&gt;/api/submit&lt;/code&gt; endpoint requires a solved reCAPTCHA token, even for fully-authenticated users. There's no API path that bypasses this. There's no honor-system "I'm a real human" header. The only ways through are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pay a CAPTCHA-solving service ($1-2 per 1000 captchas, with all the ethical and TOS implications you'd expect)&lt;/li&gt;
&lt;li&gt;Have a human solve it&lt;/li&gt;
&lt;li&gt;Don't post to Reddit&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I picked option 3. I respect the captcha as a clearly-stated boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Eat your own dog food at launch.&lt;/strong&gt; I'd been running safari-mcp for daily browser automation tasks for weeks and never hit this bug. It took the specific combination of "rapid sequence of operations across multiple Safari windows with same-domain tabs and React-driven URL rewrites" to surface it. A launch campaign happens to involve exactly that combination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-window/multi-profile is a forgotten edge case in browser automation.&lt;/strong&gt; Most automation tools assume one window or have a strict "first window" convention. Safari's profile feature (introduced in macOS Sonoma) makes multi-window the default for power users. If you write a Safari automation tool, test with three profile windows open from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL matching is fragile; identity markers in the JS context are bulletproof.&lt;/strong&gt; This is the takeaway I wish someone had told me three weeks ago. Don't track tabs by URL or title or any other property the page can mutate. Inject a marker into the page's JS realm and check for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache TTL is a knife edge.&lt;/strong&gt; 500 ms felt safe. It wasn't. 100 ms with a cheap revalidation check is the sweet spot for this workload. Your sweet spot may differ — measure it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When debugging, build a bypass tool.&lt;/strong&gt; Don't fight the bug from inside the affected layer. Route around it. The 60 lines of Python I wrote in the middle of this incident saved me hours of MCP restart cycles, and I get to keep them as a permanent low-level escape hatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some platforms genuinely don't want automation.&lt;/strong&gt; That's their right. Respect it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;safari-mcp v2.8.3&lt;/strong&gt; ships the marker fix today. &lt;a href="https://www.npmjs.com/package/safari-mcp" rel="noopener noreferrer"&gt;npm&lt;/a&gt;, &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://registry.modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP Registry&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The launch campaign worked: HN post live, X tweet live, LinkedIn post live (via the API bypass), Reddit deferred.&lt;/li&gt;
&lt;li&gt;The bug-find-fix loop took about 90 minutes. The article you're reading took longer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build MCP servers, automation tools, or anything that touches a multi-window browser, I'd love to hear how you've solved tab identity. Drop a comment or open an issue on &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;achiya-automation/safari-mcp&lt;/a&gt;. I learn from every reply.&lt;/p&gt;

&lt;p&gt;And if you're considering using your own tool to launch your own tool — do it. The bugs you'll find are the bugs your users would have hit first.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>mcp</category>
      <category>browserautomation</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I've Deployed 50+ WhatsApp Bots — Here's How the Spam Detection Algorithm Actually Works in 2026</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 12 Apr 2026 19:20:59 +0000</pubDate>
      <link>https://forem.com/achiya-automation/ive-deployed-50-whatsapp-bots-heres-how-the-spam-detection-algorithm-actually-works-in-2026-69a</link>
      <guid>https://forem.com/achiya-automation/ive-deployed-50-whatsapp-bots-heres-how-the-spam-detection-algorithm-actually-works-in-2026-69a</guid>
      <description>&lt;p&gt;After deploying 50+ WhatsApp bots for businesses, I've learned the hard way how WhatsApp's spam detection works. Not from documentation — from watching accounts get restricted and figuring out why.&lt;/p&gt;

&lt;p&gt;Here's the real picture in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-Layer Detection System
&lt;/h2&gt;

&lt;p&gt;WhatsApp doesn't use a single algorithm. It's a pipeline:&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Registration Fingerprinting
&lt;/h3&gt;

&lt;p&gt;Before you send a message, WhatsApp analyzes your registration signal — device metadata, IP clusters, phone number patterns, registration velocity. Bulk-registered numbers on VPS servers get flagged immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Behavioral Analysis (Where Bots Get Caught)
&lt;/h3&gt;

&lt;p&gt;This is the critical layer. WhatsApp monitors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Send velocity&lt;/strong&gt; — messages per minute/hour/day&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply-to-send ratio&lt;/strong&gt; — if you send 100 messages and get 5 replies, that's a 5% ratio = spam signal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message timing patterns&lt;/strong&gt; — bots send at precise intervals; humans don't&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact interaction history&lt;/strong&gt; — messages to contacts who never messaged you weigh more heavily&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From our deployments, here are the thresholds I've observed:&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;Safe&lt;/th&gt;
&lt;th&gt;Warning&lt;/th&gt;
&lt;th&gt;Danger&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Messages/hour&lt;/td&gt;
&lt;td&gt;&amp;lt; 30&lt;/td&gt;
&lt;td&gt;30-60&lt;/td&gt;
&lt;td&gt;&amp;gt; 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reply rate&lt;/td&gt;
&lt;td&gt;&amp;gt; 30%&lt;/td&gt;
&lt;td&gt;15-30%&lt;/td&gt;
&lt;td&gt;&amp;lt; 15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New contacts/day&lt;/td&gt;
&lt;td&gt;&amp;lt; 20&lt;/td&gt;
&lt;td&gt;20-50&lt;/td&gt;
&lt;td&gt;&amp;gt; 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identical messages&lt;/td&gt;
&lt;td&gt;&amp;lt; 5/hr&lt;/td&gt;
&lt;td&gt;5-15/hr&lt;/td&gt;
&lt;td&gt;&amp;gt; 15/hr&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Based on observations across 50+ deployments, not official Meta docs.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: User Reports
&lt;/h3&gt;

&lt;p&gt;Every block or spam report adds negative signal. Block rate &amp;gt; 2% = quality rating drops to "Low". Multiple reports in 24 hours = temporary restriction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4: Content Pattern Matching
&lt;/h3&gt;

&lt;p&gt;WhatsApp analyzes message metadata (length, media, links), forward patterns, and template similarity — without reading encrypted content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Big 2026 Change: Unanswered Message Counter
&lt;/h2&gt;

&lt;p&gt;The most significant change this year: WhatsApp now tracks &lt;strong&gt;messages sent that received no reply within 48 hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This counter is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cumulative&lt;/strong&gt; — counts across all conversations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-bounded&lt;/strong&gt; — rolling 30-day window&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Universal&lt;/strong&gt; — affects both official and unofficial API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We saw this hit a dental clinic client running appointment reminders via the official API. Fully compliant, template-approved, opt-in collected. But 40% of patients confirmed by showing up, not replying to WhatsApp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: We added "Reply 1 to confirm, 2 to reschedule" to every reminder. Reply rate jumped from 60% to 89%. Quality rating recovered in two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official vs Unofficial API: Risk Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Official API&lt;/th&gt;
&lt;th&gt;Unofficial (WAHA/Baileys)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Registration ban&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behavioral ban&lt;/td&gt;
&lt;td&gt;Low (templates enforce limits)&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User report ban&lt;/td&gt;
&lt;td&gt;Low (warnings first)&lt;/td&gt;
&lt;td&gt;High (direct ban)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery&lt;/td&gt;
&lt;td&gt;Appeal through Meta&lt;/td&gt;
&lt;td&gt;Permanent, no appeal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;BSP $50-100/mo + per-msg&lt;/td&gt;
&lt;td&gt;Server $5-20/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Key insight&lt;/strong&gt;: Unofficial API bots that only &lt;strong&gt;respond&lt;/strong&gt; to incoming messages have &amp;lt;2% ban rate over 12 months. Bots that &lt;strong&gt;proactively message&lt;/strong&gt; new contacts see 15-30% ban rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  7 Rules We Follow for Every Bot
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Official API for proactive messaging&lt;/strong&gt; — templates exist to keep you compliant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit opt-in&lt;/strong&gt; — not buried in ToS. Real: "I want reminders via WhatsApp"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for replies&lt;/strong&gt; — quick-reply buttons, yes/no questions. Reply rate = trust signal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit sending&lt;/strong&gt; — 50-100/batch for marketing, 5-min gaps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor quality rating&lt;/strong&gt; weekly — Meta Business Suite → Phone Numbers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment audience&lt;/strong&gt; — don't message contacts silent for 90+ days&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human escalation&lt;/strong&gt; after 2 failed bot responses — frustrated users report + block&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What If You're Already Restricted?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Official API&lt;/strong&gt;: Pause marketing templates, improve reply rates, wait 7 days for quality re-evaluation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unofficial API&lt;/strong&gt;: Stop proactive messaging immediately. If banned, the number is gone. Migrate to official API.&lt;/p&gt;




&lt;p&gt;The algorithm isn't adversarial toward legitimate businesses. The formula:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Official API + Opt-in + Relevant Messages + Reply-Encouraging Design = Zero Risk&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Full deep-dive with all technical details: &lt;a href="https://achiya-automation.com/en/blog/whatsapp-spam-detection-2026/" rel="noopener noreferrer"&gt;WhatsApp Spam Detection Algorithm 2026&lt;/a&gt;&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>bots</category>
      <category>automation</category>
      <category>security</category>
    </item>
  </channel>
</rss>
