<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: אחיה כהן</title>
    <description>The latest articles on DEV Community by אחיה כהן (@achiya-automation).</description>
    <link>https://dev.to/achiya-automation</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810102%2Fefb43e59-992c-4f8b-91df-ee602c7c853f.jpg</url>
      <title>DEV Community: אחיה כהן</title>
      <link>https://dev.to/achiya-automation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/achiya-automation"/>
    <language>en</language>
    <item>
      <title>One command adds import, WhatsApp drip and campaigns to self-hosted Chatwoot</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 05 Jul 2026 13:12:46 +0000</pubDate>
      <link>https://dev.to/achiya-automation/one-command-adds-import-whatsapp-drip-and-campaigns-to-self-hosted-chatwoot-mkh</link>
      <guid>https://dev.to/achiya-automation/one-command-adds-import-whatsapp-drip-and-campaigns-to-self-hosted-chatwoot-mkh</guid>
      <description>&lt;p&gt;I run a self-hosted &lt;a href="https://www.chatwoot.com/" rel="noopener noreferrer"&gt;Chatwoot&lt;/a&gt; for support, and I genuinely love it. But every time I wanted to actually &lt;em&gt;grow&lt;/em&gt; on it, I hit the same wall.&lt;/p&gt;

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

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

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

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



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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;A couple more measured figures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;~1,167 workflows/second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-workflow overhead&lt;/td&gt;
&lt;td&gt;~30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle memory&lt;/td&gt;
&lt;td&gt;~48MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory vs. old stack&lt;/td&gt;
&lt;td&gt;~150MB vs ~1.4GB (~10×)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Here's the honest scorecard I dropped into my README:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;safari-mcp&lt;/th&gt;
&lt;th&gt;Apple &lt;code&gt;safaridriver --mcp&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your real logins / cookies&lt;/td&gt;
&lt;td&gt;✅ Your actual Safari&lt;/td&gt;
&lt;td&gt;⚠️ Isolated automation session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on&lt;/td&gt;
&lt;td&gt;✅ Stable Safari, every Mac&lt;/td&gt;
&lt;td&gt;❌ Technology Preview 247 only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background, no focus steal&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Dedicated automation window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;~17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookies / localStorage / IndexedDB&lt;/td&gt;
&lt;td&gt;✅ 10 tools&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network mocking + throttling&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Read-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official Apple support&lt;/td&gt;
&lt;td&gt;❌ Community, MIT&lt;/td&gt;
&lt;td&gt;✅ Apple, WebDriver-standard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;macosCompatNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;major&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;risky&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;macOS version: unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;risky&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;risky&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`macOS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ⚠ CGEvent native clicks/keys may silently no-op on `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`macOS 26+ even with Accessibility granted (issue #29) — for trust-gated `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`forms prefer JS evaluation or extension-based clicks.`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`macOS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — CGEvent native input supported.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;productVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;risky&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;doctor&lt;/code&gt; calls it as &lt;em&gt;best-effort&lt;/em&gt; — &lt;code&gt;sw_vers&lt;/code&gt; is macOS-only and absent in CI sandboxes, so it's wrapped in a &lt;code&gt;try/catch&lt;/code&gt; that can never block the rest of the diagnostics:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;This is the cleanest example of "self-hosted only." Chatwoot exposes branding through environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Replaces the product name across the dashboard and system emails&lt;/span&gt;
&lt;span class="nv"&gt;INSTALLATION_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Acme Support"&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside your embedded dashboard app&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Chatwoot posts the conversation + contact context here&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;appContext&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// data.data.conversation and data.data.contact are available&lt;/span&gt;
      &lt;span class="nf"&gt;renderCustomerPanel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ignore non-Chatwoot messages */&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your agents see &lt;em&gt;your&lt;/em&gt; tooling — order history, a subscription manager, internal notes from another system — right next to the chat, without leaving Chatwoot. On a closed SaaS you'd be waiting for an official integration or a marketplace app. Here you build the panel you actually want and point Chatwoot at it.&lt;/p&gt;

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

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

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

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

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

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

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

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

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




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

</description>
      <category>chatwoot</category>
      <category>opensource</category>
      <category>selfhosted</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>My README said 80 tools. My code had 96. Nobody noticed for weeks.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 22 Jun 2026 06:24:41 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-readme-said-80-tools-my-code-had-96-nobody-noticed-for-weeks-1f3e</link>
      <guid>https://dev.to/achiya-automation/my-readme-said-80-tools-my-code-had-96-nobody-noticed-for-weeks-1f3e</guid>
      <description>&lt;p&gt;I run an open-source MCP server. It exposes browser-automation tools to AI coding agents — click this, read that page, fill this form. The README has a big proud table: "80 tools." It's in the tagline. It's in the nav anchor. It's in the alt text of the social-preview image. It's in two comparison tables. It's even in the pre-written tweet text for the share button.&lt;/p&gt;

&lt;p&gt;Eighty. Eighty everywhere.&lt;/p&gt;

&lt;p&gt;Last week I ran a boring audit — just diffing what the README &lt;em&gt;claims&lt;/em&gt; against what the code actually &lt;code&gt;register&lt;/code&gt;s at startup. The code registered &lt;strong&gt;96&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not 80. Ninety-six. The number had been wrong in nine different places, and I'd been the one typing it each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a number rots
&lt;/h2&gt;

&lt;p&gt;Nobody decides to lie in their README. Drift happens one merge at a time.&lt;/p&gt;

&lt;p&gt;You ship a feature. It adds three tools. You update the tool &lt;em&gt;list&lt;/em&gt; (because that's the part reviewers look at) but you forget the count in the tagline. Next release adds four more. Now the list says one thing and the tagline says another, and both are behind reality. A month of "small, fast" releases later, the gap is sixteen.&lt;/p&gt;

&lt;p&gt;The worst part wasn't even the tagline. When I actually counted the entries in the tool &lt;em&gt;list itself&lt;/em&gt;, it documented &lt;strong&gt;83&lt;/strong&gt; tools. So I had three different truths living in one file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The marketing number: &lt;strong&gt;80&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The documented list: &lt;strong&gt;83&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The code: &lt;strong&gt;96&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thirteen tools existed, worked, shipped to npm, and ran on real users' machines — completely undocumented. They weren't in the README at all. If you only read my docs, you didn't know they existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools you forget are the ones that matter
&lt;/h2&gt;

&lt;p&gt;Here's the part that turned a docs cleanup into a genuinely uncomfortable afternoon.&lt;/p&gt;

&lt;p&gt;I expected the undocumented thirteen to be boring helpers — the &lt;code&gt;safari_wait_for_new_tab&lt;/code&gt;s of the world. Some were. But four of them were the &lt;strong&gt;native input&lt;/strong&gt; tools: synthetic keyboard and mouse events driven through the OS-level event API (CGEvent), not through JavaScript injected into the page.&lt;/p&gt;

&lt;p&gt;Those are the &lt;em&gt;most powerful&lt;/em&gt; tools in the whole project. JavaScript-level automation is sandboxed by the page; OS-level input is not. It types into anything that has focus. It's the difference between "fill this &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt;" and "press these physical-looking keys at the system level." It's exactly the category a security-conscious user would want to read about before granting Accessibility permissions.&lt;/p&gt;

&lt;p&gt;And it was the category I'd never written down.&lt;/p&gt;

&lt;p&gt;That's the real lesson, and it's not "keep your docs updated." It's this: &lt;strong&gt;documentation drift is selection-biased toward the things you'd least want undocumented.&lt;/strong&gt; The tools you forget to document are the ones added in a hurry, in a feature branch, under a deadline — which correlates almost perfectly with the tools that are powerful, sharp, or security-relevant. The mundane stuff gets documented because it's easy. The dangerous stuff gets a TODO.&lt;/p&gt;

&lt;p&gt;If you want to find the riskiest, least-reviewed surface of any project, don't read the docs. Read the gap between the docs and the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just update the README" is the wrong fix
&lt;/h2&gt;

&lt;p&gt;The tempting fix is to fix the number. Change 80 → 96, add the thirteen missing entries, commit, done. I did that part. But it's treating the symptom.&lt;/p&gt;

&lt;p&gt;The number was wrong because it was &lt;em&gt;hand-maintained&lt;/em&gt;. Any fact a human types by hand will eventually disagree with reality. The only durable fix is to make the fact impossible to get wrong — derive it from the source of truth instead of restating it.&lt;/p&gt;

&lt;p&gt;The irony: my project already had this solved, in one place. The smoke test — contributed by someone else, not me — boots the server over a real stdio transport, asks it how many tools it registered, and asserts against a count &lt;strong&gt;derived from the source&lt;/strong&gt;. No hardcoded number. When you add a tool, the test's expectation moves with it automatically. That test could never have drifted to 80, because it never &lt;em&gt;stored&lt;/em&gt; an 80 to drift from.&lt;/p&gt;

&lt;p&gt;So the fix isn't "be more disciplined about the README." Discipline is what failed for a month straight. The fix is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Counts&lt;/strong&gt; → generate them. A tiny script that reads the registrations and writes the number into the README at build time. The human never types it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool lists&lt;/strong&gt; → generate them too, ideally from the same registry the server reads at startup. The schema is already structured data. Rendering it as a Markdown table is a formatting problem, not a writing problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prose around them&lt;/strong&gt; → that's the part humans should actually spend their attention on, because it's the part a generator can't write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every fact in your docs is either derivable or it isn't. Derivable facts should never be typed by hand. They are drift waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that takes thirty seconds
&lt;/h2&gt;

&lt;p&gt;If you maintain anything with a "we have N features / N tools / N integrations" claim, here's the audit. It took me one command:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count the real thing in code (registrations, exported functions, route handlers — whatever your N actually counts).&lt;/li&gt;
&lt;li&gt;Count what your README claims.&lt;/li&gt;
&lt;li&gt;If they disagree, you don't just have a stale number. You have a list of things that exist and that nobody chose to write down. Go read &lt;em&gt;that&lt;/em&gt; list. It's the most interesting list in your repo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I found mine had grown to a sixteen-tool gap, with the project's sharpest tools sitting inside it. Yours might be smaller. But "we never check" and "the number is correct" are not the same state, and the only way to tell them apart is to look.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is from maintaining &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt;, an open-source Safari automation server for AI agents on macOS (the one that now correctly says 96). I write up the unglamorous parts of running a small OSS project at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the widest doc-vs-code gap you've ever found in your own project — and was the stuff in the gap boring, or was it the scary stuff?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>documentation</category>
      <category>devtools</category>
      <category>ai</category>
    </item>
    <item>
      <title>A rival to my open-source tool shipped. I read all of it — then ported its 4 best ideas the same afternoon.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 18 Jun 2026 16:40:24 +0000</pubDate>
      <link>https://dev.to/achiya-automation/a-rival-to-my-open-source-tool-shipped-i-read-all-of-it-then-ported-its-4-best-ideas-the-same-1nbe</link>
      <guid>https://dev.to/achiya-automation/a-rival-to-my-open-source-tool-shipped-i-read-all-of-it-then-ported-its-4-best-ideas-the-same-1nbe</guid>
      <description>&lt;p&gt;A new MCP server showed up in the official Model Context Protocol registry last week, three slots above mine: &lt;strong&gt;&lt;code&gt;safari-devtools-mcp&lt;/code&gt;&lt;/strong&gt;. Same platform (macOS), same browser (Safari), same audience (AI coding agents). My first reaction was the honest one — a small jolt of &lt;em&gt;oh no&lt;/em&gt;. My second reaction was more useful: I cloned it and read the entire thing.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;Safari MCP&lt;/a&gt; — a browser-automation server that drives your &lt;strong&gt;real, logged-in Safari&lt;/strong&gt; through AppleScript and a native extension. No Chromium, no headless, no second browser melting your fan. So a competitor called "Safari DevTools MCP" is squarely in my lane.&lt;/p&gt;

&lt;p&gt;Here's what I found, what I deliberately &lt;em&gt;didn't&lt;/em&gt; copy, and the four tools I shipped into my own server before dinner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural fork in the road
&lt;/h2&gt;

&lt;p&gt;The very first line of its &lt;code&gt;package.json&lt;/code&gt; told me most of the story:&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="nl"&gt;"dependencies"&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;"selenium-webdriver"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@modelcontextprotocol/sdk"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"zod"&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="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;It drives Safari through &lt;strong&gt;&lt;code&gt;safaridriver&lt;/code&gt;&lt;/strong&gt; — Apple's official WebDriver. That's a legitimate, well-supported choice. But it's the exact choice my project exists to avoid. A WebDriver session launches a &lt;em&gt;clean, isolated&lt;/em&gt; automation instance of Safari: no cookies, no logins, no sessions, a "Safari is controlled by automation" banner across the top. It's the headless-browser problem wearing a Safari costume.&lt;/p&gt;

&lt;p&gt;Safari MCP does the opposite: it talks to the Safari you already have open, with all your auth intact, and never steals your foreground.&lt;/p&gt;

&lt;p&gt;So I wasn't going to rip out my engine. &lt;strong&gt;Architecture is a position, not a feature.&lt;/strong&gt; Copying it would erase the entire reason my tool exists.&lt;/p&gt;

&lt;p&gt;But tools are a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diffing 48 tools against my 91
&lt;/h2&gt;

&lt;p&gt;I dumped both tool lists and diff'd them. ~44 of its 48 tools mapped cleanly onto something I already had — &lt;code&gt;click&lt;/code&gt;, &lt;code&gt;fill&lt;/code&gt;, &lt;code&gt;screenshot&lt;/code&gt;, &lt;code&gt;get_cookies&lt;/code&gt;, network capture, the usual surface area.&lt;/p&gt;

&lt;p&gt;Then there was a cluster of four that I had &lt;em&gt;nothing&lt;/em&gt; equivalent to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;inspect_viewport_meta&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;get_safe_area_insets&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;check_ios_web_app_readiness&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;check_webkit_compatibility&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a genuinely smart niche: &lt;strong&gt;iOS-Safari web-dev validation&lt;/strong&gt;. The stuff every mobile web developer fights with — the notch, the viewport meta tag, "why won't my PWA add to the home screen," and Safari's long tail of CSS quirks. My server could automate Safari all day but couldn't answer any of those questions.&lt;/p&gt;

&lt;p&gt;And here's the part that made it a no-brainer: &lt;strong&gt;all four are pure JavaScript inspection.&lt;/strong&gt; No WebDriver capability, no protocol magic — just &lt;code&gt;document.querySelector&lt;/code&gt;, &lt;code&gt;getComputedStyle&lt;/code&gt;, and &lt;code&gt;CSS.supports()&lt;/code&gt; run inside the page. Which means they port directly onto my AppleScript &lt;code&gt;do JavaScript&lt;/code&gt; engine. The competitor's &lt;em&gt;architecture&lt;/em&gt; wasn't portable. Its &lt;em&gt;best ideas&lt;/em&gt; were.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I like most: compatibility checking with zero false positives
&lt;/h2&gt;

&lt;p&gt;Most "is this CSS supported in Safari?" tools work off a static database (think caniuse). They go stale, and they can't see your actual Safari version.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check_webkit_compatibility&lt;/code&gt; does something better. It walks every stylesheet on the page, pulls each &lt;code&gt;property: value&lt;/code&gt; pair via the structured CSSOM (no regex — so custom properties don't create false positives), and then asks the &lt;strong&gt;live browser&lt;/strong&gt; the only question that matters:&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;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;property&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="c1"&gt;// tested in THIS Safari, right now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it fails unprefixed, it retries with &lt;code&gt;-webkit-&lt;/code&gt;. If &lt;em&gt;that&lt;/em&gt; works, it tells you to add the prefix. If neither works, it's genuinely unsupported here. Then it layers on a tiny hand-curated list of behavioral quirks &lt;code&gt;CSS.supports()&lt;/code&gt; can't catch — like the classic:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;position: sticky&lt;/code&gt; silently fails inside an &lt;code&gt;overflow: hidden&lt;/code&gt;/&lt;code&gt;auto&lt;/code&gt; ancestor. Use &lt;code&gt;overflow: clip&lt;/code&gt; instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I reimplemented it as a synchronous IIFE returning JSON (my engine can't &lt;code&gt;await&lt;/code&gt; inside &lt;code&gt;do JavaScript&lt;/code&gt; — a &lt;code&gt;[object Promise]&lt;/code&gt; lesson I've &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;written about before&lt;/a&gt;), wired it into a &lt;code&gt;safari_webkit_compat&lt;/code&gt; tool, and tested it against a live page with a deliberately sticky header:&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;"totalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&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;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quirks"&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;"position:sticky silently fails inside an overflow:hidden/auto ancestor…"&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;Caught it. The other three — &lt;code&gt;safari_inspect_viewport&lt;/code&gt;, &lt;code&gt;safari_safe_area_insets&lt;/code&gt;, &lt;code&gt;safari_check_pwa&lt;/code&gt; — went in the same way, each verified against a controlled DOM in real Safari before I trusted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took, and what competition is actually for
&lt;/h2&gt;

&lt;p&gt;I ported four ideas. I rewrote every line to fit my engine and my conventions, credited the inspiration, and skipped the parts that conflicted with what my project &lt;em&gt;is&lt;/em&gt;. That feels like the honest version of "competition makes everything better" — not a press-release platitude, but a real afternoon of reading someone else's careful work and being better for it.&lt;/p&gt;

&lt;p&gt;A rival didn't make my tool worse. It handed me a roadmap for a category — iOS web-dev validation — I hadn't even thought to cover.&lt;/p&gt;

&lt;p&gt;If you're building on macOS and want an agent that drives your &lt;em&gt;actual&lt;/em&gt; Safari (logged in, no headless), it's one line: &lt;code&gt;npx safari-mcp&lt;/code&gt;. The four new validators shipped in &lt;strong&gt;v2.14.0&lt;/strong&gt; (out now). More at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question for the room:&lt;/strong&gt; when a competitor ships, do you read their code? I used to skip it out of some weird pride. I don't anymore. Where do you land — study it closely, or deliberately look away to protect your own taste?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>safari</category>
      <category>ios</category>
    </item>
    <item>
      <title>Make.com has an MCP endpoint now. The auth token goes in the URL — and 3 other walls the docs skip</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 14 Jun 2026 16:26:58 +0000</pubDate>
      <link>https://dev.to/achiya-automation/makecom-has-an-mcp-endpoint-now-the-auth-token-goes-in-the-url-and-3-other-walls-the-docs-skip-1el2</link>
      <guid>https://dev.to/achiya-automation/makecom-has-an-mcp-endpoint-now-the-auth-token-goes-in-the-url-and-3-other-walls-the-docs-skip-1el2</guid>
      <description>&lt;p&gt;I already let my AI agent read and repair my self-hosted n8n workflows. So when a client's Make.com scenario started misfiring — duplicate WhatsApp order notifications, fired in bursts — my first instinct wasn't to log into the web UI. It was: &lt;em&gt;can I give the agent the same remote access to Make that it has to n8n?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Make.com shipped both a REST API and an MCP endpoint. So yes. But getting from "they have an API" to "my agent is connected and patching a live scenario blueprint" meant walking into four undocumented walls. Here they are, in the order they hit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 1: The API is behind Cloudflare, and it hates your User-Agent
&lt;/h2&gt;

&lt;p&gt;My first call was a throwaway Python script — &lt;code&gt;urllib.request&lt;/code&gt; to &lt;code&gt;GET /api/v2/users/me&lt;/code&gt;. It came back &lt;strong&gt;403&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not 401 (bad auth). 403. The token was fine. The request never reached Make's application layer — Cloudflare's WAF bounced it on the User-Agent. The default &lt;code&gt;Python-urllib/3.x&lt;/code&gt; UA is on a block list.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous: use &lt;code&gt;curl&lt;/code&gt; (whose UA sails through) and pipe the body into Python only for parsing.&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;T&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;security find-generic-password &lt;span class="nt"&gt;-s&lt;/span&gt; make-api-token &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &lt;/span&gt;&lt;span class="nv"&gt;$T&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://eu2.make.com/api/v2/users/me"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting even in this tiny snippet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auth is &lt;code&gt;Authorization: Token &amp;lt;token&amp;gt;&lt;/code&gt; — &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;Bearer&lt;/code&gt;. (Hold that thought; it comes back.)&lt;/li&gt;
&lt;li&gt;The token never gets typed or echoed. It lives in the macOS Keychain and is read at call time. Secrets in shell history are secrets you've already leaked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wall 2: Listing scenarios needs a teamId, not an organizationId
&lt;/h2&gt;

&lt;p&gt;The object hierarchy is Organization → Team → Scenario. Natural assumption: list scenarios by the org you're in.&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;GET /api/v2/scenarios?organizationId=&amp;lt;org&amp;gt;   → not what you want
GET /api/v2/scenarios?teamId=&amp;lt;team&amp;gt;          → ✅
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/scenarios&lt;/code&gt; is scoped to a &lt;strong&gt;team&lt;/strong&gt;, not an organization. So the real discovery sequence is:&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;GET /organizations
GET /teams?organizationId=&amp;lt;org&amp;gt;
GET /scenarios?teamId=&amp;lt;team&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip the middle call and you'll spend ten minutes convinced your token lacks scope, when it's just keyed on the wrong ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 3: The blueprint JSON won't parse — and it's not your code
&lt;/h2&gt;

&lt;p&gt;The whole point was to &lt;em&gt;fix&lt;/em&gt; the scenario, and on Make the editable definition is the blueprint:&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;GET   /api/v2/scenarios/{id}/blueprint     # read it
PATCH /api/v2/scenarios/{id}               # write it back, blueprint as a JSON string
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pulled the blueprint, piped it through &lt;code&gt;jq&lt;/code&gt;, and got a parse error. The JSON was, by the spec, invalid.&lt;/p&gt;

&lt;p&gt;The cause: the scenario sends Hebrew WhatsApp messages, and the message templates contain &lt;strong&gt;raw, unescaped newline characters&lt;/strong&gt; inside string values. Strict JSON forbids literal control characters in strings; Make emits them anyway.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;jq&lt;/code&gt; refuses. So does Python's default &lt;code&gt;json.loads&lt;/code&gt;. The escape hatch is one keyword:&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;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="n"&gt;blueprint&lt;/span&gt; &lt;span class="o"&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# tolerate raw control chars
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;strict=False&lt;/code&gt; tells the parser to accept the control characters instead of throwing. Once it's a Python object you can edit the module you care about, re-serialize, and PATCH it back as a string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 4: The MCP endpoint 404s on a Bearer header
&lt;/h2&gt;

&lt;p&gt;This was the one that cost me the most time, because everything &lt;em&gt;looked&lt;/em&gt; right.&lt;/p&gt;

&lt;p&gt;Make exposes an MCP server. I added it to my agent the way I add every other remote MCP — URL plus an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header. &lt;strong&gt;404.&lt;/strong&gt; Not 401. 404, as if the endpoint didn't exist.&lt;/p&gt;

&lt;p&gt;It doesn't — not at the path I was using. The Make MCP doesn't authenticate via header at all. The token goes &lt;strong&gt;in the URL path&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://eu2.make.com/mcp/u/&amp;lt;token&amp;gt;/stateless
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Header auth on &lt;code&gt;/mcp&lt;/code&gt;: 404. Token baked into the path on &lt;code&gt;/mcp/u/&amp;lt;token&amp;gt;/stateless&lt;/code&gt;: 200, connected.&lt;/p&gt;

&lt;p&gt;That's an awkward shape if you care about not pasting secrets into config files — a URL with a live token in it is exactly the kind of string that ends up committed. The way out is to assemble the URL at launch time from the Keychain, so the token is never written to disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// MCP server entry&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;"bash"&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="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"-c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"exec mcp-remote &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;https://eu2.make.com/mcp/u/$(security find-generic-password -s make-api-token -a $USER -w)/stateless&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;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;mcp-remote&lt;/code&gt; bridges the streamable HTTP endpoint to a local stdio MCP server. The &lt;code&gt;$(...)&lt;/code&gt; runs every launch, so the config on disk contains a Keychain lookup, never the token itself. Same pattern I use for other path-token services.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Four walls — a WAF User-Agent block, a team-vs-org ID, control characters in "valid" JSON, and path-based MCP auth — and on the other side the agent can do, remotely and unattended, what used to mean opening the Make UI and clicking through a scenario by hand: list orgs and teams, pull a misbehaving scenario's blueprint, diff it, and PATCH the fix.&lt;/p&gt;

&lt;p&gt;None of these are in the quickstart. All four are the difference between "they have an API" and "it actually works." If you're wiring an AI agent into Make.com, start with &lt;code&gt;curl&lt;/code&gt; (not your HTTP library), scope &lt;code&gt;/scenarios&lt;/code&gt; by team, parse blueprints with &lt;code&gt;strict=False&lt;/code&gt;, and put the MCP token in the path.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build WhatsApp bots and business automation for companies — self-hosted n8n, Make.com, WAHA, the works. More war stories and guides at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>automation</category>
      <category>api</category>
      <category>ai</category>
    </item>
    <item>
      <title>My safety guard protected 2 tools and trusted the other 20</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 14 Jun 2026 15:36:54 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-safety-guard-protected-2-tools-and-trusted-the-other-20-5890</link>
      <guid>https://dev.to/achiya-automation/my-safety-guard-protected-2-tools-and-trusted-the-other-20-5890</guid>
      <description>&lt;p&gt;I maintain an MCP server that lets a coding agent drive &lt;strong&gt;your real, logged-in Safari&lt;/strong&gt; — the same browser where your bank, your email, and your half-written Slack messages live. The whole premise only works if there's one ironclad rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The agent may only touch tabs &lt;strong&gt;it&lt;/strong&gt; opened. Never yours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wrote that guard early. It was a small function: before any page-mutating action, check that the target tab is one the agent owns. I dropped it into the wrapper that &lt;code&gt;safari_click&lt;/code&gt; and &lt;code&gt;safari_fill&lt;/code&gt; both flow through, watched the two of them refuse to act on an unowned tab, and moved on feeling responsible.&lt;/p&gt;

&lt;p&gt;It took three separate audits to discover that the guard was, for most of its life, decorative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: the guard was in a place almost nothing went through
&lt;/h2&gt;

&lt;p&gt;The wrapper I'd guarded — call it &lt;code&gt;extensionOrFallback&lt;/code&gt; — was the path for &lt;code&gt;click&lt;/code&gt; and &lt;code&gt;fill&lt;/code&gt;. It was &lt;em&gt;not&lt;/em&gt; the path for the other twenty-ish tools that mutate a page.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;safari_set_cookie&lt;/code&gt;. &lt;code&gt;safari_delete_cookies&lt;/code&gt;. Every local-storage and session-storage writer. &lt;code&gt;safari_import_storage&lt;/code&gt;. &lt;code&gt;safari_drag&lt;/code&gt;. &lt;code&gt;safari_upload_file&lt;/code&gt;. &lt;code&gt;safari_paste_image&lt;/code&gt;. &lt;code&gt;safari_select_option&lt;/code&gt;. &lt;code&gt;safari_mock_route&lt;/code&gt;. &lt;code&gt;safari_throttle_network&lt;/code&gt;. &lt;code&gt;safari_override_geolocation&lt;/code&gt;. &lt;code&gt;safari_handle_dialog&lt;/code&gt;. &lt;code&gt;safari_resize&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every one of them called the engine &lt;strong&gt;directly&lt;/strong&gt;. None of them touched the wrapper. So the ownership check — the entire safety story of the project — applied to two tools and was silently absent from the rest. An agent that got confused about which tab was in front could write a cookie or dump localStorage into &lt;em&gt;your&lt;/em&gt; logged-in session, and nothing would stop it.&lt;/p&gt;

&lt;p&gt;The fix was conceptually trivial (extract the check into &lt;code&gt;_assertTabOwnership()&lt;/code&gt; and call it first in all of them) and that triviality is exactly the point. &lt;strong&gt;A guard living in one convenient wrapper is not a guard. It's a guard-shaped object that happens to sit on the two code paths you tested.&lt;/strong&gt; The real invariant is "every path that mutates a page calls the check," and a wrapper can only ever promise that for the paths that go through the wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: the batch tool had an escape hatch
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;safari_run_script&lt;/code&gt; runs a batch of steps — navigate, click, fill, evaluate — in one call. It did check ownership… once, pre-flight, before the batch started.&lt;/p&gt;

&lt;p&gt;A single pre-flight check on a batch that can &lt;em&gt;move between tabs&lt;/em&gt; is not a check. Two holes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;evaluate&lt;/code&gt; was exempt.&lt;/strong&gt; You could run arbitrary JavaScript in an unowned tab simply by wrapping it in a batch instead of calling it standalone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;switchTab&lt;/code&gt; or &lt;code&gt;navigate&lt;/code&gt; step mid-batch could walk onto your tab&lt;/strong&gt;, and the &lt;em&gt;next&lt;/em&gt; step — a &lt;code&gt;click&lt;/code&gt;, say — would land there, because the only gate was at the door, not on each step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now ownership is enforced &lt;strong&gt;per step&lt;/strong&gt; while the batch runs. A &lt;code&gt;navigate&lt;/code&gt; step registers ownership of its destination exactly like the standalone tool does, and any refused step aborts the whole batch instead of letting the rest proceed on freshly-stolen ground.&lt;/p&gt;

&lt;p&gt;The lesson rhymes with Round 1: I'd put the check at the &lt;em&gt;start of the operation&lt;/em&gt; when the dangerous thing was the &lt;em&gt;transition between steps&lt;/em&gt;. Guards belong on the state change, not on the entrance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: the state itself was lying
&lt;/h2&gt;

&lt;p&gt;The third audit was the unsettling one, because the guard was now &lt;em&gt;called&lt;/em&gt; everywhere — and could still be wrong, because the data it consulted was rotten in three different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/org&lt;/code&gt; owned &lt;code&gt;/org-evil&lt;/code&gt;.&lt;/strong&gt; Ownership was matched with a path-prefix test and no segment boundary. If the agent legitimately owned &lt;code&gt;https://site.com/org&lt;/code&gt;, my matcher cheerfully concluded it also owned &lt;code&gt;https://site.com/org-evil&lt;/code&gt; on the same origin. A string prefix is not a security boundary; I had quietly treated it as one. Matching now requires a real &lt;code&gt;/&lt;/code&gt; boundary, and — because this is precisely the kind of rule that rots silently — I pulled the whole matching/TTL semantics out into a pure &lt;code&gt;ownership-match.js&lt;/code&gt; module with a unit suite that locks the &lt;code&gt;/org&lt;/code&gt; vs &lt;code&gt;/org-evil&lt;/code&gt; case in place forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The TTL leaked ownership across sessions.&lt;/strong&gt; Every time the ownership file was saved, it rewrote each entry's timestamp to &lt;em&gt;now&lt;/em&gt;. The 30-minute expiry could therefore never fire on a long-lived session — owned URLs accumulated indefinitely, potentially carrying a stale claim onto a tab that was now yours. Original timestamps are preserved now, and expiry is enforced on use, not just at load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A worker restart failed open.&lt;/strong&gt; The browser extension is MV3, so its service worker is killed and restarted at the platform's whim. When it restarted, the in-memory map of owned tabs was wiped — and the code interpreted "no tabs owned yet" as the &lt;em&gt;startup&lt;/em&gt; compatibility path, which is permissive by design. So a routine worker restart silently disabled the guard entirely until the next &lt;code&gt;new_tab&lt;/code&gt;. The owned-tab state now survives restarts in &lt;code&gt;storage.session&lt;/code&gt;. &lt;strong&gt;State that resets must fail closed.&lt;/strong&gt; Mine had been failing open, in the one direction that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed about how I work
&lt;/h2&gt;

&lt;p&gt;The code fixes are in the changelog. The habits are the takeaway:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Guard the chokepoint, or guard every call site — never the convenient wrapper.&lt;/strong&gt; If you can't put the check on a single line every path provably crosses, you have to put it on every path. There's no third option that's safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Owns" is a security boundary; &lt;code&gt;startsWith&lt;/code&gt; is not.&lt;/strong&gt; Any time a string comparison stands in for an authorization decision, assume it's wrong at the boundary and write the test that proves it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what your state does when it resets.&lt;/strong&gt; Caches get cleared, workers get killed, files get reloaded. The only question is whether that reset opens the gate or closes it. Pick on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract the security-relevant logic into something a test can pin.&lt;/strong&gt; The matching rules lived inline, tangled with I/O, untestable — which is &lt;em&gt;why&lt;/em&gt; the boundary bug survived. Pure module + unit suite turned an invisible invariant into a red build.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are clever. They're the boring discipline that the exciting "give your AI agent a real browser" headline quietly depends on. The guard isn't the feature. The guard is what lets the feature be allowed to exist.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Safari MCP is open source (MIT) — a Safari automation server for AI coding agents on macOS: native WebKit, your real logins, no Chrome, no headless. The full three-pass hardening shipped in &lt;a href="https://github.com/achiya-automation/safari-mcp/releases/tag/v2.13.0" rel="noopener noreferrer"&gt;v2.13.0&lt;/a&gt;; the blow-by-blow is in the &lt;a href="https://github.com/achiya-automation/safari-mcp/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;. Repo: &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;github.com/achiya-automation/safari-mcp&lt;/a&gt;. I build automation like this at &lt;a href="https://achiya-automation.com" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Have you found one of these in your own code — a guard on the wrapper, a prefix standing in for a boundary, state that fails open on restart? I'd genuinely like to hear which of the three bit you.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your branch protection is quietly turning away first-time contributors</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 08 Jun 2026 09:24:40 +0000</pubDate>
      <link>https://dev.to/achiya-automation/your-branch-protection-is-quietly-turning-away-first-time-contributors-48if</link>
      <guid>https://dev.to/achiya-automation/your-branch-protection-is-quietly-turning-away-first-time-contributors-48if</guid>
      <description>&lt;p&gt;Ten weeks ago I did the thing every "grow your open source project" guide tells you to do. I carved a few small, self-contained tasks out of my backlog, labeled them &lt;code&gt;good first issue&lt;/code&gt;, wrote crisp descriptions, and waited for contributors to roll in.&lt;/p&gt;

&lt;p&gt;They didn't roll in. The issues just sat there.&lt;/p&gt;

&lt;p&gt;This morning, one of them finally got picked up. A first-time contributor opened a clean PR against my MCP server: a smoke-test suite, no new dependencies, green across the whole Node CI matrix. Exactly the contribution the label was advertising for.&lt;/p&gt;

&lt;p&gt;And then my own repository spent the next twenty minutes trying to stop it from getting merged.&lt;/p&gt;

&lt;p&gt;Not with anything dramatic. With three quiet, individually-reasonable "best practice" gates that, stacked together, form a gauntlet aimed squarely at the one person you spent ten weeks trying to attract. I want to walk through each gate, because almost everything written about contributors is about &lt;em&gt;attracting&lt;/em&gt; them, and almost nothing is about the last hundred feet — the silent friction between a willing PR and a merged commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The advice is only half the story
&lt;/h2&gt;

&lt;p&gt;"Add good first issues and contributors will come" is true in the same way "build it and they will come" is true: technically, eventually, for a small subset, with survivorship bias baked in. My &lt;code&gt;good first issue&lt;/code&gt; opened on March 31. The PR that closed it merged on June 8. That's sixty-nine days of a clearly-labeled, beginner-friendly task sitting untouched.&lt;/p&gt;

&lt;p&gt;I'm not complaining about the wait — that part is normal. I'm pointing out that the advice stops exactly where the interesting problem starts. Because the bottleneck was never &lt;em&gt;finding&lt;/em&gt; someone willing. When someone willing finally showed up, the friction was entirely on my side of the fence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 1: the CI that silently refuses to run
&lt;/h2&gt;

&lt;p&gt;GitHub Actions does not run workflows on pull requests from first-time contributors until a maintainer approves the run. This is a sane anti-abuse measure — fork PRs can run arbitrary code in your CI — and I'd keep it on. But look at it from the contributor's seat.&lt;/p&gt;

&lt;p&gt;They open a careful PR. They watch the checks section. And nothing happens. No green check. No red X. No "running." Just a quiet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no checks reported on the 'codex/add-smoke-tests-for-mcp-tools' branch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the maintainer's side it shows up as &lt;code&gt;action_required&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;27117992338  [completed/action_required]  pull_request  CI
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To &lt;em&gt;you&lt;/em&gt; it's a one-click "Approve and run." To &lt;em&gt;them&lt;/em&gt; it is indistinguishable from "this maintainer doesn't care / this repo is dead / I did something wrong." There is no message telling them it's waiting on you. A motivated first-timer who doesn't know this is a normal GitHub behavior will quietly assume the worst and never come back. The gate didn't reject their code — it rejected their confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 2: &lt;code&gt;BLOCKED&lt;/code&gt;, reason unspecified
&lt;/h2&gt;

&lt;p&gt;Approved the run, watched it go green on Node 20, 22, and 24. Posted a review. Hit merge. Got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pull request is not mergeable: the base branch policy prohibits the merge.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I checked the branch protection. The classic API was almost entirely empty:&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;"required_pull_request_reviews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"required_status_checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;Reviews: not required. Status checks: not required. The PR itself reported &lt;code&gt;mergeable: MERGEABLE&lt;/code&gt;, &lt;code&gt;reviewDecision: APPROVED&lt;/code&gt;, every check &lt;code&gt;SUCCESS&lt;/code&gt; — and &lt;code&gt;mergeStateStatus: BLOCKED&lt;/code&gt;. Blocked by what? Nothing the obvious endpoint admitted to. I had to pull the &lt;em&gt;full&lt;/em&gt; protection object to find the real culprits:&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;"required_signatures"&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;"enabled"&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="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;"required_conversation_resolution"&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;"enabled"&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="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;"enforce_admins"&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;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;Here's the thing: &lt;em&gt;I&lt;/em&gt; set those, and even I had to dig to remember they were on and reconcile them against a half-empty API response. A first-time contributor staring at "the base branch policy prohibits the merge" has zero ability to diagnose this. It's not their branch, not their settings, not their problem to solve — and yet it's their PR sitting in limbo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 3: the unsigned fork commit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;required_signatures&lt;/code&gt; was the actual wall. My &lt;code&gt;main&lt;/code&gt; requires verified commit signatures. A commit authored on someone else's fork is, naturally, not signed with anything my branch trusts. The escape hatch turned out to be the squash merge: when GitHub creates the squash commit server-side, it signs it with its own web-flow key, so the &lt;em&gt;resulting&lt;/em&gt; commit on &lt;code&gt;main&lt;/code&gt; is verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;a4b29a054  verified=true  | test: add smoke tests for registered MCP tools (#&lt;/span&gt;35&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. But notice the shape of the solution: the contributor's commit was never going to satisfy the rule, and the only reason it merged is a side effect of &lt;em&gt;how&lt;/em&gt; GitHub squashes. That's not a policy a contributor can reason about. It's tribal maintainer knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  None of these are bugs. That's the problem.
&lt;/h2&gt;

&lt;p&gt;Every gate here is defensible in isolation. Approval-gating fork CI prevents crypto-miners in your Actions minutes. Required signatures raise the bar on supply-chain tampering. Conversation resolution stops half-finished reviews from merging. I'm not telling you to turn them off.&lt;/p&gt;

&lt;p&gt;I'm telling you that nobody adds them &lt;em&gt;together&lt;/em&gt; on purpose, with the contributor experience in mind. They accrete. You enable one after a scare, another because a security checklist said so, a third because it was the default in a template. And the cumulative effect is an invisible obstacle course that your most valuable, least-experienced contributor runs blind — with no signposts, while you're not looking.&lt;/p&gt;

&lt;p&gt;The "growth" content optimizes the funnel right up to the PR and then goes silent. The actual leak is &lt;em&gt;after&lt;/em&gt; the PR.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Document the approval gate.&lt;/strong&gt; One line in &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;: "Your first PR's CI won't run until I approve it — that's a GitHub default, not you. Ping me if it sits." That single sentence converts "this repo is dead" into "ah, normal."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the Actions tab, not just the PR list.&lt;/strong&gt; &lt;code&gt;action_required&lt;/code&gt; runs don't page you. If you're seeding good first issues, you've explicitly invited PRs from people whose CI will &lt;em&gt;always&lt;/em&gt; start out gated. Treat that queue as a first-class inbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-audit protection rules as a set, from the contributor's chair.&lt;/strong&gt; Pull the full protection object, not the two-field summary. Ask of each rule: "if a stranger's clean PR hits this, will they understand what happened?" If the answer is no, at minimum it needs documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply fast and warm on the first one.&lt;/strong&gt; The first external PR is a confidence transaction as much as a code transaction. I approved CI, left a specific review calling out what was good, and merged inside the hour. That contributor is far more likely to send a second one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The smoke suite, by the way, is genuinely nice work: it boots the server over the real stdio transport — the same path actual MCP clients use — and asserts that all 91 registered tools show up with unique &lt;code&gt;safari_*&lt;/code&gt; names and valid schemas. It derives the expected count from the source instead of hardcoding it, so it stays honest as the tool surface grows. It runs on &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, a browser-automation MCP server for Safari on macOS, if you want to see the shape of it.&lt;/p&gt;

&lt;p&gt;Maintainers: how do you handle the first-time-contributor CI gate — pre-approve, automate it with a workflow, or just keep the Actions tab open? I'd genuinely like to steal a better answer than "watch it like a hawk."&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>github</category>
      <category>githubactions</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
