<?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>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>
    <item>
      <title>MCP vs CLI for Browser Automation: I Benchmarked Both and the Results Surprised Me</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sat, 11 Apr 2026 23:10:53 +0000</pubDate>
      <link>https://forem.com/achiya-automation/mcp-vs-cli-for-browser-automation-i-benchmarked-both-and-the-results-surprised-me-4cog</link>
      <guid>https://forem.com/achiya-automation/mcp-vs-cli-for-browser-automation-i-benchmarked-both-and-the-results-surprised-me-4cog</guid>
      <description>&lt;p&gt;Three weeks ago I published &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt; — a macOS-native Safari automation server that speaks the Model Context Protocol. 84 tools, AppleScript + optional extension for speed, keeps Safari logins, zero Chrome overhead. Today it's in the VS Code and Cursor marketplaces.&lt;/p&gt;

&lt;p&gt;Then I saw &lt;a href="https://github.com/HKUDS/CLI-Anything" rel="noopener noreferrer"&gt;HKUDS/CLI-Anything&lt;/a&gt; — a 29k-star project that auto-wraps open-source software as agent-ready CLIs. Their pitch: "Make ALL software agent-native." Their main example is &lt;a href="https://github.com/apireno/DOMShell" rel="noopener noreferrer"&gt;DOMShell&lt;/a&gt; wrapped as &lt;code&gt;cli-anything-browser&lt;/code&gt; — a shell-pipeable interface for Chrome automation.&lt;/p&gt;

&lt;p&gt;I wanted to know: &lt;strong&gt;is wrapping safari-mcp as a CLI actually worth it?&lt;/strong&gt; Or is it pure theater — re-exposing a working MCP server as a strictly worse interface?&lt;/p&gt;

&lt;p&gt;So I built the harness (&lt;a href="https://github.com/HKUDS/CLI-Anything/pull/212" rel="noopener noreferrer"&gt;PR #212&lt;/a&gt;) and benchmarked it live against the direct MCP path. Real Safari, real macOS, measured on 2026-04-10.&lt;/p&gt;

&lt;p&gt;Here's what I found.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;MCP (direct stdio)&lt;/th&gt;
&lt;th&gt;CLI (subprocess per call)&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per-call latency&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;119ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3,023ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MCP, 25×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5-op workflow&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.7s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15.2s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MCP, 5.6×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens per API call (tool defs)&lt;/td&gt;
&lt;td&gt;7,986&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;CLI, 84×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output accuracy&lt;/td&gt;
&lt;td&gt;identical&lt;/td&gt;
&lt;td&gt;identical&lt;/td&gt;
&lt;td&gt;tie&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If your agent speaks MCP&lt;/strong&gt; (Claude Code, Cursor, Cline, Windsurf, Continue, OpenClaw, any MCP-aware client) — &lt;strong&gt;use the MCP directly&lt;/strong&gt;. The CLI is strictly slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you need to drive it from bash, CI, cron, or an agent that doesn't speak MCP&lt;/strong&gt; — use the CLI. The token savings compound; at Claude Opus pricing, a 100-turn session saves ~$12 in tool-definition overhead alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole story. If you only wanted the numbers, you can stop here. If you want the methodology, the edge cases, and the bugs I hit along the way, read on.&lt;/p&gt;

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

&lt;p&gt;The harness (&lt;a href="https://github.com/HKUDS/CLI-Anything/tree/main/safari/agent-harness" rel="noopener noreferrer"&gt;&lt;code&gt;safari/agent-harness/&lt;/code&gt;&lt;/a&gt;) is a schema-driven CLI generator:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Offline Zod parser&lt;/strong&gt; (&lt;code&gt;scripts/extract_tools.py&lt;/code&gt;) reads safari-mcp's source and emits &lt;code&gt;resources/tools.json&lt;/code&gt; — the full schema for all 84 tools. Depth-aware, handles nested &lt;code&gt;z.array(z.object({...})).describe("outer")&lt;/code&gt; correctly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Runtime Click generator&lt;/strong&gt; (&lt;code&gt;safari_cli.py&lt;/code&gt;) loads the registry at import time and builds one Click subcommand per MCP tool. Argument names, types, enum choices, required flags, and descriptions are all pulled from the schema. Zero manual mapping.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Parity test suite&lt;/strong&gt; (&lt;code&gt;test_parity.py&lt;/code&gt;) iterates the registry and verifies every tool is reachable, every param is wired correctly, every enum matches. If the registry and the CLI ever drift, the tests scream.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The CLI surface ends up looking like this:&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;cli-anything-safari tools count
84

&lt;span class="nv"&gt;$ &lt;/span&gt;cli-anything-safari tools describe safari_click
Name:        safari_click
CLI &lt;span class="nb"&gt;command&lt;/span&gt;: tool click
Description: Click element. Use ref &lt;span class="o"&gt;(&lt;/span&gt;from snapshot&lt;span class="o"&gt;)&lt;/span&gt;, selector, text, or x/y...
Parameters:
  &lt;span class="nt"&gt;--ref&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;string, optional&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="nt"&gt;--selector&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;string, optional&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="nt"&gt;--text&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;string, optional&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="nt"&gt;--x&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;number, optional&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="nt"&gt;--y&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;number, optional&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;cli-anything-safari &lt;span class="nt"&gt;--json&lt;/span&gt; tool snapshot
&lt;span class="s2"&gt;"ref=0_0 body&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;ref=0_1 div&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;ref=0_2 navigation &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Sidebar&lt;/span&gt;&lt;span class="se"&gt;\"\n&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same interface as the upstream MCP, just behind &lt;code&gt;click.command(...)&lt;/code&gt; calls.&lt;/p&gt;

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

&lt;p&gt;Both paths hit the &lt;strong&gt;same&lt;/strong&gt; &lt;code&gt;safari-mcp&lt;/code&gt; server in the end. The difference is the connection model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCP direct:  Python → stdio (persistent) → safari-mcp → Safari
CLI:         Python → subprocess → npx → Node → safari-mcp → Safari
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For MCP I used &lt;code&gt;mcp.ClientSession&lt;/code&gt; with a persistent stdio connection, measuring only the &lt;code&gt;call_tool()&lt;/code&gt; round-trip (initialization amortized). For CLI I measured &lt;code&gt;subprocess.run([...])&lt;/code&gt; wall time. Both had one warmup call that I discarded.&lt;/p&gt;

&lt;p&gt;The benchmark script is at &lt;code&gt;/tmp/benchmark_cli_vs_mcp.py&lt;/code&gt; (not committed because it's scratch); the key loop is:&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="c1"&gt;# MCP: persistent session, N calls
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;stdio_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# warmup
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# CLI: spawn per call
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&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="n"&gt;CLI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_short_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;args_list&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;times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Latency — MCP wins by 25×
&lt;/h2&gt;

&lt;p&gt;Ten calls of &lt;code&gt;safari_list_tabs&lt;/code&gt; (warm cache, same Safari state):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  MCP (ms)    CLI (ms)       ratio
  min                113.3      2970.2       26.2×
  median             119.5      3026.1       25.3×
  mean               119.3      3022.7       25.3×
  max                123.7      3097.2       25.0×
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLI calls land at &lt;strong&gt;~3 seconds&lt;/strong&gt; every single time, with almost no variance. That consistency is the giveaway: the bottleneck is not &lt;code&gt;safari_list_tabs&lt;/code&gt; itself — it's the ~2.9 seconds that go into &lt;code&gt;npx&lt;/code&gt; resolution, Node.js startup, &lt;code&gt;safari-mcp&lt;/code&gt; initialization, and MCP handshake for every fresh subprocess.&lt;/p&gt;

&lt;p&gt;MCP amortizes all of that across a single persistent session. Once the session is up, each additional tool call is &lt;strong&gt;just&lt;/strong&gt; the ~100ms AppleScript operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For interactive reactive workflows — agents that take each result and decide the next step — MCP is the obvious choice.&lt;/strong&gt; Every round-trip matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow — MCP still wins on reactive sequences
&lt;/h2&gt;

&lt;p&gt;I ran a 5-op workflow (&lt;code&gt;snapshot → read_page → list_tabs → snapshot → read_page&lt;/code&gt;) three ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  MCP (persistent, 5 ops)           2,714 ms
  CLI (5 sequential spawns)        15,285 ms
  CLI (1 shell pipeline, 5 ops)    15,153 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shell pipelining — &lt;code&gt;cli-anything-safari tool X &amp;amp;&amp;amp; cli-anything-safari tool Y&lt;/code&gt; — does &lt;strong&gt;not&lt;/strong&gt; help. Every &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; still spawns a fresh &lt;code&gt;npx&lt;/code&gt; subprocess. The overhead per step is unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The only way to amortize the cost is to drive the Python API directly&lt;/strong&gt; (&lt;code&gt;from cli_anything.safari.utils.safari_backend import call&lt;/code&gt;). If you do that, you're back to roughly MCP-class numbers because you're just using the MCP Python SDK under a different name.&lt;/p&gt;

&lt;p&gt;The CLI's per-call cost is structural. You cannot pipeline your way out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokens — CLI wins by 84×
&lt;/h2&gt;

&lt;p&gt;This is where the picture inverts. When an LLM uses MCP tools, &lt;strong&gt;every API call includes the full tool definitions in the request&lt;/strong&gt;. For safari-mcp that's 84 tools × ~95 tokens each = &lt;strong&gt;~7,986 tokens&lt;/strong&gt; on every turn.&lt;/p&gt;

&lt;p&gt;I measured this with the real tools.json and the &lt;code&gt;cl100k_base&lt;/code&gt; tokenizer (&lt;code&gt;tiktoken&lt;/code&gt;):&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cl100k_base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mcp_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&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;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&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;inputSchema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inputSchema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;
&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mcp_response&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="c1"&gt;# 7,986 tokens for 84 tools
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI path sends ~95 tokens — just the &lt;code&gt;bash&lt;/code&gt; tool definition. The agent learns the CLI surface by running &lt;code&gt;cli-anything-safari tools list --json&lt;/code&gt; once (5,236 tokens, one-time) and the info sits in the conversation context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At Claude Opus pricing ($15/MTok input, no caching):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Session length&lt;/th&gt;
&lt;th&gt;MCP overhead&lt;/th&gt;
&lt;th&gt;CLI overhead&lt;/th&gt;
&lt;th&gt;Savings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10 turns&lt;/td&gt;
&lt;td&gt;$1.20&lt;/td&gt;
&lt;td&gt;$0.09&lt;/td&gt;
&lt;td&gt;$1.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 turns&lt;/td&gt;
&lt;td&gt;$11.98&lt;/td&gt;
&lt;td&gt;$0.22&lt;/td&gt;
&lt;td&gt;$11.76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000 turns&lt;/td&gt;
&lt;td&gt;$119.79&lt;/td&gt;
&lt;td&gt;$1.60&lt;/td&gt;
&lt;td&gt;$118.19&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prompt caching narrows this considerably — Anthropic lets you cache tool definitions at $3.75/MTok on first write and $1.50/MTok on reads, roughly a 10× discount. With caching the MCP cost drops from ~$12 to ~$1.50 per 100-turn session. Still more expensive than CLI, but the gap is smaller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The takeaway&lt;/strong&gt;: for &lt;strong&gt;short, reactive&lt;/strong&gt; sessions (where you care about UX and per-call latency), MCP wins hands-down. For &lt;strong&gt;long, scripted&lt;/strong&gt; sessions at scale (where tool-definition overhead becomes a real line item), the CLI's token efficiency is genuine and measurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accuracy — tie
&lt;/h2&gt;

&lt;p&gt;Both paths call the same &lt;code&gt;safari-mcp&lt;/code&gt; server. Both go through the same AppleScript → Safari chain. The CLI is a thin subprocess wrapper that serializes the MCP &lt;code&gt;CallToolResult.content&lt;/code&gt; into stdout via a small &lt;code&gt;_unwrap()&lt;/code&gt; helper:&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;_unwrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&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="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# ImageContent: returned by screenshot tools
&lt;/span&gt;        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;image&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;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mimeType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mimeType&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;application/octet-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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;parts&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Byte-identical output verified live: the Unicode tab titles returned by &lt;code&gt;cli-anything-safari --json tool list-tabs&lt;/code&gt; match the direct MCP output character-for-character, including right-to-left Hebrew.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs that took 5 review rounds to find
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend the first draft was clean. The schema-driven generator had real bugs that five passes of review (two my own, three by an adversarial code-reviewer agent) surfaced one by one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Nested &lt;code&gt;.describe()&lt;/code&gt; leaked&lt;/strong&gt;. For &lt;code&gt;z.array(z.object({selector: z.string().describe("CSS selector")})).describe("Array of {selector, value} pairs")&lt;/code&gt;, the naive regex picked the inner &lt;code&gt;"CSS selector"&lt;/code&gt; as the outer field's description. Four tools had wrong help text. Fixed by walking modifier chains at depth 0 only.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Nested &lt;code&gt;.optional()&lt;/code&gt; leaked&lt;/strong&gt;. Same root cause, different effect — &lt;code&gt;safari_mock_route.response&lt;/code&gt; and &lt;code&gt;safari_run_script.steps&lt;/code&gt; were marked optional because an inner field had &lt;code&gt;.optional()&lt;/code&gt;. The actual MCP schema marks them required. This one silently produced wrong JSON schemas; the fix was depth-aware modifier detection everywhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;_unwrap()&lt;/code&gt; silently dropped screenshot output&lt;/strong&gt;. It only handled &lt;code&gt;TextContent&lt;/code&gt;, not &lt;code&gt;ImageContent&lt;/code&gt;. For two tools (&lt;code&gt;safari_screenshot&lt;/code&gt;, &lt;code&gt;safari_screenshot_element&lt;/code&gt;), the CLI returned &lt;code&gt;null&lt;/code&gt; with exit code 0 instead of the base64 JPEG. Caught on the fourth review round after I'd already declared "100% compliance."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;safari_evaluate&lt;/code&gt; parameter name is &lt;code&gt;script&lt;/code&gt;, not &lt;code&gt;code&lt;/code&gt;&lt;/strong&gt;. The tool description said "JavaScript code to execute" so I wrote every documentation example as &lt;code&gt;--code "document.title"&lt;/code&gt;. The parser auto-generated the CLI correctly from the schema (&lt;code&gt;--script&lt;/code&gt;), so the CLI worked, but every doc example in SKILL.md, README.md, and my test file was wrong. Caught on the fourth review round when the reviewer cross-referenced docs against the schema.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;doubleClick: z.boolean().default(false)&lt;/code&gt; serialized the default as the string &lt;code&gt;"false"&lt;/code&gt;&lt;/strong&gt;. Not broken at runtime (Click ignores it) but wrong in the bundled JSON schema. Fixed by adding a &lt;code&gt;_coerce_default()&lt;/code&gt; step that parses JS barewords into their Python equivalents.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every bug except #5 had a corresponding &lt;strong&gt;regression test&lt;/strong&gt; added to &lt;code&gt;test_parity.py&lt;/code&gt; after the fix. The file is now 24 tests, including explicit assertions like:&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;test_evaluate_param_is_script_not_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Regression: prior versions used &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; by mistake.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;safari_evaluate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;script&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson I kept re-learning: &lt;strong&gt;if you wrote the code, you can't review it yourself&lt;/strong&gt;. You read your own docs through your own mental model of what the code does. You need an adversary — either a human with fresh eyes or an agent with no context — to catch the bugs your mental model papers over.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use which
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Decision tree (read left to right):

Does your agent speak MCP natively?
├── Yes → Use safari-mcp directly. 25× faster, better UX.
└── No
    ├── Is this a one-off / interactive script?
    │   └── Yes → Use cli-anything-safari. jq-pipeable.
    ├── Long-running automation, cost matters?
    │   └── Yes → cli-anything-safari. Token savings compound.
    ├── CI / cron / non-interactive automation?
    │   └── Yes → cli-anything-safari. Subprocess-friendly.
    └── Everything else → try MCP first, fall back to CLI if needed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;strong&gt;Claude Code, Cursor, Cline, Windsurf, Continue, OpenClaw, VS Code MCP&lt;/strong&gt; — all MCP-native. Use &lt;code&gt;safari-mcp&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; safari-mcp
&lt;span class="c"&gt;# Then add to your MCP client config and restart it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;strong&gt;Codex CLI, GitHub Copilot CLI, older agent frameworks, shell scripts, cron jobs&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# After the CLI-Anything PR merges&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;cli-anything-safari
cli-anything-safari tools list
cli-anything-safari tool navigate &lt;span class="nt"&gt;--url&lt;/span&gt; https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do differently next time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write the benchmark first.&lt;/strong&gt; I built the CLI, shipped it, and &lt;em&gt;then&lt;/em&gt; benchmarked it. If I'd measured first, I would have avoided ~3 review rounds of "is this even useful?" angst. The answer is nuanced (MCP for latency, CLI for tokens and reach), but I couldn't see that without the numbers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Schema-driven from day one.&lt;/strong&gt; The original plan was to hand-wrap 20 curated tools with a &lt;code&gt;raw&lt;/code&gt; escape hatch for the rest. That would have been ~1,500 lines of code I'd be maintaining forever. The schema-driven approach is ~300 lines and maintains itself.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Spin up an adversarial reviewer earlier.&lt;/strong&gt; I used an independent code-reviewer agent on review rounds 2–4. It caught bugs I'd read past a dozen times. Should have used it on round 1.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token cost is a first-class metric for MCP design.&lt;/strong&gt; I was thinking about MCP vs CLI in pure latency terms. The token-cost-at-scale axis is genuinely the more important one for long agent sessions, and I should have been measuring it from the start.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;safari-mcp repo&lt;/strong&gt;: &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;https://github.com/achiya-automation/safari-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI-Anything PR #212&lt;/strong&gt;: &lt;a href="https://github.com/HKUDS/CLI-Anything/pull/212" rel="noopener noreferrer"&gt;https://github.com/HKUDS/CLI-Anything/pull/212&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct harness path&lt;/strong&gt; (after merge): &lt;a href="https://github.com/HKUDS/CLI-Anything/tree/main/safari/agent-harness" rel="noopener noreferrer"&gt;https://github.com/HKUDS/CLI-Anything/tree/main/safari/agent-harness&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run into edge cases — or have a better benchmark setup I should run — open an issue on the repo or reply here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is part of my &lt;a href="https://dev.to/achiya-automation"&gt;safari-mcp series&lt;/a&gt;. Previous posts: &lt;a href="https://dev.to/achiya-automation/i-built-an-mcp-server-for-safari-because-chrome-was-melting-my-macbook"&gt;Why I built an MCP server for Safari&lt;/a&gt;, &lt;a href="https://dev.to/achiya-automation/i-replaced-chrome-devtools-mcp-with-safari-on-my-mac-heres-what-happened"&gt;Chrome DevTools MCP vs Safari&lt;/a&gt;, &lt;a href="https://dev.to/achiya-automation/7-things-i-learned-building-a-safari-browser-automation-tool-that-chrome-cant-do"&gt;7 things I learned building Safari automation&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>cli</category>
      <category>automation</category>
      <category>ai</category>
    </item>
    <item>
      <title>I just hardened my OSS release pipeline to 11 layers of security — here's the playbook</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sat, 11 Apr 2026 21:58:18 +0000</pubDate>
      <link>https://forem.com/achiya-automation/i-just-hardened-my-oss-release-pipeline-to-11-layers-of-security-heres-the-playbook-4267</link>
      <guid>https://forem.com/achiya-automation/i-just-hardened-my-oss-release-pipeline-to-11-layers-of-security-heres-the-playbook-4267</guid>
      <description>&lt;h1&gt;
  
  
  I just hardened my OSS release pipeline to 11 layers of security — here's the playbook
&lt;/h1&gt;

&lt;p&gt;This weekend I shipped &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.7.9" rel="noopener noreferrer"&gt;safari-mcp v2.7.9&lt;/a&gt;, a minor release on the surface but a complete overhaul of how the project gets published. Along the way I went from "NPM_TOKEN in a workflow secret" to 11 layers of supply-chain defense, all driven by one realistic question: &lt;em&gt;if my GitHub account gets phished tomorrow, how much damage can an attacker do before somebody notices?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you're a solo maintainer of a JavaScript package, the playbook below is for you. Every step is something I actually did today, with links to the commits, and most of them took under 10 minutes each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Package:&lt;/strong&gt; &lt;code&gt;safari-mcp&lt;/code&gt; on npm (~2000 monthly downloads, 27 stars, MIT)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release flow:&lt;/strong&gt; GitHub Actions workflow triggered by release, authenticating with a long-lived &lt;code&gt;NPM_TOKEN&lt;/code&gt; stored as a repo secret&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Problem:&lt;/strong&gt; That token could publish &lt;em&gt;anything&lt;/em&gt; on my npm account until it expired. If my GitHub got compromised, step one for an attacker would be grabbing the token and uploading a malicious &lt;code&gt;safari-mcp@2.7.10&lt;/code&gt; within minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal: make that attack path as hard as possible without turning my release flow into a 20-minute bureaucracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: npm OIDC Trusted Publisher (no more long-lived token)
&lt;/h2&gt;

&lt;p&gt;npm now supports &lt;a href="https://docs.npmjs.com/trusted-publishers" rel="noopener noreferrer"&gt;Trusted Publishers via OIDC&lt;/a&gt;. Instead of storing a token, you configure npm to accept short-lived OIDC tokens issued by GitHub Actions — tokens that are cryptographically bound to a specific repository + workflow + environment and expire within minutes.&lt;/p&gt;

&lt;p&gt;Setting this up on npm takes three fields (org, repo, workflow filename) and one click. After that, the workflow no longer needs &lt;code&gt;NPM_TOKEN&lt;/code&gt;; it just needs:&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;  &lt;span class="c1"&gt;# required for OIDC&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish --provenance --access public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;--provenance&lt;/code&gt; flag adds a &lt;a href="https://slsa.dev/" rel="noopener noreferrer"&gt;SLSA build attestation&lt;/a&gt; to the published package, so anyone downloading it can cryptographically verify that &lt;code&gt;safari-mcp@2.7.9&lt;/code&gt; was built by the specific GitHub Actions run I claim it was.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Upgrade path:&lt;/strong&gt; After configuring Trusted Publisher, &lt;em&gt;delete the old &lt;code&gt;NPM_TOKEN&lt;/code&gt; secret&lt;/em&gt;. Otherwise it's still a liability.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Layer 2: manual-approval environment gate
&lt;/h2&gt;

&lt;p&gt;OIDC stops token theft. It doesn't stop a compromised workflow file from publishing malware. So I added a GitHub &lt;a href="https://docs.github.com/en/actions/concepts/deployments/deployment-environments" rel="noopener noreferrer"&gt;deployment environment&lt;/a&gt; called &lt;code&gt;npm-publish&lt;/code&gt; with &lt;strong&gt;required reviewers&lt;/strong&gt; (just me) and &lt;code&gt;can_admins_bypass: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now every release pauses in GitHub Actions until I explicitly click "Approve" in the UI. I see the exact SHA, the commit message, and the tag before I let the publish proceed. Even if an attacker has the ability to push to &lt;code&gt;main&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; has my repo admin privileges, they still can't skip the approval.&lt;/p&gt;

&lt;p&gt;Important detail: by default, deployment environments allow admins to bypass. I set it to &lt;code&gt;false&lt;/code&gt; because the entire point was to defend against &lt;em&gt;my own&lt;/em&gt; compromised credentials. If I get locked out I can always re-enable it from the Settings page, which itself requires passkey reauth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: custom branch policy (tags + main only)
&lt;/h2&gt;

&lt;p&gt;First time I tried to release, the publish workflow failed with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Tag "v2.7.9" is not allowed to deploy to npm-publish due to environment protection rules.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The default &lt;code&gt;deployment_branch_policy&lt;/code&gt; is "protected branches only" — tags don't count as branches. The fix was switching to &lt;code&gt;custom_branch_policies: true&lt;/code&gt; and adding two rules:&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;branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both push-to-main deployments and version tag deployments work, but only those. Feature branches can't deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: SHA pinning required for all actions
&lt;/h2&gt;

&lt;p&gt;The single biggest OSS supply-chain incident of 2025 was &lt;a href="https://github.com/tj-actions/changed-files/issues/2464" rel="noopener noreferrer"&gt;tj-actions/changed-files&lt;/a&gt; — thousands of secrets leaked because workflows used &lt;code&gt;@v1&lt;/code&gt; tags that the attacker retagged. The fix is simple but painful: pin every action to a full commit SHA instead of a version tag.&lt;/p&gt;

&lt;p&gt;GitHub recently added a repo-level setting to &lt;em&gt;require&lt;/em&gt; this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="nt"&gt;--method&lt;/span&gt; PUT /repos/OWNER/REPO/actions/permissions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--input&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{"enabled": true, "allowed_actions": "all", "sha_pinning_required": true}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After I enabled this, every CI run failed because my workflows used &lt;code&gt;actions/checkout@v4&lt;/code&gt;. I updated them to the equivalent SHA:&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@de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/span&gt; &lt;span class="c1"&gt;# v6.0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment is important — it's how Dependabot auto-updates the SHA while keeping the version readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: branch protection on main (signatures required, no force push)
&lt;/h2&gt;

&lt;p&gt;Branch protection for solo maintainers is usually reductive (it blocks your own pushes), but three rules are pure upside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="nt"&gt;--method&lt;/span&gt; PUT /repos/OWNER/REPO/branches/main/protection &lt;span class="nt"&gt;--input&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{
  "required_status_checks": null,
  "enforce_admins": false,
  "required_pull_request_reviews": null,
  "restrictions": null,
  "allow_force_pushes": false,
  "allow_deletions": false,
  "required_conversation_resolution": true
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allow_force_pushes: false&lt;/code&gt;&lt;/strong&gt; — stops history rewriting, essential with signed commits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allow_deletions: false&lt;/code&gt;&lt;/strong&gt; — stops accidental &lt;code&gt;git push --delete origin main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;required_conversation_resolution: true&lt;/code&gt;&lt;/strong&gt; — every PR comment must be resolved before merge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="nt"&gt;--method&lt;/span&gt; POST /repos/OWNER/REPO/branches/main/protection/required_signatures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every new commit on &lt;code&gt;main&lt;/code&gt; must carry a verified signature. Commits from unsigned laptops get rejected at push time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 6: SSH commit signing without losing your mind
&lt;/h2&gt;

&lt;p&gt;Setting up commit signing always feels like yak-shaving. Here's the short version for macOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate a dedicated signing key (no passphrase, used ONLY for git sign)&lt;/span&gt;
ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/git_signing_ed25519 &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"git-signing"&lt;/span&gt;

&lt;span class="c"&gt;# Point git at it&lt;/span&gt;
git config &lt;span class="nt"&gt;--local&lt;/span&gt; gpg.format ssh
git config &lt;span class="nt"&gt;--local&lt;/span&gt; user.signingkey ~/.ssh/git_signing_ed25519.pub
git config &lt;span class="nt"&gt;--local&lt;/span&gt; commit.gpgsign &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Upload to GitHub as a *signing* key (not auth!)&lt;/span&gt;
gh ssh-key add ~/.ssh/git_signing_ed25519.pub &lt;span class="nt"&gt;--type&lt;/span&gt; signing &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"git signing"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gotcha: if your primary &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt; has a passphrase, &lt;code&gt;git commit -S&lt;/code&gt; will hang forever trying to unlock it without prompting (in a non-interactive shell). The dedicated no-passphrase key avoids that, and since it's only usable for signing — not auth — the security trade-off is small.&lt;/p&gt;

&lt;p&gt;Second gotcha: commits aren't shown as "Verified" on GitHub unless the committer email matches a verified email on your GitHub account. If yours doesn't, switch to the &lt;code&gt;&amp;lt;user-id&amp;gt;+&amp;lt;username&amp;gt;@users.noreply.github.com&lt;/code&gt; format, which GitHub recognizes automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 7: Approval for outside-collaborator workflows
&lt;/h2&gt;

&lt;p&gt;The default GitHub setting is "Require approval for first-time contributors". That means a malicious user who's had one merged PR 5 years ago can push any workflow to your repo without approval. Ramp it up:&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;Settings → Actions → General → Fork pull request workflows from outside collaborators&lt;/code&gt;, pick &lt;strong&gt;"Require approval for all external contributors"&lt;/strong&gt;. Every outside PR now waits for me to click "Approve and run" before its workflow touches any of my secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layers 8–11: the boring-but-essential ones
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CODEOWNERS&lt;/strong&gt; — auto-request my review on any PR that touches &lt;code&gt;.github/**&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt;, or native code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependabot for GitHub Actions&lt;/strong&gt; — by default Dependabot only monitors &lt;code&gt;npm&lt;/code&gt; / &lt;code&gt;pip&lt;/code&gt; / etc. Add &lt;code&gt;github-actions&lt;/code&gt; to &lt;code&gt;.github/dependabot.yml&lt;/code&gt; so action pins get security updates too&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm WebAuthn 2FA&lt;/strong&gt; — not new, but confirm your npm account uses a hardware-backed second factor, not just TOTP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;package.json overrides&lt;/strong&gt; — when a transitive dep has a security advisory that the parent hasn't fixed, force the patched version with &lt;code&gt;overrides&lt;/code&gt; instead of waiting&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the attack surface looks like now
&lt;/h2&gt;

&lt;p&gt;For an attacker to publish malicious &lt;code&gt;safari-mcp@X.Y.Z&lt;/code&gt; to npm, they need to &lt;em&gt;simultaneously&lt;/em&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compromise my GitHub account (phishing-resistant passkey)&lt;/li&gt;
&lt;li&gt;Bypass signed commits on &lt;code&gt;main&lt;/code&gt; (signing key on a different laptop)&lt;/li&gt;
&lt;li&gt;Either compromise the code review process or commit directly (blocked by required_signatures)&lt;/li&gt;
&lt;li&gt;Bypass the &lt;code&gt;npm-publish&lt;/code&gt; environment approval (can_admins_bypass: false — not even I can skip it)&lt;/li&gt;
&lt;li&gt;Either compromise my local keychain (for the approval click) or the npm OIDC signing key at GitHub Actions runtime&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pre-playbook, the attacker needed: (1) my GitHub token, or (2) the NPM_TOKEN secret. One step.&lt;/p&gt;

&lt;p&gt;Post-playbook: five independent, cryptographically-bounded steps — four of which require a different device or a human decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-minute version
&lt;/h2&gt;

&lt;p&gt;If you're reading this and thinking "I'll do this later", here's the minimum viable version you can copy-paste &lt;em&gt;right now&lt;/em&gt; (replace &lt;code&gt;OWNER/REPO&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="c"&gt;# 1. SHA pinning (10s)&lt;/span&gt;
gh api &lt;span class="nt"&gt;--method&lt;/span&gt; PUT /repos/OWNER/REPO/actions/permissions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--input&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{"enabled": true, "allowed_actions": "all", "sha_pinning_required": true}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# 2. Branch protection (10s)&lt;/span&gt;
gh api &lt;span class="nt"&gt;--method&lt;/span&gt; PUT /repos/OWNER/REPO/branches/main/protection &lt;span class="nt"&gt;--input&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{"required_status_checks":null,"enforce_admins":false,"required_pull_request_reviews":null,"restrictions":null,"allow_force_pushes":false,"allow_deletions":false,"required_conversation_resolution":true}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# 3. Add github-actions to Dependabot (edit .github/dependabot.yml)&lt;/span&gt;
&lt;span class="c"&gt;# 4. Enable npm Trusted Publisher on npmjs.com for your package&lt;/span&gt;
&lt;span class="c"&gt;# 5. Delete NPM_TOKEN secret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five steps. Zero downtime. You're 70% of the way there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Plug
&lt;/h2&gt;

&lt;p&gt;I do this on a project called &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt; — a macOS-only MCP server that lets AI agents drive your real Safari (with all your existing logins) instead of spawning Chrome. It's MIT, runs on &lt;code&gt;npx safari-mcp&lt;/code&gt;, and has 80 tools for navigation, form fill, screenshots, and everything in between.&lt;/p&gt;

&lt;p&gt;If you're tired of Chrome DevTools MCP eating your M-series battery, give it a spin. And if you have opinions about any of the security layers above — or know one I missed — hit me on &lt;a href="https://github.com/achiya-automation/safari-mcp/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources and links:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.7.9" rel="noopener noreferrer"&gt;safari-mcp v2.7.9 release&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.npmjs.com/trusted-publishers" rel="noopener noreferrer"&gt;npm Trusted Publishers docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/concepts/deployments/deployment-environments" rel="noopener noreferrer"&gt;GitHub Actions deployment environments&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://slsa.dev/" rel="noopener noreferrer"&gt;SLSA provenance v1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/tj-actions/changed-files/issues/2464" rel="noopener noreferrer"&gt;tj-actions/changed-files incident post-mortem&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>security</category>
      <category>github</category>
      <category>npm</category>
    </item>
    <item>
      <title>Why 60 seconds beats a perfect message: an automation latency study</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sat, 11 Apr 2026 21:54:54 +0000</pubDate>
      <link>https://forem.com/achiya-automation/why-60-seconds-beats-a-perfect-message-an-automation-latency-study-3gde</link>
      <guid>https://forem.com/achiya-automation/why-60-seconds-beats-a-perfect-message-an-automation-latency-study-3gde</guid>
      <description>&lt;p&gt;A few months ago I built what looked like a textbook lead-capture workflow for a real estate company in Israel: Facebook ad → lead form → CRM → agent follow-up. Standard pipeline. The thing that surprised me about how it performed had nothing to do with the agents, the message templates, or the offer. It came down to a single variable I had not been measuring: &lt;strong&gt;time between trigger and first touch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is about what that variable does and why I now think it's the first thing to optimize in any marketing automation, not the last.&lt;/p&gt;

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

&lt;p&gt;Real estate, Hebrew market, Facebook lead ads. The original pre-automation flow looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prospect fills out a Facebook lead form&lt;/li&gt;
&lt;li&gt;Lead lands in the company's Facebook Lead Center&lt;/li&gt;
&lt;li&gt;Once or twice a day, an admin exports it&lt;/li&gt;
&lt;li&gt;Lead gets called by an available agent&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;End-to-end latency: &lt;strong&gt;3 to 6 hours&lt;/strong&gt; on a good day. The pattern was depressingly familiar to anyone who's looked at lead funnels — by the time the agent called, the prospect had already messaged 2-3 competing brokers, locked in a viewing with whoever responded first, and stopped picking up unknown numbers.&lt;/p&gt;

&lt;p&gt;The instinct (mine and theirs) was to fix this with better human routing: rotate agents, set up paging, train people to check more often. I've seen this approach a hundred times. It does not work. Humans are not the right tier for sub-minute response.&lt;/p&gt;

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

&lt;p&gt;The whole thing is small. Three pieces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Facebook Lead Form
       │
       ▼
[Webhook → n8n]
       │
       ├─→ WhatsApp Business API: send acknowledgment
       │
       ├─→ Monday CRM: create item with full lead data
       │
       └─→ Notify available agent (email + push)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The n8n flow has six nodes: Webhook trigger, a Set node to normalize the lead payload, a WhatsApp Cloud API node for the acknowledgment, an HTTP Request node to Monday's API, a second HTTP Request to send the agent notification, and an If node to handle the rare malformed payload.&lt;/p&gt;

&lt;p&gt;The acknowledgment template is intentionally boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hi {{firstName}}, thanks for your inquiry about {{property}}.
One of our agents will reach out shortly to schedule a viewing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No personalization beyond the first name and the property they asked about. No emoji. No marketing copy. No upsell.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected vs. what happened
&lt;/h2&gt;

&lt;p&gt;I expected the automation to mostly help on the operations side — fewer dropped leads, less manual data entry, agents stop chasing stale leads. Conversion impact, I figured, would come from the human follow-up still being good.&lt;/p&gt;

&lt;p&gt;What actually happened: the response time dropped from hours to under one minute, and conversion improved significantly. What surprised me was how much of that improvement seemed to come from the automated acknowledgment alone. Prospects who got the WhatsApp ping within 60 seconds were warmer when the human agent eventually called back, and "warmer" mostly meant "still expecting our call instead of three competitors'."&lt;/p&gt;

&lt;p&gt;The acknowledgment was not a bridge to the human call. It was the moment the prospect committed to talking to &lt;em&gt;us&lt;/em&gt; instead of shopping around.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model that explains it
&lt;/h2&gt;

&lt;p&gt;Here's what I now believe is happening, and it's pretty simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lead intent has a half-life of minutes, not hours.&lt;/strong&gt; When someone fills out a form, their attention is on the problem they're trying to solve right now. Every second after submission, that attention is leaking — to a competitor, to a phone notification, to dinner. By the 15-minute mark, you're competing with an entirely different mental state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An acknowledgment is not a placeholder. It's the conversion event.&lt;/strong&gt; Most marketers treat the first-touch message as "we got it, real reply coming." Prospects don't read it that way. They read it as "this business is alive and responsive." That signal — "alive and responsive" — is what makes them stop shopping. The actual conversation that follows is downstream of that decision, not the cause of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exact words barely matter as much as the timing.&lt;/strong&gt; Across the workflows I've built, copy tweaks (better wording, personalization, emoji, social proof) tend to produce small incremental conversion changes. Cutting response latency from hours to minutes consistently produces much larger jumps. The latency variable seems to dwarf the copy variable in almost every test I've watched.&lt;/p&gt;

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

&lt;p&gt;If you're building a marketing automation workflow, the order of operations I'd recommend is the opposite of how most teams approach it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;First&lt;/strong&gt;, measure your current trigger-to-first-response time. Not your trigger-to-human-call time. The time until &lt;em&gt;anything&lt;/em&gt; reaches the prospect. If that number is over 5 minutes, you have a latency problem and no amount of better copy will fix it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Second&lt;/strong&gt;, get a barebones acknowledgment out the door. Plain language. No personalization beyond what you can parse from the trigger payload. The goal is sub-60-second delivery.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Third&lt;/strong&gt;, instrument the gap between acknowledgment and human follow-up so you can see what conversion looks like with and without human touch. You will probably find, like I did, that the acknowledgment is doing more work than you thought.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Then&lt;/strong&gt;, and only then, start optimizing copy, personalization, and follow-up sequences.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This ordering matters because most marketing automation post-mortems end up with a list of recommendations like "rewrite the email," "add more drip steps," "personalize the subject line." Those are real levers but they're second-order. The first-order lever is latency, and almost no one is measuring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few practical notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use WhatsApp Business API or another channel with native push delivery, not email. Email-based acknowledgments hit the spam filter latency wall and you lose your sub-minute window.&lt;/li&gt;
&lt;li&gt;Make the trigger sync, not poll. Webhooks beat scheduled exports every time.&lt;/li&gt;
&lt;li&gt;Don't put your acknowledgment behind a manual approval step. The agent does not need to review the auto-message. Trust the template.&lt;/li&gt;
&lt;li&gt;Log the trigger-to-acknowledgment timestamp so you can actually see when it drifts. If it ever goes above 90 seconds you have an outage even if every node is "green."&lt;/li&gt;
&lt;li&gt;If you need a CRM, use one with a public API and a real webhook surface. Most of the time spent on these workflows is fighting CRM integrations, not building the automation logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've built something similar and seen the same effect — or if you've measured the opposite and the human touch matters more than I think — I'd love to hear about it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is one of about 50 automation projects I've built for Israeli SMBs. If you want to see more case studies, including the full pricing breakdown for similar workflows, &lt;a href="https://achiya-automation.com/blog/automatic-lead-collection-whatsapp/" rel="noopener noreferrer"&gt;there's a longer write-up on my site&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>n8n</category>
      <category>whatsapp</category>
      <category>marketing</category>
    </item>
    <item>
      <title>7 Things I Learned Building a Safari Browser Automation Tool That Chrome Can't Do</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 01 Apr 2026 10:42:21 +0000</pubDate>
      <link>https://forem.com/achiya-automation/7-things-i-learned-building-a-safari-browser-automation-tool-that-chrome-cant-do-2i6n</link>
      <guid>https://forem.com/achiya-automation/7-things-i-learned-building-a-safari-browser-automation-tool-that-chrome-cant-do-2i6n</guid>
      <description>&lt;p&gt;Every browser automation tool assumes you're using Chrome.&lt;/p&gt;

&lt;p&gt;Playwright? Chrome. Puppeteer? Chrome. Selenium? &lt;em&gt;Technically&lt;/em&gt; supports others, but let's be real -- Chrome. Even the new wave of AI-powered browser tools (Chrome DevTools MCP, Browserbase) are all Chromium under the hood.&lt;/p&gt;

&lt;p&gt;I use Safari as my daily browser. I have 47 tabs open right now with active sessions -- Gmail, GitHub, Ahrefs, my hosting dashboards. When I started building AI agents that needed to interact with web pages, every tool told me the same thing: "Just use Chrome."&lt;/p&gt;

&lt;p&gt;So I spent the last two weeks building &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt; -- a native Safari automation server with 80 tools, running entirely through AppleScript and JavaScript injection. No Chrome. No Puppeteer. No headless browser.&lt;/p&gt;

&lt;p&gt;Here are 7 things I learned that surprised me.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. WebKit on macOS Is Not What You Think It Is
&lt;/h2&gt;

&lt;p&gt;When Playwright says it supports WebKit, it's running a &lt;em&gt;custom build&lt;/em&gt; of WebKit in a separate process. It's WebKit the engine, not Safari the application.&lt;/p&gt;

&lt;p&gt;The real Safari on macOS runs inside the operating system's rendering pipeline. It shares resources with the window server, uses the system's DNS resolver, benefits from Apple's Intelligent Tracking Prevention, and -- this is the part that matters for automation -- it has access to your actual cookies, sessions, and logins.&lt;/p&gt;

&lt;p&gt;The practical difference: when my AI agent needs to check Google Search Console, it just... opens it. No login flow. No stored credentials. No OAuth dance. Safari already has my Google session from this morning.&lt;/p&gt;

&lt;p&gt;This is what "native" actually means. Not "runs on macOS" -- but "runs &lt;em&gt;as&lt;/em&gt; macOS."&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The CPU Difference Is Real: ~60% Less Than Chrome
&lt;/h2&gt;

&lt;p&gt;I wasn't expecting this to be significant. I was wrong.&lt;/p&gt;

&lt;p&gt;Running Chrome DevTools Protocol (via Chrome DevTools MCP) on my M2 MacBook Pro, Activity Monitor showed Chrome Helper processes eating 30-45% CPU during automation tasks. The fans spun up. My laptop got hot.&lt;/p&gt;

&lt;p&gt;Safari MCP doing the same tasks: 10-15% CPU. No fan noise. The reason isn't that Safari is "more efficient" in some abstract sense -- it's that Safari's rendering is baked into macOS's WindowServer process, which is already running. There's no separate browser process to spin up, no V8 isolate to warm, no DevTools protocol overhead.&lt;/p&gt;

&lt;p&gt;For AI agents that run for hours -- scraping data, filling forms, monitoring dashboards -- this isn't a nice-to-have. It's the difference between a usable laptop and a space heater.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Apple's Private Entitlement Wall (The Thing That Almost Killed the Project)
&lt;/h2&gt;

&lt;p&gt;My first approach was obvious: use Safari's Web Inspector Protocol. Safari &lt;em&gt;has&lt;/em&gt; a remote debugging protocol -- you can see it in the Develop menu. It's how Safari's built-in DevTools work.&lt;/p&gt;

&lt;p&gt;I spent days trying to connect to it programmatically. Here's what I found:&lt;/p&gt;

&lt;p&gt;Safari's Web Inspector uses an XPC service (&lt;code&gt;com.apple.WebKit.WebContent&lt;/code&gt;) that requires a &lt;strong&gt;private Apple entitlement&lt;/strong&gt; to connect. This entitlement is only granted to Apple-signed binaries -- Safari itself and Xcode's instruments.&lt;/p&gt;

&lt;p&gt;You cannot get this entitlement as a third-party developer. There's no API to request it. No workaround. Apple has deliberately locked down programmatic access to Safari's debugging protocol.&lt;/p&gt;

&lt;p&gt;This is the wall that stops every "Safari automation" attempt. It's why Selenium's Safari driver is perpetually limited. It's why no one has built a Puppeteer-for-Safari.&lt;/p&gt;

&lt;p&gt;I had to find another way in.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. AppleScript + A Swift Daemon Gets You ~5ms Per Command
&lt;/h2&gt;

&lt;p&gt;The "other way in" turned out to be hiding in plain sight: AppleScript's &lt;code&gt;do JavaScript&lt;/code&gt; command.&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;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;do&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;JavaScript&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document.title"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&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="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs arbitrary JavaScript in any Safari tab. It's been in macOS for over a decade. The catch: spawning &lt;code&gt;osascript&lt;/code&gt; as a subprocess takes ~80ms per call. For a single command that's fine. For an AI agent issuing hundreds of commands to fill a form, it's painfully slow.&lt;/p&gt;

&lt;p&gt;The solution: a persistent Swift daemon (&lt;code&gt;safari-helper.swift&lt;/code&gt; -- 301 lines) that keeps an &lt;code&gt;NSAppleScript&lt;/code&gt; instance alive in-process. The AI agent sends JSON lines over stdin, the daemon executes them and returns results.&lt;/p&gt;

&lt;p&gt;Result: &lt;strong&gt;~5ms per command&lt;/strong&gt; instead of ~80ms. A 16x speedup from a 301-line Swift file.&lt;/p&gt;

&lt;p&gt;The entire codebase is ~6,000 lines across 4 files. Two production dependencies (&lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; for the MCP protocol, &lt;code&gt;ws&lt;/code&gt; for the optional Extension WebSocket). That's it.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The Tab Ownership Problem (AI Agents Will Destroy Your Work)
&lt;/h2&gt;

&lt;p&gt;This one cost me a full day of lost work before I solved it.&lt;/p&gt;

&lt;p&gt;Here's the scenario: you're writing an email in Safari tab 3. Your AI agent is automating something in tab 5. The agent needs to navigate somewhere -- and it navigates &lt;em&gt;in your tab 3&lt;/em&gt; instead. Your half-written email is gone. The form state is destroyed. There's no undo.&lt;/p&gt;

&lt;p&gt;This happens because tab indices shift. You close a tab, every index after it changes. The agent cached "my tab is index 5" but now it's index 4, and your email tab is 5.&lt;/p&gt;

&lt;p&gt;My solution: &lt;strong&gt;tab tracking by URL&lt;/strong&gt;. Every command resolves the target tab by its URL, not its index. If the URL doesn't match, the command fails safely instead of hitting the wrong tab. The agent maintains a list of tabs it opened (via &lt;code&gt;safari_new_tab&lt;/code&gt;) and refuses to touch any tab it didn't create.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before EVERY command:
1. Resolve tab by URL, not cached index
2. Verify this is a tab the agent opened
3. If mismatch -&amp;gt; fail safely, never navigate in user's tab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added this as a hard rule in my MCP configuration: the AI agent must call &lt;code&gt;safari_list_tabs&lt;/code&gt; at the start of every session, track which tabs it opens, and verify ownership before every interaction.&lt;/p&gt;

&lt;p&gt;It sounds paranoid. It is paranoid. And it's the only way to safely share a browser between a human and an AI agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Shadow DOM, React State, and CSP -- Why I Had to Build a Safari Extension
&lt;/h2&gt;

&lt;p&gt;AppleScript's &lt;code&gt;do JavaScript&lt;/code&gt; is powerful, but it runs in the page's JavaScript context. Three things break it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Closed Shadow DOM.&lt;/strong&gt; Reddit, many web components, and design systems use &lt;code&gt;mode: 'closed'&lt;/code&gt; shadow roots. JavaScript running in the page context literally cannot see inside them -- &lt;code&gt;element.shadowRoot&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;. The only way in is through a browser extension's content script, which has access to the internal shadow tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React's internal value tracker.&lt;/strong&gt; If you set &lt;code&gt;input.value = 'hello'&lt;/code&gt; on a React-controlled input, React ignores it. React tracks the "last known value" via an internal &lt;code&gt;_valueTracker&lt;/code&gt; property on the DOM element. You have to reset this tracker &lt;em&gt;before&lt;/em&gt; dispatching the input event, or React's synthetic event system thinks nothing changed. I learned this the hard way on LinkedIn, where every form is React-controlled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The hack that makes React forms work:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_valueTracker&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;tracker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;input&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Content Security Policy.&lt;/strong&gt; Strict CSP headers block dynamic code execution and inline scripts. Some sites (banking, enterprise tools) restrict even more aggressively. The Extension runs in the &lt;code&gt;MAIN&lt;/code&gt; world with elevated privileges, bypassing CSP restrictions that would block AppleScript-injected JavaScript.&lt;/p&gt;

&lt;p&gt;This led to the &lt;strong&gt;dual-engine architecture&lt;/strong&gt;: the Safari Extension handles modern SPAs and CSP-strict sites (5-20ms per command via HTTP polling), while AppleScript handles everything else (~5ms via the Swift daemon). The system automatically falls back between them.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. CGEvent Window Targeting -- Clicking Without Stealing Focus
&lt;/h2&gt;

&lt;p&gt;The final boss: some sites (Airtable, complex React apps) don't respond to synthetic JavaScript clicks. They check &lt;code&gt;event.isTrusted&lt;/code&gt; -- a read-only property that's &lt;code&gt;true&lt;/code&gt; only for events generated by the OS, not by JavaScript.&lt;/p&gt;

&lt;p&gt;The obvious solution -- simulate a real mouse click via macOS accessibility APIs -- has a nasty side effect: it moves your physical cursor and brings Safari to the foreground. If you're typing in VS Code while your agent works, suddenly your cursor jumps and Safari appears on top.&lt;/p&gt;

&lt;p&gt;The fix lives in &lt;code&gt;safari-helper.swift&lt;/code&gt; and uses a largely undocumented CGEvent feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CGEventField 91 = kCGMouseEventWindowUnderMousePointer&lt;/span&gt;
&lt;span class="c1"&gt;// (not in Apple's public headers)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;kWindowField&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGEventField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;91&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setIntegerValueField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kWindowField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;windowId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By setting the window ID on the CGEvent, the click is delivered directly to Safari's window -- without moving the mouse cursor, without activating the window, without stealing focus. The event registers as &lt;code&gt;isTrusted: true&lt;/code&gt; in the browser.&lt;/p&gt;

&lt;p&gt;This field isn't in Apple's public CGEvent documentation. I found it by reading Chromium's source code (they use the same trick for their own window targeting) and then confirmed the raw field numbers work on macOS Sequoia.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&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 open source (MIT), installs via &lt;code&gt;npm install -g safari-mcp&lt;/code&gt; or Homebrew, and works with Claude Code, Claude Desktop, Cursor, Windsurf, and any MCP-compatible client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;By the numbers:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;80 tools (navigation, forms, screenshots, network mocking, cookies, accessibility, and more)&lt;/li&gt;
&lt;li&gt;~5ms per command via the persistent Swift daemon&lt;/li&gt;
&lt;li&gt;~6,000 lines of code across 4 files&lt;/li&gt;
&lt;li&gt;2 production dependencies&lt;/li&gt;
&lt;li&gt;~60% less CPU than Chrome-based alternatives&lt;/li&gt;
&lt;li&gt;MIT license&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use it daily at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt; for everything from filling client forms to monitoring dashboards to running SEO audits -- all without Chrome ever touching my system.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I started over, I'd skip the XPC/Web Inspector rabbit hole entirely and go straight to AppleScript + Extension. I lost three days on the private entitlement wall before accepting it wasn't going to work.&lt;/p&gt;

&lt;p&gt;I'd also build tab tracking from day one. The "navigate in the wrong tab" disaster happened because I treated it as an edge case. It's not an edge case -- it's the &lt;em&gt;default&lt;/em&gt; failure mode when an AI agent shares a browser with a human.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Question I Keep Coming Back To
&lt;/h2&gt;

&lt;p&gt;Every MCP server I've seen for browser automation is Chrome-first. Playwright MCP, Chrome DevTools MCP, Browserbase -- all Chromium.&lt;/p&gt;

&lt;p&gt;But most Mac developers I know use Safari as their daily browser. And the AI agent use case is fundamentally different from testing: you're not running in CI, you're running on &lt;em&gt;your&lt;/em&gt; machine, with &lt;em&gt;your&lt;/em&gt; sessions, while &lt;em&gt;you're&lt;/em&gt; actively working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For those of you building AI agents that interact with browsers: what's the biggest pain point you've hit with focus stealing, session management, or CPU overhead -- and did you solve it, or just live with Chrome eating your battery?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm genuinely curious whether the "just use headless Chrome" consensus holds when the agent runs on your personal laptop for 8 hours a day.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>I replaced Chrome DevTools MCP with Safari on my Mac. Here's what happened.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 30 Mar 2026 13:49:03 +0000</pubDate>
      <link>https://forem.com/achiya-automation/i-replaced-chrome-devtools-mcp-with-safari-on-my-mac-heres-what-happened-1i0l</link>
      <guid>https://forem.com/achiya-automation/i-replaced-chrome-devtools-mcp-with-safari-on-my-mac-heres-what-happened-1i0l</guid>
      <description>&lt;p&gt;Everyone in the MCP world uses Chrome. I stopped.&lt;/p&gt;

&lt;p&gt;Not because I'm contrarian. Because my MacBook Pro was hitting 97°C running Chrome DevTools MCP during a 3-hour automation session, and the fan noise was louder than my Spotify playlist.&lt;/p&gt;

&lt;p&gt;I'm an automation developer who builds AI-powered workflows for businesses (&lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;). My AI agents need to interact with browsers constantly — filling forms, scraping data, clicking through multi-step flows. Chrome DevTools MCP worked, but the cost was real: heat, zombie processes, and my browser getting hijacked mid-work.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt; — a native macOS MCP server with 80 tools, running entirely through AppleScript and JavaScript. No Chrome. No Puppeteer. No Playwright.&lt;/p&gt;

&lt;p&gt;Here are 7 things I learned along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. WebKit on Apple Silicon is dramatically cheaper than Chromium
&lt;/h2&gt;

&lt;p&gt;This isn't a fabricated benchmark — it's something any Mac developer can verify themselves. Safari uses the native WebKit engine that Apple optimizes specifically for their hardware. Chrome brings its own Chromium engine, which runs as a separate process architecture.&lt;/p&gt;

&lt;p&gt;The difference is measurable. Open Activity Monitor, run the same page in Safari and Chrome, and watch the CPU and energy impact columns. On Apple Silicon Macs, WebKit consistently uses roughly 60% less CPU than Chromium for equivalent workloads. Apple publishes WebKit energy efficiency comparisons on &lt;a href="https://webkit.org/blog/8970/how-web-content-can-affect-power-usage/" rel="noopener noreferrer"&gt;webkit.org&lt;/a&gt;, and independent tests from outlets like &lt;a href="https://www.imore.com/" rel="noopener noreferrer"&gt;iMore&lt;/a&gt; and &lt;a href="https://www.tomsguide.com/" rel="noopener noreferrer"&gt;Tom's Guide&lt;/a&gt; have confirmed the gap on M-series chips.&lt;/p&gt;

&lt;p&gt;For MCP automation, this compounds. An AI agent might execute hundreds of browser commands per session. Each one is cheaper when the underlying engine is native.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. You don't need Chrome DevTools Protocol for browser automation on macOS
&lt;/h2&gt;

&lt;p&gt;This was the biggest mental shift. The MCP ecosystem gravitates toward Chrome DevTools Protocol (CDP) because it's well-documented and cross-platform. But on macOS, AppleScript has been automating Safari since the 90s.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;do JavaScript&lt;/code&gt; command in AppleScript lets you execute arbitrary JS in any Safari tab, by index, without activating the window. That single capability covers about 80% of what CDP does for MCP tools — navigation, DOM reading, clicking, form filling, screenshots.&lt;/p&gt;

&lt;p&gt;The key insight: AppleScript doesn't spawn a new process for each command if you keep a persistent &lt;code&gt;osascript&lt;/code&gt; process running. I use a Swift helper daemon that keeps one process alive and pipes commands through stdin/stdout. Result: ~5ms per command, compared to ~80ms when spawning a fresh &lt;code&gt;osascript&lt;/code&gt; each time. That's a 16x improvement from a single architectural decision — keep the process alive instead of spawning one per command. It sounds obvious in retrospect, but I only figured it out after profiling showed that 90% of the latency was process startup, not the actual JavaScript execution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude/Cursor/AI Agent
        ↓ MCP Protocol (stdio)
   Safari MCP Server (Node.js)
        ↓                    ↓
   Extension (HTTP)     AppleScript + Swift daemon
   (~5-20ms/cmd)        (~5ms/cmd, always available)
        ↓                    ↓
   Content Script       do JavaScript in tab N
        ↓                    ↓
   Page DOM ←←←←←←←←←← Page DOM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The "no focus steal" problem nearly killed the project
&lt;/h2&gt;

&lt;p&gt;Here's the scenario that made me start this project: I'm typing an email in Safari. My AI agent decides to navigate to a different page for a task I assigned it. Chrome DevTools MCP opens a new Chrome window — which steals focus from Safari. Annoying, but manageable.&lt;/p&gt;

&lt;p&gt;But the real nightmare: what if the agent navigates &lt;em&gt;in a tab I'm actively using&lt;/em&gt;? Forms I was filling get wiped. Unsaved text disappears. This actually happened to me.&lt;/p&gt;

&lt;p&gt;Safari MCP solves this at the architecture level. Every tool targets tabs by index. The &lt;code&gt;safari_new_tab&lt;/code&gt; command opens tabs in the background. There is literally no &lt;code&gt;activate&lt;/code&gt; command anywhere in the codebase. Safari never comes to the foreground.&lt;/p&gt;

&lt;p&gt;I added a safety protocol: the agent must call &lt;code&gt;safari_list_tabs&lt;/code&gt; at session start, note which tabs already exist, and only interact with tabs it opened via &lt;code&gt;safari_new_tab&lt;/code&gt;. Any tab the agent didn't open is treated as the user's tab — untouchable.&lt;/p&gt;

&lt;p&gt;This alone was worth building the entire project.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. React, Vue, and Angular break naive form filling
&lt;/h2&gt;

&lt;p&gt;This was the hardest engineering problem. If you just set &lt;code&gt;element.value = "text"&lt;/code&gt; on a React input, nothing happens. React uses a synthetic event system and tracks internal state via a &lt;code&gt;_valueTracker&lt;/code&gt; on the element. You need to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get the native &lt;code&gt;HTMLInputElement&lt;/code&gt; setter&lt;/li&gt;
&lt;li&gt;Call it with &lt;code&gt;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(element, value)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Delete the &lt;code&gt;_valueTracker&lt;/code&gt; so React doesn't skip the update&lt;/li&gt;
&lt;li&gt;Dispatch &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;change&lt;/code&gt; events with &lt;code&gt;bubbles: true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dispatch &lt;code&gt;blur&lt;/code&gt; for validation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Vue and Angular have their own quirks. Closure-based editors like Medium's need &lt;code&gt;execCommand&lt;/code&gt; insertion or synthetic clipboard paste events.&lt;/p&gt;

&lt;p&gt;Safari MCP's &lt;code&gt;safari_fill&lt;/code&gt; handles all of these. It auto-detects the framework and applies the correct strategy. This was easily 40% of the development effort for what seems like a trivially simple feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A Safari Extension unlocks what AppleScript can't touch
&lt;/h2&gt;

&lt;p&gt;AppleScript gets you 80% of the way. But modern web apps have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Closed Shadow DOM&lt;/strong&gt; (Reddit, web components) — AppleScript JavaScript can't see inside&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict Content Security Policy&lt;/strong&gt; — blocks injected scripts on sites like GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex editor state&lt;/strong&gt; (Draft.js, ProseMirror, Slate) — needs deep framework-level manipulation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built a Safari Web Extension that injects a content script into every page. It runs in the MAIN world (not ISOLATED), giving it full access to the page's JavaScript context, Shadow DOM, and framework internals.&lt;/p&gt;

&lt;p&gt;The extension communicates with the MCP server via HTTP polling on port 9224. The MCP server exposes a simple REST API, the extension polls it every 100ms for pending commands, executes them, and posts results back.&lt;/p&gt;

&lt;p&gt;The dual-engine design means everything works with just AppleScript, but the extension makes it better. If the extension isn't connected, the server falls back automatically. There's no configuration needed — the server tries the extension first, and if it doesn't respond within a few milliseconds, routes through AppleScript. Users who don't want to bother with Xcode and extension setup get a fully working tool out of the box. Power users who need Shadow DOM access or CSP bypass can opt into the extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The honest limitations matter
&lt;/h2&gt;

&lt;p&gt;I keep Chrome DevTools MCP installed. Here's why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lighthouse audits&lt;/strong&gt; — there's no Safari equivalent. When I need Core Web Vitals scores, I use Chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance traces&lt;/strong&gt; — Chrome's Performance panel and trace format are irreplaceable for debugging rendering bottlenecks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory snapshots&lt;/strong&gt; — heap snapshots for finding memory leaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform&lt;/strong&gt; — Safari MCP is macOS only. Period. If you're on Linux or Windows, this doesn't exist for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My actual workflow: Safari MCP handles 95% of daily automation (navigation, form filling, data extraction, screenshots). Chrome DevTools MCP handles the 5% that requires Chromium-specific devtools.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Session persistence is the feature nobody talks about
&lt;/h2&gt;

&lt;p&gt;With Playwright or Puppeteer, every session starts with a fresh browser profile. No cookies. No logins. No saved passwords. You need to handle authentication flows every single time, or manage browser profiles and cookie injection.&lt;/p&gt;

&lt;p&gt;Safari MCP uses your actual Safari browser. Gmail is logged in. GitHub is logged in. Your company's internal tools are logged in. When your AI agent needs to check something on a site you're authenticated on — it just works.&lt;/p&gt;

&lt;p&gt;This saves an absurd amount of time. No OAuth flows to automate. No cookie files to manage. No headless browser profiles. I've literally saved hours per week not having to deal with authentication in automation scripts.&lt;/p&gt;

&lt;p&gt;The trade-off is obvious: your agent has access to your real sessions, so you need to trust what it's doing. But for a local development tool running on your own machine, that's the correct trade-off. You're already trusting your AI coding assistant with your filesystem and terminal — browser sessions aren't a meaningful expansion of that trust boundary.&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; safari-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prerequisites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS (any version)&lt;/li&gt;
&lt;li&gt;Node.js 18+&lt;/li&gt;
&lt;li&gt;Safari → Settings → Advanced → "Show features for web developers" ✓&lt;/li&gt;
&lt;li&gt;Safari → Develop → "Allow JavaScript from Apple Events" ✓&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add to your MCP client config (Claude Code, Cursor, Windsurf, etc.):&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;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safari"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safari-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt; has full docs, all 80 tools listed, and a comparison table against Chrome DevTools MCP and Playwright MCP.&lt;/p&gt;

&lt;p&gt;If you want to see how I use it in production automation workflows — I run an automation agency (&lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;) where Safari MCP is a core part of the stack alongside &lt;a href="https://n8n.io/?ref=achiya" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; for workflow orchestration.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Mac developers — what's your biggest pain point with browser automation tools in your MCP/AI agent setup?&lt;/strong&gt; I'm genuinely curious whether the problems I solved (heat, focus stealing, session persistence) are the ones that bother you most, or if there are pain points I haven't addressed yet. If you've built your own MCP server for something unconventional, I'd love to hear about that too.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>mcp</category>
      <category>webdev</category>
      <category>macos</category>
    </item>
    <item>
      <title>Every MCP Browser Tool Uses Chromium. That's a Problem.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sat, 28 Mar 2026 20:08:51 +0000</pubDate>
      <link>https://forem.com/achiya-automation/every-mcp-browser-tool-uses-chromium-thats-a-problem-4kpp</link>
      <guid>https://forem.com/achiya-automation/every-mcp-browser-tool-uses-chromium-thats-a-problem-4kpp</guid>
      <description>&lt;p&gt;The Model Context Protocol has a browser monoculture problem, and nobody's talking about it.&lt;/p&gt;

&lt;p&gt;I just searched the MCP server registry. There are at least 13 browser automation servers listed. Every single one requires Chromium -- Chrome DevTools Protocol, Puppeteer, Playwright with Chromium, or some wrapper around them. If your AI agent needs to interact with a web page, your only option has been "launch Chrome."&lt;/p&gt;

&lt;p&gt;This matters more than you think. Not because of some abstract browser diversity argument, but because &lt;strong&gt;Chrome is the single biggest resource drain in most developers' MCP setups&lt;/strong&gt; -- and it's completely unnecessary for 95% of browser automation tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Realization That Started This
&lt;/h2&gt;

&lt;p&gt;I run an automation business. My daily workflow involves Claude Code connected to 6-7 MCP servers simultaneously. One day I noticed my M2 MacBook Pro fans spinning up during what should have been a simple task -- the AI agent was just reading a dashboard and filling a form.&lt;/p&gt;

&lt;p&gt;I opened Activity Monitor. Chrome (spawned by the browser MCP server) was using 38% CPU. Just sitting there. With a debug port open. Waiting for commands.&lt;/p&gt;

&lt;p&gt;Meanwhile, Safari -- the browser where I was already logged into everything I needed -- was using 0.3% CPU with 11 tabs open.&lt;/p&gt;

&lt;p&gt;That's when it clicked: &lt;strong&gt;why am I running two browsers when the one I already have open does everything I need?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducing Safari MCP
&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 a native MCP server that controls Safari directly through AppleScript and JavaScript. No Chrome. No Puppeteer. No WebDriver. No headless browser process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install from npm&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; safari-mcp

&lt;span class="c"&gt;# Or clone the repo&lt;/span&gt;
git clone https://github.com/achiya-automation/safari-mcp.git
&lt;span class="nb"&gt;cd &lt;/span&gt;safari-mcp
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable Safari's JavaScript bridge (one-time setup):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Safari -&amp;gt; Settings -&amp;gt; Advanced -&amp;gt; &lt;strong&gt;Show features for web developers&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Safari -&amp;gt; Develop -&amp;gt; &lt;strong&gt;Allow JavaScript from Apple Events&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Add to your MCP client config (&lt;code&gt;~/.mcp.json&lt;/code&gt; for Claude Code, &lt;code&gt;.cursor/mcp.json&lt;/code&gt; for Cursor):&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;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safari"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safari-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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;That's it. No Chrome to install, no debug ports, no Playwright browser binaries to download.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dual-Engine Architecture
&lt;/h2&gt;

&lt;p&gt;Most people look at this project and assume it's "just AppleScript." It's not. Safari MCP runs a &lt;strong&gt;dual-engine architecture&lt;/strong&gt; where two completely different execution paths compete for the best way to handle each command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI Agent (Claude Code / Cursor / Windsurf)
        |
        v  MCP Protocol (stdio)
   Safari MCP Server (Node.js)
        |                    |
   Safari Extension      AppleScript + Swift daemon
   (HTTP, ~5-20ms)       (~5ms, always available)
        |                    |
   Content Script        do JavaScript in tab N
        |                    |
        +---&amp;gt; Page DOM &amp;lt;-----+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Engine 1: AppleScript + Swift Daemon (always available)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the foundation. A persistent Swift helper process stays running and accepts AppleScript commands via stdin, returning results via stdout. Instead of spawning a new &lt;code&gt;osascript&lt;/code&gt; process for every command (~80ms overhead each), the daemon keeps one process alive. Result: &lt;strong&gt;~5ms per command&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engine 2: Safari Extension (optional, for advanced scenarios)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A native Safari Web Extension that communicates with the MCP server over HTTP (port 9224). This engine handles things AppleScript fundamentally cannot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Closed Shadow DOM&lt;/strong&gt; -- Reddit, Web Components, Shoelace UI. AppleScript's &lt;code&gt;do JavaScript&lt;/code&gt; runs in the page context and can't pierce shadow boundaries. The extension's content script runs in an isolated world with access to &lt;code&gt;element.shadowRoot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict CSP sites&lt;/strong&gt; -- Sites with &lt;code&gt;script-src&lt;/code&gt; Content Security Policy headers block injected JavaScript. The extension executes in the MAIN world, bypassing CSP entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep framework state&lt;/strong&gt; -- React Fiber tree traversal, ProseMirror editor manipulation, Vue reactivity system hooks. The extension can access internal framework objects that AppleScript-injected JS often can't reach.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server automatically uses the Extension when it's connected, falling back to AppleScript seamlessly. You don't configure anything -- it just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  80 Tools Across 20 Categories
&lt;/h2&gt;

&lt;p&gt;Safari MCP isn't a proof of concept with 5 navigation tools. It ships 80 tools that cover everything from basic navigation to network mocking to accessibility auditing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Navigation &amp;amp; Reading&lt;/strong&gt; -- &lt;code&gt;safari_navigate&lt;/code&gt;, &lt;code&gt;safari_read_page&lt;/code&gt;, &lt;code&gt;safari_navigate_and_read&lt;/code&gt; (combines both in one round-trip), &lt;code&gt;safari_go_back&lt;/code&gt;, &lt;code&gt;safari_go_forward&lt;/code&gt;, &lt;code&gt;safari_reload&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interaction&lt;/strong&gt; -- &lt;code&gt;safari_click&lt;/code&gt; (CSS selector, visible text, or coordinates), &lt;code&gt;safari_double_click&lt;/code&gt;, &lt;code&gt;safari_right_click&lt;/code&gt;, &lt;code&gt;safari_hover&lt;/code&gt;, &lt;code&gt;safari_drag&lt;/code&gt;, &lt;code&gt;safari_click_and_wait&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forms&lt;/strong&gt; -- &lt;code&gt;safari_fill&lt;/code&gt; (React/Vue/Angular compatible), &lt;code&gt;safari_fill_form&lt;/code&gt; (batch), &lt;code&gt;safari_fill_and_submit&lt;/code&gt;, &lt;code&gt;safari_select_option&lt;/code&gt;, &lt;code&gt;safari_type_text&lt;/code&gt;, &lt;code&gt;safari_press_key&lt;/code&gt;, &lt;code&gt;safari_clear_field&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots &amp;amp; PDF&lt;/strong&gt; -- &lt;code&gt;safari_screenshot&lt;/code&gt; (viewport or full page), &lt;code&gt;safari_screenshot_element&lt;/code&gt;, &lt;code&gt;safari_save_pdf&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network&lt;/strong&gt; -- &lt;code&gt;safari_start_network_capture&lt;/code&gt;, &lt;code&gt;safari_network_details&lt;/code&gt; (headers, timing, bodies), &lt;code&gt;safari_mock_route&lt;/code&gt; (intercept fetch/XHR), &lt;code&gt;safari_throttle_network&lt;/code&gt; (simulate 3G/4G/offline)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage&lt;/strong&gt; -- Full cookie, localStorage, sessionStorage, and IndexedDB access. Plus &lt;code&gt;safari_export_storage&lt;/code&gt; / &lt;code&gt;safari_import_storage&lt;/code&gt; for backing up and restoring entire browser sessions as JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility&lt;/strong&gt; -- &lt;code&gt;safari_accessibility_snapshot&lt;/code&gt; returns the full a11y tree with roles, ARIA attributes, and focusable elements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data Extraction&lt;/strong&gt; -- &lt;code&gt;safari_extract_tables&lt;/code&gt; (structured JSON), &lt;code&gt;safari_extract_meta&lt;/code&gt; (OG, Twitter, JSON-LD), &lt;code&gt;safari_extract_links&lt;/code&gt;, &lt;code&gt;safari_extract_images&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advanced&lt;/strong&gt; -- &lt;code&gt;safari_css_coverage&lt;/code&gt; (find unused CSS), &lt;code&gt;safari_analyze_page&lt;/code&gt; (full analysis in one call), &lt;code&gt;safari_emulate&lt;/code&gt; (device emulation), &lt;code&gt;safari_run_script&lt;/code&gt; (batch multiple actions)&lt;/p&gt;

&lt;h2&gt;
  
  
  The React Form Problem Everyone Hits
&lt;/h2&gt;

&lt;p&gt;If you've automated browser forms with any tool, you've probably hit this wall:&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;// This doesn't work in React&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;#email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test@example.com&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;React's synthetic event system doesn't see direct &lt;code&gt;value&lt;/code&gt; assignments. The component state doesn't update. Validation doesn't run. The submit button stays disabled.&lt;/p&gt;

&lt;p&gt;Safari MCP handles this correctly by default using native property setters:&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;// What safari_fill actually does under the hood&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nativeSetter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&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;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;nativeSetter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&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;element&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;element&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;change&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach works with React, Vue, Angular, Svelte, Solid -- any framework that uses synthetic event listeners or intercepted property setters. When the Extension is active, it goes even deeper: it can reset React's internal &lt;code&gt;_valueTracker&lt;/code&gt; to ensure the framework truly sees the new value.&lt;/p&gt;

&lt;p&gt;You don't think about any of this. You just call &lt;code&gt;safari_fill&lt;/code&gt; and it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Head-to-Head: Safari MCP vs The Alternatives
&lt;/h2&gt;

&lt;p&gt;Here's an honest comparison. I use all three of these regularly and each has legitimate strengths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Safari MCP&lt;/th&gt;
&lt;th&gt;Chrome DevTools MCP&lt;/th&gt;
&lt;th&gt;Playwright MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Engine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;WebKit (native)&lt;/td&gt;
&lt;td&gt;Chromium (CDP)&lt;/td&gt;
&lt;td&gt;Chromium/WebKit/FF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU idle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~0.1%&lt;/td&gt;
&lt;td&gt;~8-15% (observed, M2)&lt;/td&gt;
&lt;td&gt;~3-8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU active&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2-5%&lt;/td&gt;
&lt;td&gt;~25-40% (observed, M2)&lt;/td&gt;
&lt;td&gt;~10-25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~30MB (Node only)&lt;/td&gt;
&lt;td&gt;~200-400MB (Chrome+Node)&lt;/td&gt;
&lt;td&gt;~150-300MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Command latency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5ms&lt;/td&gt;
&lt;td&gt;~15-30ms&lt;/td&gt;
&lt;td&gt;~20-50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Startup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt;1s&lt;/td&gt;
&lt;td&gt;3-5s&lt;/td&gt;
&lt;td&gt;2-4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your logins/cookies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (real Safari)&lt;/td&gt;
&lt;td&gt;Yes (your Chrome)&lt;/td&gt;
&lt;td&gt;No (clean profile)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tool count&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;~30&lt;/td&gt;
&lt;td&gt;~25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Network mocking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lighthouse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Performance traces&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cross-platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;macOS only&lt;/td&gt;
&lt;td&gt;Any OS&lt;/td&gt;
&lt;td&gt;Any OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Headless mode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shadow DOM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (with Extension)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dependencies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Chrome + debug port&lt;/td&gt;
&lt;td&gt;Playwright runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Where each tool wins:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Safari MCP&lt;/strong&gt;: Daily workflow automation, anything involving your existing browser sessions, long-running agent tasks where CPU/battery matters, Mac-native development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome DevTools MCP&lt;/strong&gt;: Lighthouse audits, Performance traces, CPU profiling, memory snapshots -- the debugging tools Chrome pioneered&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playwright MCP&lt;/strong&gt;: Cross-platform CI/CD, testing across multiple browser engines, headless server environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CPU/memory numbers above are what I observed on my M2 MacBook Pro running each server. Your numbers will vary, but the relative differences should hold -- Chrome simply has more overhead because it's running an entire separate browser process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Agent Workflow: Why Sessions Matter
&lt;/h2&gt;

&lt;p&gt;Here's something the comparison table doesn't capture: &lt;strong&gt;authenticated sessions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When I ask Claude to "check my Google Search Console rankings," Safari MCP just... does it. Because I'm already logged into GSC in Safari. The agent navigates to the page, reads the data, done.&lt;/p&gt;

&lt;p&gt;With Playwright MCP, I'd need to either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store credentials and log in every time (security concern)&lt;/li&gt;
&lt;li&gt;Export cookies and import them (fragile, expires)&lt;/li&gt;
&lt;li&gt;Use a persistent browser context (more complexity)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With Chrome DevTools MCP, my Chrome also has my sessions, so this works too. But now I'm running two browsers -- Safari for my work, Chrome for the AI agent. That's 400MB+ of RAM and 15% CPU for the privilege.&lt;/p&gt;

&lt;p&gt;Safari MCP sidesteps all of this. One browser. One set of sessions. Zero extra overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup for Every MCP Client
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; (&lt;code&gt;~/.mcp.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safari"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safari-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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;Claude Desktop&lt;/strong&gt; (&lt;code&gt;claude_desktop_config.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safari"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safari-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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;Cursor&lt;/strong&gt; (&lt;code&gt;.cursor/mcp.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safari"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safari-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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;All of these work identically. The server communicates over stdio using the standard MCP protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Tradeoffs
&lt;/h2&gt;

&lt;p&gt;Safari MCP is &lt;strong&gt;not&lt;/strong&gt; a drop-in replacement for everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;macOS only&lt;/strong&gt; -- If you're on Linux or Windows, this doesn't exist for you. Safari is a macOS application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No headless mode&lt;/strong&gt; -- Safari is always "real." You can't run it on a CI server without a display.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Lighthouse&lt;/strong&gt; -- Performance auditing is Chrome's crown jewel. I still use Chrome DevTools MCP for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No CDP&lt;/strong&gt; -- We use AppleScript, not Chrome DevTools Protocol. This is both a limitation and the reason it's so lightweight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extension requires Xcode&lt;/strong&gt; -- The optional Safari Extension needs a one-time Xcode build. AppleScript mode works without it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My actual daily setup: Safari MCP for 95% of tasks, Chrome DevTools MCP for the 5% that specifically needs Lighthouse or Performance traces. They coexist peacefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The project is MIT-licensed, open source, and actively maintained. The codebase is deliberately small -- two main files (&lt;code&gt;index.js&lt;/code&gt; for MCP tool definitions, &lt;code&gt;safari.js&lt;/code&gt; for the AppleScript bridge) totaling about 5,000 lines.&lt;/p&gt;

&lt;p&gt;Some things I'm working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Better tab management for multi-window workflows&lt;/li&gt;
&lt;li&gt;Improved network capture with response body access&lt;/li&gt;
&lt;li&gt;More device emulation presets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PRs are welcome. The architecture is simple enough that most contributors can get productive within an hour of reading the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub&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;npm&lt;/strong&gt;: &lt;a href="https://www.npmjs.com/package/safari-mcp" rel="noopener noreferrer"&gt;npmjs.com/package/safari-mcp&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Question I Actually Want Answered
&lt;/h2&gt;

&lt;p&gt;I built Safari MCP because Chrome's overhead drove me crazy. But I'm genuinely uncertain about something:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many MCP servers are you running simultaneously in your daily workflow, and what's the total memory footprint?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I run 6-7 and it adds up fast. I'm curious whether other people have hit the same resource ceiling, or whether I'm unusually sensitive to it because I work on a laptop all day without external power.&lt;/p&gt;

&lt;p&gt;If you're running Chrome DevTools MCP or Playwright MCP alongside 4+ other MCP servers, I'd love to know: &lt;strong&gt;what does your Activity Monitor (or Task Manager) look like right now?&lt;/strong&gt; Screenshot it. I bet at least one process in there is Chrome eating more resources than all your other MCP servers combined.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>safari</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Replaced Zapier with n8n for 12 Clients — Here's What Actually Happened</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 26 Mar 2026 18:49:14 +0000</pubDate>
      <link>https://forem.com/achiya-automation/i-replaced-zapier-with-n8n-for-12-clients-heres-what-actually-happened-5546</link>
      <guid>https://forem.com/achiya-automation/i-replaced-zapier-with-n8n-for-12-clients-heres-what-actually-happened-5546</guid>
      <description>&lt;p&gt;&lt;strong&gt;I've been migrating small business clients from Zapier to self-hosted n8n over the past year. The cost difference is dramatic — but it wasn't all sunshine. Here's the honest breakdown of what worked, what broke, and what I'd do differently.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Even Considered the Switch
&lt;/h2&gt;

&lt;p&gt;The trigger was simple: I kept seeing clients paying $200-500+/month for Zapier to run 8-15 basic workflows. Lead capture, CRM sync, appointment reminders, weekly reports. Nothing that actually needs a premium SaaS platform.&lt;/p&gt;

&lt;p&gt;When I started mapping out what these workflows actually do — webhook trigger, transform data, HTTP request — I realized n8n on a $20/month VPS could handle all of it. The math was too obvious to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration: 3 Patterns That Kept Repeating
&lt;/h2&gt;

&lt;p&gt;After migrating multiple clients across industries (real estate, clinics, e-commerce, law firms), I noticed three patterns:&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: The "Simple" Workflows Were Actually Simple
&lt;/h3&gt;

&lt;p&gt;About 60% of workflows were straightforward: webhook trigger → transform data → HTTP request to CRM/WhatsApp. These migrated in under 30 minutes each.&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;n&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="err"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;webhook&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;WhatsApp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;notification&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(simplified)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n8n-nodes-base.webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"new-lead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"httpMethod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Send WhatsApp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n8n-nodes-base.httpRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"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://waha-api.example.com/api/sendText"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"chatId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"={{$json.phone}}@c.us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"New lead: {{$json.name}} - {{$json.email}}"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pattern 2: Zapier Multi-Step Workflows Became Single n8n Workflows
&lt;/h3&gt;

&lt;p&gt;In Zapier, complex logic requires multiple Zaps chained together. In n8n, one workflow handles everything with IF/Switch nodes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example — lead routing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lead comes in from form&lt;/li&gt;
&lt;li&gt;IF budget &amp;gt; ₪10K → assign to senior agent + send premium template&lt;/li&gt;
&lt;li&gt;ELSE IF returning customer → assign to their previous agent&lt;/li&gt;
&lt;li&gt;ELSE → round-robin assignment + standard template&lt;/li&gt;
&lt;li&gt;ALL paths → log to Google Sheets + notify on WhatsApp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Zapier, this needs multiple Zaps. In n8n: 1 workflow, no extra cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: Error Handling is Where n8n Destroys Zapier
&lt;/h3&gt;

&lt;p&gt;Zapier's error handling is binary: retry or stop. n8n gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Error triggers that catch failures and route them&lt;/li&gt;
&lt;li&gt;Retry with backoff on specific nodes&lt;/li&gt;
&lt;li&gt;Dead letter queues for manual review&lt;/li&gt;
&lt;li&gt;Custom error notifications via WhatsApp/email
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// n8n error workflow — notify on WhatsApp when any workflow fails&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&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;Error Handler&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;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;n8n-nodes-base.errorTrigger&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;position&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="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// → Format error message → Send WhatsApp alert to admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Error workflows alone are a game changer — instead of discovering failures hours later, you get instant WhatsApp alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Downsides
&lt;/h2&gt;

&lt;p&gt;I'd be lying if I said the migration was painless. Here's what actually went wrong:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Initial Setup Complexity
&lt;/h3&gt;

&lt;p&gt;Zapier: sign up, connect apps, done. n8n self-hosted: VPS setup, Docker, SSL, reverse proxy, backups. For non-technical users, this is a dealbreaker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My solution:&lt;/strong&gt; I handle all infrastructure. Client never touches a terminal. They get a URL, log in, and see their workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Some Integrations Don't Exist
&lt;/h3&gt;

&lt;p&gt;Zapier has 6,000+ integrations. n8n has ~400 built-in + community nodes. For some clients, I needed custom HTTP nodes for Israeli CRMs (Priority, Rivhit) that had native Zapier integrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time cost:&lt;/strong&gt; ~2 hours per custom integration. But once built, reusable across all clients.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Updates Require Management
&lt;/h3&gt;

&lt;p&gt;Zapier auto-updates. Self-hosted n8n needs manual updates (or a cron job). I've set up automated updates with health checks — but it's still my responsibility, not Zapier's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Difference
&lt;/h2&gt;

&lt;p&gt;The exact savings depend on the client's Zapier plan and usage. But here's the general pattern I've seen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zapier costs scale linearly&lt;/strong&gt; — every new workflow, every additional task, every premium integration adds to the bill&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n self-hosted has a flat cost&lt;/strong&gt; — one VPS handles multiple clients, and adding workflows costs nothing extra&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The biggest hidden saving&lt;/strong&gt; is that clients start automating more when the marginal cost is zero. On Zapier, they'd think twice before adding a new workflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For context, Hetzner VPS hosting for n8n starts around $20/month and can handle dozens of workflows across multiple clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should NOT Switch to n8n
&lt;/h2&gt;

&lt;p&gt;Let me be clear: n8n isn't for everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stay on Zapier if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're non-technical and don't have someone to manage infrastructure&lt;/li&gt;
&lt;li&gt;You use 30+ different SaaS tools (Zapier's integration catalog is unbeatable)&lt;/li&gt;
&lt;li&gt;Uptime SLA is critical and you can't manage your own infrastructure&lt;/li&gt;
&lt;li&gt;Your budget is under $50/month (Zapier's free tier might suffice)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Switch to n8n if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're spending $200+/month on Zapier/Make&lt;/li&gt;
&lt;li&gt;You need complex logic (branching, loops, error handling)&lt;/li&gt;
&lt;li&gt;You work with APIs not in Zapier's catalog&lt;/li&gt;
&lt;li&gt;Data privacy matters (self-hosted = your data stays on your servers)&lt;/li&gt;
&lt;li&gt;You have someone technical to manage it (or hire someone like me)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Tool I Actually Recommend for Most Israeli SMBs
&lt;/h2&gt;

&lt;p&gt;For my Israeli clients specifically, the sweet spot is usually:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;n8n self-hosted&lt;/strong&gt; for the automation engine ($20/month VPS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAHA&lt;/strong&gt; for WhatsApp API (self-hosted, ~$0 vs Twilio's per-message pricing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; for database (free tier covers most SMBs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monday/HubSpot&lt;/strong&gt; for CRM (what the team actually sees)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total infrastructure cost: $20-40/month vs $500-2,000/month for the equivalent SaaS stack.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's your automation stack? Still on Zapier, moved to Make, or went self-hosted? I'm genuinely curious what the cost difference looked like for your setup.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Achiya, an automation consultant based in Israel specializing in WhatsApp bots and business workflow automation. If you're considering the switch, &lt;a href="https://achiya-automation.com/contact/" rel="noopener noreferrer"&gt;feel free to reach out&lt;/a&gt;. &lt;a href="https://n8n.partnerlinks.io/achiya-automation" rel="noopener noreferrer"&gt;Try n8n&lt;/a&gt; — the platform that made all of this possible.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>n8n</category>
      <category>zapier</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Built a Multi-Tenant WhatsApp Automation Platform Using n8n and WAHA</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 23 Mar 2026 11:22:26 +0000</pubDate>
      <link>https://forem.com/achiya-automation/how-i-built-a-multi-tenant-whatsapp-automation-platform-using-n8n-and-waha-4jj4</link>
      <guid>https://forem.com/achiya-automation/how-i-built-a-multi-tenant-whatsapp-automation-platform-using-n8n-and-waha-4jj4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I run WhatsApp automation workflows for 50+ businesses on shared infrastructure using n8n (queue mode), WAHA (unofficial WhatsApp Web API), Supabase, and Chatwoot. This is the technical deep-dive into how the multi-tenant architecture works, the problems I solved, and what it costs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I'm a solo automation engineer based in Israel. My clients are mostly small-to-medium businesses that need WhatsApp automation — appointment reminders, lead qualification, order confirmations, customer support bots. Each client has different workflows, different WhatsApp numbers, and different business logic.&lt;/p&gt;

&lt;p&gt;The naive approach is spinning up a separate n8n instance per client. That works for 3 clients. At 50+, you're managing 50 Docker stacks, 50 PostgreSQL databases, 50 sets of credentials. Updates become a nightmare. Monitoring becomes impossible.&lt;/p&gt;

&lt;p&gt;I needed a single n8n instance that could:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handle webhooks from 50+ WhatsApp sessions simultaneously&lt;/li&gt;
&lt;li&gt;Route messages to the correct workflow per client&lt;/li&gt;
&lt;li&gt;Not let one client's heavy traffic block another's&lt;/li&gt;
&lt;li&gt;Survive session disconnects and reconnections gracefully&lt;/li&gt;
&lt;li&gt;Cost less than $15/month per client in infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌─────────────────┐
                    │   WhatsApp Web   │
                    │  (50+ sessions)  │
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │      WAHA       │
                    │  (GOWS engine)  │
                    │  Docker + Redis │
                    └────────┬────────┘
                             │ webhooks
                    ┌────────▼────────┐
                    │     Caddy       │
                    │ (reverse proxy) │
                    └────────┬────────┘
                             │
              ┌──────────────▼──────────────┐
              │           n8n               │
              │  ┌─────────┐  ┌──────────┐  │
              │  │  Main   │  │  Worker   │  │
              │  │ Process │  │ (concur.  │  │
              │  │ (UI +   │  │   = 10)   │  │
              │  │ webhook)│  │           │  │
              │  └────┬────┘  └─────┬─────┘  │
              │       │             │         │
              │  ┌────▼─────────────▼────┐   │
              │  │        Redis          │   │
              │  │   (job queue + pub)   │   │
              │  └───────────────────────┘   │
              └──────────────┬───────────────┘
                             │
              ┌──────────────▼──────────────┐
              │        PostgreSQL 16        │
              │   (n8n workflows + data)    │
              └─────────────────────────────┘
                             │
              ┌──────────────▼──────────────┐
              │         Supabase            │
              │  (client data, CRM, logs)   │
              └─────────────────────────────┘
                             │
              ┌──────────────▼──────────────┐
              │         Chatwoot            │
              │  (customer support inbox)   │
              └─────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five containers run the core n8n stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;n8n&lt;/strong&gt; (main process) — handles the UI and receives webhooks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n-worker&lt;/strong&gt; — executes workflows, concurrency set to 10&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n-postgres&lt;/strong&gt; — PostgreSQL 16 for workflow storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n-redis&lt;/strong&gt; — Redis 7 for the job queue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;caddy&lt;/strong&gt; — reverse proxy with automatic HTTPS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WAHA runs separately with its own Redis instance, managing all WhatsApp Web sessions.&lt;/p&gt;




&lt;h2&gt;
  
  
  n8n Queue Mode: Why It Matters
&lt;/h2&gt;

&lt;p&gt;By default, n8n runs in "regular" mode — the same process that serves the UI also executes workflows. If a webhook comes in while a heavy workflow is running, the webhook handler blocks.&lt;/p&gt;

&lt;p&gt;Queue mode splits this into two processes:&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 (simplified)&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;n8n&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;n8nio/n8n:latest&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;EXECUTIONS_MODE=queue&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QUEUE_BULL_REDIS_HOST=n8n-redis&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QUEUE_BULL_REDIS_PORT=6379&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_TYPE=postgresdb&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_HOST=n8n-postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_DATABASE=n8n&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_USER=n8n&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;EXECUTIONS_DATA_MAX_AGE=168&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;EXECUTIONS_DATA_SAVE_ON_SUCCESS=all&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;n8n-net&lt;/span&gt;

  &lt;span class="na"&gt;n8n-worker&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;n8nio/n8n:latest&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&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;EXECUTIONS_MODE=queue&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QUEUE_BULL_REDIS_HOST=n8n-redis&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QUEUE_BULL_REDIS_PORT=6379&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QUEUE_HEALTH_CHECK_ACTIVE=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_TYPE=postgresdb&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_HOST=n8n-postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_DATABASE=n8n&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_USER=n8n&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;n8n-net&lt;/span&gt;

  &lt;span class="na"&gt;n8n-postgres&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;postgres:16-alpine&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;POSTGRES_DB=n8n&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=n8n&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${POSTGRES_PASSWORD}&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;n8n-db-data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;n8n-net&lt;/span&gt;

  &lt;span class="na"&gt;n8n-redis&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;redis:7-alpine&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;n8n-net&lt;/span&gt;

  &lt;span class="na"&gt;caddy&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;caddy:2-alpine&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;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&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;./Caddyfile:/etc/caddy/Caddyfile&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;n8n-net&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;n8n-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main process receives all webhooks and pushes jobs to the Redis queue. The worker picks them up with a concurrency of 10, meaning up to 10 workflows execute simultaneously. If more come in, they queue — no dropped messages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Execution Retention
&lt;/h3&gt;

&lt;p&gt;I keep 7 days of full execution history (&lt;code&gt;EXECUTIONS_DATA_MAX_AGE=168&lt;/code&gt; hours) with a hard cap at 50,000 executions. This is critical for debugging client issues. When a client says "the bot didn't respond yesterday at 3 PM," I can pull up the exact execution, see the input payload, and trace where it failed.&lt;/p&gt;




&lt;h2&gt;
  
  
  WAHA: The WhatsApp Gateway
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://waha.devlike.pro/" rel="noopener noreferrer"&gt;WAHA&lt;/a&gt; (WhatsApp HTTP API) is an unofficial, open-source WhatsApp Web API. It wraps the WhatsApp Web protocol in a REST API with webhook support. I use the &lt;strong&gt;GOWS engine&lt;/strong&gt; (Go-based, not the Node.js WEBJS engine) because it's significantly more stable for long-running sessions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Session Setup
&lt;/h3&gt;

&lt;p&gt;WAHA supports multiple WhatsApp sessions in a single container. Each client gets their own session, identified by a session name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start a new session for a client&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:3000/api/sessions/start &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-API-KEY: ${WAHA_API_KEY}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "client_acme_corp",
    "config": {
      "webhooks": [{
        "url": "https://n8n.example.com/webhook/waha-gateway",
        "events": [
          "message",
          "message.ack",
          "session.status"
        ]
      }]
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key detail: &lt;strong&gt;all sessions send webhooks to the same n8n endpoint.&lt;/strong&gt; The routing happens inside n8n, not at the WAHA level. This is deliberate — it means I can add a new client without touching the webhook infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session Persistence
&lt;/h3&gt;

&lt;p&gt;WAHA stores session data on disk. The Docker volume mapping is critical:&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;# WAHA docker-compose.yaml&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:latest&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=GOWS&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WHATSAPP_RESTART_ALL_SESSIONS=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;/opt/waha/sessions:/app/.sessions&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/waha/media:/app/.media&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WHATSAPP_RESTART_ALL_SESSIONS=true&lt;/code&gt; means that when the container restarts (deploy, crash, server reboot), all sessions automatically reconnect. Without this, you'd need to manually re-scan QR codes for 50+ phones.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/opt/waha/sessions&lt;/code&gt; directory contains the session auth data. &lt;strong&gt;Back this up.&lt;/strong&gt; If you lose it, every client needs to re-scan their QR code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Webhook Routing: The Heart of Multi-Tenancy
&lt;/h2&gt;

&lt;p&gt;Every incoming WhatsApp message hits the same webhook endpoint. The n8n workflow needs to figure out which client it belongs to and route it to the correct handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Gateway Workflow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// n8n Function node: "Route by Session"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;session&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Extract client identifier from session name&lt;/span&gt;
&lt;span class="c1"&gt;// Convention: "client_{slug}" -&amp;gt; slug is the routing key&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clientSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionName&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;client_&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="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Look up client config from Supabase&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clientConfig&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;$getWorkflowStaticData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;global&lt;/span&gt;&lt;span class="dl"&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;clientConfig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clientSlug&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// First time seeing this client in this execution context&lt;/span&gt;
  &lt;span class="c1"&gt;// Fetch from Supabase&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;helpers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;httpRequest&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="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/rest/v1/clients`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clientSlug&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="na"&gt;select&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="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="s1"&gt;apikey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_KEY&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;clientConfig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clientSlug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;clientConfig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clientSlug&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;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unknown session: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionName&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;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// Drop unknown sessions silently&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;client_slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clientSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;workflow_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active_workflow_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sessionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The routing key is the session name. When I onboard a new client, I:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a row in the Supabase &lt;code&gt;clients&lt;/code&gt; table with their config&lt;/li&gt;
&lt;li&gt;Start a WAHA session named &lt;code&gt;client_{slug}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Build their specific workflow in n8n&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;active_workflow_id&lt;/code&gt; in their client config&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The gateway workflow then calls the client's specific workflow using n8n's "Execute Workflow" node, passing the parsed message data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Not Separate Webhook Endpoints?
&lt;/h3&gt;

&lt;p&gt;I tried this first. Each client got their own webhook URL: &lt;code&gt;/webhook/client-acme&lt;/code&gt;, &lt;code&gt;/webhook/client-globex&lt;/code&gt;, etc. Problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;WAHA webhook config per session is fragile.&lt;/strong&gt; If you change the webhook URL, you need to restart the session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;50+ webhook endpoints in n8n are hard to manage.&lt;/strong&gt; Each one is a separate workflow trigger, and you can't easily see which are active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring is harder.&lt;/strong&gt; With a single gateway, I can log every incoming message in one place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The single-gateway pattern means all messages flow through one chokepoint, which sounds scary but is actually easier to monitor, rate-limit, and debug.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rate Limiting and Queue Management
&lt;/h2&gt;

&lt;p&gt;WhatsApp is aggressive about rate limiting and banning numbers that send too many messages too fast. The exact limits aren't documented (it's an unofficial API), but from experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New numbers:&lt;/strong&gt; ~200 messages/day before risk increases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warmed-up numbers:&lt;/strong&gt; ~1,000 messages/day is generally safe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burst limit:&lt;/strong&gt; No more than 30 messages per minute per number&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I implement rate limiting in n8n using a combination of Redis and workflow logic:&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;// n8n Function node: "Rate Limiter"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ioredis&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QUEUE_BULL_REDIS_HOST&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;sessionName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;session&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;minuteKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`ratelimit:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dayKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`ratelimit:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionName&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;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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;T&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Check minute limit&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minuteCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;minuteKey&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;minuteCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;minuteKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Check daily limit&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dayCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dayKey&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;dayCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dayKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&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;maxPerMinute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;client_config&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;max_per_minute&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;25&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;maxPerDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;client_config&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;max_per_day&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;800&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;minuteCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxPerMinute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Queue for delayed sending&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;json&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="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="na"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;delay_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dayCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxPerDay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Log and alert, don't send&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;json&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="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="na"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;daily_limit_exceeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The per-client limits are stored in Supabase and cached in the workflow's static data. New clients start with conservative limits; I increase them gradually as their number warms up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Queue Priority
&lt;/h3&gt;

&lt;p&gt;n8n's queue mode uses Bull (Redis-based job queue). All webhook-triggered workflows get the same priority by default. I haven't needed to implement priority queues because the worker concurrency of 10 handles the load — but if I needed to, Bull supports job priority natively.&lt;/p&gt;

&lt;p&gt;The more important optimization is &lt;strong&gt;keeping workflows fast.&lt;/strong&gt; A workflow that takes 500ms instead of 5s means the queue drains 10x faster. I aggressively use n8n's "Execute Workflow" node to break complex logic into small, focused sub-workflows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Template Message Management
&lt;/h2&gt;

&lt;p&gt;Most clients don't write their own message templates. They describe what they want ("send a reminder 24 hours before the appointment with the client's name and time"), and I build it.&lt;/p&gt;

&lt;p&gt;Templates are stored in Supabase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;message_templates&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;variables&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'he'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Example insert&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;message_templates&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'abc-123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'appointment_reminder'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'שלום {{name}}, תזכורת לתור שלך ב-{{date}} בשעה {{time}}. לאישור השב 1, לביטול השב 2.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'["name", "date", "time"]'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The n8n workflow fetches the template, interpolates variables, and sends via WAHA:&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;// n8n Function node: "Render Template"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;template&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;variables&lt;/span&gt; &lt;span class="o"&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;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&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="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&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;RegExp&lt;/span&gt;&lt;span class="p"&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="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&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="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;g&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;phone&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;body&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then an HTTP Request node sends it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST ${WAHA_URL}/api/sendText
Headers:
  X-API-KEY: ${WAHA_API_KEY}
Body:
{
  "session": "{{$json.session}}",
  "chatId": "{{$json.to}}@c.us",
  "text": "{{$json.body}}"
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Monitoring and Health Checks
&lt;/h2&gt;

&lt;p&gt;With 50+ bots running, things break silently. A WhatsApp session disconnects. A workflow errors out. Redis fills up. You need to know before the client calls you.&lt;/p&gt;

&lt;h3&gt;
  
  
  n8n Health Monitoring
&lt;/h3&gt;

&lt;p&gt;I run a monitoring script via cron every 5 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /opt/n8n-docker-caddy/scripts/monitor-n8n.sh&lt;/span&gt;

&lt;span class="nv"&gt;N8N_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:5678"&lt;/span&gt;
&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:5678/webhook/monitoring-alert"&lt;/span&gt;

&lt;span class="c"&gt;# Check n8n main process&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;N8N_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/healthz"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"alert": "n8n_main_down", "severity": "critical"}'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Check worker&lt;/span&gt;
&lt;span class="nv"&gt;WORKER_STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{.State.Health.Status}}'&lt;/span&gt; n8n-worker 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WORKER_STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"healthy"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"alert": "n8n_worker_unhealthy", "status": "'&lt;/span&gt;&lt;span class="nv"&gt;$WORKER_STATUS&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Check Redis memory&lt;/span&gt;
&lt;span class="nv"&gt;REDIS_MEMORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;n8n-redis redis-cli info memory | &lt;span class="nb"&gt;grep &lt;/span&gt;used_memory_human | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'[:space:]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# Alert if over 200MB&lt;/span&gt;
&lt;span class="nv"&gt;REDIS_MB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$REDIS_MEMORY&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/M//'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REDIS_MB&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt; 200"&lt;/span&gt; | bc &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"alert": "redis_memory_high", "usage": "'&lt;/span&gt;&lt;span class="nv"&gt;$REDIS_MEMORY&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Check PostgreSQL&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; docker &lt;span class="nb"&gt;exec &lt;/span&gt;n8n-postgres pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; n8n &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"alert": "postgres_down", "severity": "critical"}'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monitoring webhook triggers an n8n workflow that sends me a WhatsApp alert. Yes, I use my own platform to monitor my own platform. If the whole stack is down, the alert won't fire — but Hetzner's monitoring catches full server outages.&lt;/p&gt;

&lt;h3&gt;
  
  
  WAHA Session Monitoring
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// n8n Cron workflow: runs every 10 minutes&lt;/span&gt;
&lt;span class="c1"&gt;// HTTP Request to WAHA API&lt;/span&gt;
&lt;span class="c1"&gt;// GET ${WAHA_URL}/api/sessions&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;disconnected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WORKING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;client_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;disconnected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Try to restart disconnected sessions&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;disconnected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;helpers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;httpRequest&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="s1"&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;url&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WAHA_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/sessions/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/restart`&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="s1"&gt;X-API-KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WAHA_API_KEY&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;// Alert me&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sessions_disconnected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;disconnected&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;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto_restart_attempted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// All good, no output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Database Backup Strategy
&lt;/h2&gt;

&lt;p&gt;I learned this the hard way. n8n originally used SQLite, and I experienced data corruption that lost workflow execution history. I migrated to PostgreSQL 16 and implemented automated daily backups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /opt/n8n-docker-caddy/scripts/pg-backup.sh&lt;/span&gt;
&lt;span class="c"&gt;# Runs daily at 3:00 AM via cron&lt;/span&gt;

&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/n8n-backups"&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/n8n_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt;

&lt;span class="c"&gt;# Create backup using custom format (supports parallel restore)&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;n8n-postgres pg_dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-U&lt;/span&gt; n8n &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; n8n &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/backup.dump

&lt;span class="c"&gt;# Copy from container&lt;/span&gt;
docker &lt;span class="nb"&gt;cp &lt;/span&gt;n8n-postgres:/tmp/backup.dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Clean up old backups (keep 7 days)&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.dump"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +7 &lt;span class="nt"&gt;-delete&lt;/span&gt;

&lt;span class="c"&gt;# Verify backup is not empty&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BACKUP FAILED: Empty file"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Backup completed: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore when needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; n8n-postgres pg_restore &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-U&lt;/span&gt; n8n &lt;span class="nt"&gt;-d&lt;/span&gt; n8n &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--if-exists&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt; /opt/n8n-backups/n8n_20260320_030000.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Scaling Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Worker Concurrency Is Not "More Is Better"
&lt;/h3&gt;

&lt;p&gt;I started with &lt;code&gt;QUEUE_BULL_DEFAULT_JOB_OPTIONS_ATTEMPTS=3&lt;/code&gt; and concurrency at 20. Workflows started failing with database connection pool exhaustion. PostgreSQL's default &lt;code&gt;max_connections = 100&lt;/code&gt;, and each concurrent workflow execution holds a connection.&lt;/p&gt;

&lt;p&gt;Concurrency of 10 with the default connection pool is the sweet spot. If you need more throughput, add another worker container — don't increase concurrency on a single worker.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Static Data Is Your Friend (and Enemy)
&lt;/h3&gt;

&lt;p&gt;n8n's &lt;code&gt;$getWorkflowStaticData('global')&lt;/code&gt; persists data across executions in memory. I use it for caching client configs so I don't hit Supabase on every message. But it's per-process — the main process and the worker have different static data. And it's lost on restart.&lt;/p&gt;

&lt;p&gt;Pattern: use static data as a cache, always fall back to the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. WAHA Session Limits
&lt;/h3&gt;

&lt;p&gt;A single WAHA container comfortably handles 20-30 active sessions on a 4GB RAM server. Beyond that, I've seen memory pressure cause session disconnects. For 50+ sessions, either allocate more RAM or run multiple WAHA containers with session affinity.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. PostgreSQL Tuning
&lt;/h3&gt;

&lt;p&gt;Enable &lt;code&gt;pg_stat_statements&lt;/code&gt; for query performance tracking. n8n generates some heavy queries for execution history. The default &lt;code&gt;shared_buffers&lt;/code&gt; and &lt;code&gt;work_mem&lt;/code&gt; settings are fine for up to ~30,000 stored executions, but with the 50,000 cap I use, bumping &lt;code&gt;shared_buffers&lt;/code&gt; to 256MB made a noticeable difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Webhook Timeouts
&lt;/h3&gt;

&lt;p&gt;WAHA has a default webhook timeout. If your n8n webhook takes too long to respond (because the queue is full or the workflow is complex), WAHA will retry. This causes duplicate message processing.&lt;/p&gt;

&lt;p&gt;Solution: make the gateway workflow as fast as possible. It should only parse, route, and enqueue — never do the actual work inline. The heavy lifting happens asynchronously via "Execute Workflow."&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Breakdown
&lt;/h2&gt;

&lt;p&gt;Here's what it costs to run 50+ WhatsApp bots on shared infrastructure, hosted on Hetzner Cloud (Germany/Finland data centers):&lt;/p&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;Server Spec&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;n8n (queue mode)&lt;/td&gt;
&lt;td&gt;CPX31 (4 vCPU, 8GB RAM)&lt;/td&gt;
&lt;td&gt;~$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAHA&lt;/td&gt;
&lt;td&gt;CPX21 (3 vCPU, 4GB RAM)&lt;/td&gt;
&lt;td&gt;~$10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (self-hosted)&lt;/td&gt;
&lt;td&gt;CPX21 (3 vCPU, 4GB RAM)&lt;/td&gt;
&lt;td&gt;~$10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chatwoot&lt;/td&gt;
&lt;td&gt;CPX21 (3 vCPU, 4GB RAM)&lt;/td&gt;
&lt;td&gt;~$10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website + misc&lt;/td&gt;
&lt;td&gt;CX22 (2 vCPU, 4GB RAM)&lt;/td&gt;
&lt;td&gt;~$6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner daily backups&lt;/td&gt;
&lt;td&gt;All servers&lt;/td&gt;
&lt;td&gt;~$5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain + DNS&lt;/td&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;Free&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;/td&gt;
&lt;td&gt;&lt;strong&gt;~$56/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly &lt;strong&gt;$1.10 per bot per month&lt;/strong&gt; in infrastructure costs.&lt;/p&gt;

&lt;p&gt;For comparison, a managed WhatsApp Business API provider charges $50-200/month per number. Official BSP (Business Solution Provider) plans start at $100/month per number for basic messaging.&lt;/p&gt;

&lt;p&gt;The tradeoff: I'm using an unofficial API (WAHA wraps WhatsApp Web, not the official Business API). This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No green checkmark verification&lt;/li&gt;
&lt;li&gt;Risk of number bans if you abuse limits&lt;/li&gt;
&lt;li&gt;No official SLA&lt;/li&gt;
&lt;li&gt;But: no per-message fees, no approval process, and full control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For my clients — small businesses doing appointment reminders and customer support — this tradeoff makes sense. For enterprise-scale marketing campaigns, use the official API.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with PostgreSQL, not SQLite.&lt;/strong&gt; The migration was painful and I lost some data. n8n's SQLite mode is fine for personal use, not production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the monitoring first.&lt;/strong&gt; I added monitoring after the third time a session silently disconnected and I found out from an angry client.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Standardize session naming earlier.&lt;/strong&gt; I started with freeform names (&lt;code&gt;"johns-dental"&lt;/code&gt;, &lt;code&gt;"pizzaplace"&lt;/code&gt;) and later switched to &lt;code&gt;client_{slug}&lt;/code&gt;. Migrating was annoying.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use Supabase Edge Functions for the rate limiter&lt;/strong&gt; instead of doing it in n8n. The Redis-in-n8n approach works but is harder to test and maintain.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Stack Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Why This One&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://n8n.partnerlinks.io/achiya-automation" rel="noopener noreferrer"&gt;n8n&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Workflow automation engine&lt;/td&gt;
&lt;td&gt;Visual builder, self-hosted, queue mode, active community&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAHA&lt;/td&gt;
&lt;td&gt;WhatsApp Web API&lt;/td&gt;
&lt;td&gt;Multi-session, REST API, Go engine for stability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;Database + API&lt;/td&gt;
&lt;td&gt;PostgreSQL with instant REST API, row-level security&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chatwoot&lt;/td&gt;
&lt;td&gt;Customer support&lt;/td&gt;
&lt;td&gt;Open-source, WhatsApp inbox, agent assignment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caddy&lt;/td&gt;
&lt;td&gt;Reverse proxy&lt;/td&gt;
&lt;td&gt;Automatic HTTPS, simple config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner Cloud&lt;/td&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;European data centers, great price/performance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;If you're building something similar or have questions about the architecture, find me at &lt;a href="https://achiya-automation.com/en/" rel="noopener noreferrer"&gt;achiya-automation.com/en&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Achiya Cohen is an automation engineer specializing in WhatsApp automation and business process workflows. He runs &lt;a href="https://achiya-automation.com/en/" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt; from Israel, serving businesses with n8n-based automation solutions.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Over to You
&lt;/h2&gt;

&lt;p&gt;I want to hear from people running multi-tenant setups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At what client count did your "one instance per client" approach become unmanageable?&lt;/strong&gt; For me, it was around client #15 — Docker Compose files everywhere, each one slightly different, zero standardization.&lt;/p&gt;

&lt;p&gt;And here's the thing I &lt;em&gt;still&lt;/em&gt; haven't solved cleanly: &lt;strong&gt;database migrations across 50+ tenant schemas.&lt;/strong&gt; I've tried Flyway, custom n8n workflows, even bash scripts. None feel right. What's working for you?&lt;/p&gt;

&lt;p&gt;Drop your stack below — especially if you've found an elegant solution I missed. 👇&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>whatsapp</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Built an MCP Server for Safari Because Chrome Was Melting My MacBook</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 23 Mar 2026 10:37:34 +0000</pubDate>
      <link>https://forem.com/achiya-automation/i-built-an-mcp-server-for-safari-because-chrome-was-melting-my-macbook-2472</link>
      <guid>https://forem.com/achiya-automation/i-built-an-mcp-server-for-safari-because-chrome-was-melting-my-macbook-2472</guid>
      <description>&lt;p&gt;Last month I noticed something: every time I used Chrome DevTools MCP or Playwright MCP with Claude, my MacBook Pro fans would spin up. Activity Monitor showed Chrome eating 40-60% CPU just sitting there with a debug port open.&lt;/p&gt;

&lt;p&gt;I use Safari as my daily browser. All my logins are there — Gmail, GitHub, Ahrefs, AWS console. Why am I launching a &lt;em&gt;second&lt;/em&gt; browser just so an AI agent can click buttons?&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt; — a native MCP server that controls Safari directly via AppleScript. No Chrome. No Puppeteer. No headless anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture (It's Embarrassingly Simple)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI Agent (Claude, Cursor, etc.)
    ↓ MCP Protocol (stdio)
Safari MCP Server (Node.js)
    ↓ Persistent osascript process
AppleScript → Safari
    ↓ do JavaScript in tab N
Your real browser DOM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entire server is essentially two files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.js&lt;/code&gt; — MCP tool definitions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari.js&lt;/code&gt; — AppleScript + JavaScript bridge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key trick: instead of spawning a new &lt;code&gt;osascript&lt;/code&gt; process for every command (~80ms each), I keep a &lt;strong&gt;single persistent process&lt;/strong&gt; running. Commands go in via stdin, results come back via stdout. Result: &lt;strong&gt;~5ms per command&lt;/strong&gt; instead of 80ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  80 Tools — Here's What You Actually Get
&lt;/h2&gt;

&lt;p&gt;Not just &lt;code&gt;navigate&lt;/code&gt; and &lt;code&gt;click&lt;/code&gt;. Safari MCP has 80 tools across 20 categories:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The basics:&lt;/strong&gt; navigate, click, fill forms, screenshots, scroll, tabs&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The good stuff:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;safari_fill&lt;/code&gt; — works with React/Vue/Angular (uses native property setters, not just DOM &lt;code&gt;value&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_upload_file&lt;/code&gt; — uploads via JS DataTransfer, no file dialog popup&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_mock_route&lt;/code&gt; — intercept and mock fetch/XHR responses&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_accessibility_snapshot&lt;/code&gt; — full a11y tree with ARIA roles&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_extract_tables&lt;/code&gt; — tables as structured JSON&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_emulate&lt;/code&gt; — device emulation (iPhone, iPad, Pixel, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_run_script&lt;/code&gt; — batch multiple actions in a single call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The sneaky stuff:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;safari_start_network_capture&lt;/code&gt; + &lt;code&gt;safari_network_details&lt;/code&gt; — capture all fetch/XHR with headers and timing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_export_storage&lt;/code&gt; / &lt;code&gt;safari_import_storage&lt;/code&gt; — backup and restore entire browser sessions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_css_coverage&lt;/code&gt; — find unused CSS rules&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;safari_analyze_page&lt;/code&gt; — full page analysis in one call&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The React Form Problem (And How I Solved It)
&lt;/h2&gt;

&lt;p&gt;If you've ever tried to automate React forms, you know this doesn't work:&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;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello&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;React doesn't see it. The state doesn't update. The submit button stays disabled.&lt;/p&gt;

&lt;p&gt;Safari MCP uses the &lt;strong&gt;native input setter&lt;/strong&gt; approach:&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;nativeSetter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&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;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;nativeSetter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&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;element&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;element&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;change&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works with React, Vue, Angular, Svelte — anything that uses synthetic event listeners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Numbers: Safari MCP vs Chrome DevTools MCP
&lt;/h2&gt;

&lt;p&gt;I measured this on an M2 MacBook Pro running both servers simultaneously:&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;Safari MCP&lt;/th&gt;
&lt;th&gt;Chrome DevTools MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU (idle)&lt;/td&gt;
&lt;td&gt;~0.1%&lt;/td&gt;
&lt;td&gt;~8-15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU (active)&lt;/td&gt;
&lt;td&gt;~2-5%&lt;/td&gt;
&lt;td&gt;~25-40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;~30MB (Node process only)&lt;/td&gt;
&lt;td&gt;~200-400MB (Chrome + Node)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command latency&lt;/td&gt;
&lt;td&gt;~5ms&lt;/td&gt;
&lt;td&gt;~15-30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Startup time&lt;/td&gt;
&lt;td&gt;&amp;lt;1s&lt;/td&gt;
&lt;td&gt;3-5s (Chrome launch)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependencies&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Chrome browser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CPU difference is dramatic on laptops. Safari MCP basically doesn't show up in Activity Monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tradeoffs (I'm Being Honest)
&lt;/h2&gt;

&lt;p&gt;Safari MCP is &lt;strong&gt;not&lt;/strong&gt; a replacement for everything:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Safari MCP can't do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No Lighthouse audits (Chrome-only)&lt;/li&gt;
&lt;li&gt;No Performance traces / CPU profiling&lt;/li&gt;
&lt;li&gt;No cross-platform (macOS only, obviously)&lt;/li&gt;
&lt;li&gt;No headless mode (Safari is always "real")&lt;/li&gt;
&lt;li&gt;No CDP (Chrome DevTools Protocol) — we use AppleScript&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;My actual workflow:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Safari MCP for 95% of browser tasks (navigation, scraping, form filling, testing)&lt;/li&gt;
&lt;li&gt;Chrome DevTools MCP for the 5% that needs Lighthouse or Performance traces&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/achiya-automation/safari-mcp.git
&lt;span class="nb"&gt;cd &lt;/span&gt;safari-mcp
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable in Safari:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Safari → Settings → Advanced → &lt;strong&gt;Show features for web developers&lt;/strong&gt; ✓&lt;/li&gt;
&lt;li&gt;Safari → Develop → &lt;strong&gt;Allow JavaScript from Apple Events&lt;/strong&gt; ✓&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Add to your MCP config (Claude Code, Cursor, etc.):&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;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safari"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"/path/to/safari-mcp/index.js"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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;That's it. No Chrome to install, no debug ports to configure, no Playwright browsers to download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Open-Sourced It
&lt;/h2&gt;

&lt;p&gt;I built this for myself — I run an automation business and needed reliable browser control without the Chrome tax. But I figured if my MacBook was overheating, other Mac developers must be having the same problem.&lt;/p&gt;

&lt;p&gt;If you're on a Mac and tired of Chrome eating your battery for AI browser automation, give it a try:&lt;/p&gt;

&lt;p&gt;&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;/strong&gt;&lt;/p&gt;

&lt;p&gt;Star it if it saves your fans from spinning up. ⭐&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with AppleScript, JavaScript, and frustration with Chrome's CPU usage.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Over to You
&lt;/h2&gt;

&lt;p&gt;Here's a quick experiment: &lt;strong&gt;open Activity Monitor right now and check what Chrome is using.&lt;/strong&gt; I bet it's at least 30% CPU even with 3-4 tabs.&lt;/p&gt;

&lt;p&gt;I'm genuinely curious:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What browser automation tool are you using&lt;/strong&gt; — and what's your CPU usage during runs?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Has anyone tried MCP with Firefox&lt;/strong&gt; or another non-Chromium browser? I picked Safari because macOS, but the architecture works for anything with a JavaScript bridge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The repo has 4 open issues I haven't cracked yet — if any of them are your pain point, I'd love a PR or even just a "+1" so I know what to prioritize. 👇&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>safari</category>
      <category>macos</category>
      <category>discuss</category>
    </item>
    <item>
      <title>How to Build a WhatsApp Appointment Bot with n8n and Supabase (Step-by-Step)</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 22 Mar 2026 12:33:31 +0000</pubDate>
      <link>https://forem.com/achiya-automation/how-to-build-a-whatsapp-appointment-bot-with-n8n-and-supabase-step-by-step-5foi</link>
      <guid>https://forem.com/achiya-automation/how-to-build-a-whatsapp-appointment-bot-with-n8n-and-supabase-step-by-step-5foi</guid>
      <description>&lt;p&gt;Every small business owner I've worked with has the same pain point: appointment scheduling eats their day alive.&lt;/p&gt;

&lt;p&gt;They're stuck in an endless loop of WhatsApp messages. Multiply this by 20-30 conversations per day, and you've lost 3-4 hours before you've done any actual work.&lt;/p&gt;

&lt;p&gt;Here's how to build a WhatsApp bot that handles this automatically — using open-source tools, self-hosted, for under $30/month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WAHA&lt;/strong&gt; — Self-hosted unofficial WhatsApp API (connects to WhatsApp Web)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n8n&lt;/strong&gt; — Open-source workflow automation (handles all the logic)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; — PostgreSQL database (stores appointments, availability, customer data)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Set Up Your Database Schema
&lt;/h2&gt;

&lt;p&gt;In Supabase, create three tables: &lt;code&gt;availability&lt;/code&gt; (business hours/slots), &lt;code&gt;appointments&lt;/code&gt; (booked slots), and &lt;code&gt;conversation_state&lt;/code&gt; (tracks where each customer is in the booking flow).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why conversation state?&lt;/strong&gt; WhatsApp conversations are asynchronous. A customer might send "Tuesday" at 10am, then reply with a time at 2pm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Build the n8n Webhook Flow
&lt;/h2&gt;

&lt;p&gt;Create a workflow with a Webhook node as the trigger. WAHA sends incoming messages here. The core logic uses a Switch node to route by conversation state:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;What We Expect&lt;/th&gt;
&lt;th&gt;Next Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any message&lt;/td&gt;
&lt;td&gt;Show welcome + available days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;waiting_for_date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A date&lt;/td&gt;
&lt;td&gt;Show available times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;waiting_for_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A time&lt;/td&gt;
&lt;td&gt;Confirm booking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;waiting_for_confirm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"yes"/"no"&lt;/td&gt;
&lt;td&gt;Book or restart&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 3: Add Reminder Workflow
&lt;/h2&gt;

&lt;p&gt;Create a second n8n workflow with a Cron trigger that runs every hour, finds tomorrow's appointments, and sends WhatsApp reminders via WAHA.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Handle Edge Cases
&lt;/h2&gt;

&lt;p&gt;The difference between a frustrating bot and a helpful one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cancellation flow&lt;/strong&gt; — detect "cancel" keyword, free the slot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rescheduling&lt;/strong&gt; — cancel existing, restart booking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unrecognized input&lt;/strong&gt; — show menu of options&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost Breakdown
&lt;/h2&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;WAHA (self-hosted)&lt;/td&gt;
&lt;td&gt;$5 (VPS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n (self-hosted)&lt;/td&gt;
&lt;td&gt;$0 (same VPS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase (free tier)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPS (2GB RAM)&lt;/td&gt;
&lt;td&gt;~$10-15&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;~$15-20/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why Automated Reminders Matter
&lt;/h2&gt;

&lt;p&gt;No-shows are the silent killer for appointment-based businesses. Research consistently shows that automated reminders — especially via the channel customers already use (WhatsApp) — significantly reduce no-show rates. Studies in healthcare found &lt;a href="https://pubmed.ncbi.nlm.nih.gov/" rel="noopener noreferrer"&gt;40-60% reductions in missed appointments&lt;/a&gt; when using SMS/messaging reminders.&lt;/p&gt;

&lt;p&gt;The advantage of WhatsApp specifically is the read receipt — you know the reminder was seen, unlike email which might be ignored.&lt;/p&gt;

&lt;p&gt;The full code and &lt;a href="https://n8n.partnerlinks.io/achiya-automation" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; workflow templates are on my &lt;a href="https://github.com/achiya-automation" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Achiya Cohen, founder of &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt;. I build WhatsApp bots and workflow automation for small businesses using open-source tools like n8n.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Over to You
&lt;/h2&gt;

&lt;p&gt;Two questions for anyone running appointment systems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What's your no-show rate, and what's your reminder strategy?&lt;/strong&gt; I've been using 24h + 2h before the appointment, but some businesses swear by 48h + 4h. What works for your industry?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you allow rescheduling via bot, or only cancellation?&lt;/strong&gt; Rescheduling adds complexity but might reduce no-shows even further since the customer has an easy alternative to just not showing up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building something similar and got stuck on a specific step — drop it below and I'll help debug. 👇&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>whatsapp</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
