<?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: Takuya Auto</title>
    <description>The latest articles on DEV Community by Takuya Auto (@takuyaauto).</description>
    <link>https://dev.to/takuyaauto</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%2F4128495%2F6547fa8e-718c-40e2-a3ae-511fe3cbdea5.jpg</url>
      <title>DEV Community: Takuya Auto</title>
      <link>https://dev.to/takuyaauto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/takuyaauto"/>
    <language>en</language>
    <item>
      <title>Google Apps Script answers webhooks with a 302: why Stripe retries forever, and how to fix it</title>
      <dc:creator>Takuya Auto</dc:creator>
      <pubDate>Fri, 18 Sep 2026 23:28:22 +0000</pubDate>
      <link>https://dev.to/takuyaauto/google-apps-script-answers-webhooks-with-a-302-why-stripe-retries-forever-and-how-to-fix-it-481e</link>
      <guid>https://dev.to/takuyaauto/google-apps-script-answers-webhooks-with-a-302-why-stripe-retries-forever-and-how-to-fix-it-481e</guid>
      <description>&lt;p&gt;I run a small setup where a Stripe payment triggers Google Apps Script (GAS) to email the product to the buyer. While building it I hit the kind of trap where everything "works" and you still get an incident. Here is what went wrong, how I fixed it, and how I test GAS logic without opening the editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: a GAS web app answers POST with a 302
&lt;/h2&gt;

&lt;p&gt;When you deploy GAS as a web app (&lt;code&gt;/exec&lt;/code&gt;) and receive a Stripe webhook in &lt;code&gt;doPost(e)&lt;/code&gt;, this happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;doPost&lt;/code&gt; &lt;strong&gt;does run&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;but the response is a &lt;strong&gt;302 redirect&lt;/strong&gt; (to a result page on &lt;code&gt;script.googleusercontent.com&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is how GAS web apps work: after a POST, the result is served from a different URL.&lt;/p&gt;

&lt;p&gt;The problem is on the sender's side. &lt;strong&gt;Stripe, like many webhook senders, treats a 3xx as a failed delivery.&lt;/strong&gt; So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stripe sends &lt;code&gt;checkout.session.completed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;GAS sends the delivery email (success)&lt;/li&gt;
&lt;li&gt;The response is a 302, so Stripe marks it failed and &lt;strong&gt;retries&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;GAS sends the delivery email again → the buyer gets the same email several times&lt;/li&gt;
&lt;li&gt;If failures continue, the webhook endpoint may eventually be disabled&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Fix: put a relay in front that returns 200, and make GAS idempotent
&lt;/h3&gt;

&lt;p&gt;The relay can live anywhere. It is a tiny endpoint that &lt;strong&gt;forwards to GAS and returns 200 to the sender&lt;/strong&gt;. I used PHP on an ordinary shared host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// Stripe -&amp;gt; this relay -&amp;gt; GAS. The sender gets a 200 and never sees GAS's 302.&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php://input'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'HTTP_STRIPE_SIGNATURE'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Verify the signature here (doPost in GAS cannot read request headers)&lt;/span&gt;
&lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'STRIPE_WEBHOOK_SECRET'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;parse_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;amp;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sig&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// split t=...,v1=...&lt;/span&gt;
&lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'t'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'t'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://script.google.com/macros/s/XXXXXXXX/exec'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;curl_setopt_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="no"&gt;CURLOPT_POST&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="no"&gt;CURLOPT_POSTFIELDS&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="no"&gt;CURLOPT_HTTPHEADER&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="no"&gt;CURLOPT_FOLLOWLOCATION&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// follow GAS's 302 through to the result&lt;/span&gt;
  &lt;span class="no"&gt;CURLOPT_RETURNTRANSFER&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="no"&gt;CURLOPT_TIMEOUT&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_getinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CURLINFO_HTTP_CODE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;curl_close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&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="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// only report failure when GAS is really down, and let Stripe retry&lt;/span&gt;
  &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Return 500 only when GAS is actually down.&lt;/strong&gt; That way Stripe's automatic retries apply to real failures only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the Stripe signature in the relay.&lt;/strong&gt; &lt;code&gt;doPost(e)&lt;/code&gt; in GAS cannot read request headers, so it cannot verify it. (Between the relay and GAS, use a hard-to-guess URL or check a shared secret passed in the query string.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other half is making &lt;strong&gt;the GAS side idempotent&lt;/strong&gt;. Even with a relay, the same event can arrive twice for ordinary network reasons.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doPost&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contents&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;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PropertiesService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getScriptProperties&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// Stripe event ID&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LockService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getScriptLock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ContentService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTextOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duplicate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// already handled: do nothing&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;deliver_&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="c1"&gt;// send the delivery email, etc.&lt;/span&gt;
    &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ContentService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTextOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;releaseLock&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;&lt;strong&gt;On a duplicate, do not send again.&lt;/strong&gt; If you make it "send once more just in case", every retry loop adds another email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: testing GAS with &lt;code&gt;curl -X POST&lt;/code&gt; gives a 411
&lt;/h2&gt;

&lt;p&gt;When I tested GAS directly with curl (no relay), adding &lt;code&gt;-X POST&lt;/code&gt; failed.&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;# NG: fails with 411 Length Required and similar&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"test":1}'&lt;/span&gt; &lt;span class="s2"&gt;"https://script.google.com/macros/s/XXXX/exec"&lt;/span&gt;

&lt;span class="c"&gt;# OK: no -X, just -d and -L&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"test":1}'&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="s2"&gt;"https://script.google.com/macros/s/XXXX/exec"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-X POST&lt;/code&gt; forces the method on the redirect target too. The request that should turn into a GET after the 302 stays a POST with no body, and gets rejected. With &lt;code&gt;-d&lt;/code&gt; alone, curl uses POST automatically and then correctly fetches the result with GET after the redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3 (more of a technique): test GAS logic in Node, using the production code as is
&lt;/h2&gt;

&lt;p&gt;GAS is awkward to test: you run things by hand in the editor or wait for a trigger. But a large part of real GAS code is &lt;strong&gt;plain JavaScript that never touches &lt;code&gt;SpreadsheetApp&lt;/code&gt;&lt;/strong&gt; — amount checks, date arithmetic, duplicate detection.&lt;/p&gt;

&lt;p&gt;So I load &lt;strong&gt;the production &lt;code&gt;.gs&lt;/code&gt; files unchanged&lt;/strong&gt; with Node's &lt;code&gt;vm&lt;/code&gt; module, stub only the GAS globals, and call the functions.&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;// test/rules.test.js (no dependencies; run with: node test/rules.test.js)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sandbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Utilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;:&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;tz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yyyy&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MM&lt;/span&gt;&lt;span class="dl"&gt;'&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dd&lt;/span&gt;&lt;span class="dl"&gt;'&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&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;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;GmailApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;DriveApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="na"&gt;PropertiesService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;LockService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Like GAS in production, load every file into one namespace&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.gs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runInContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Call a pure-logic function with an object shaped like one sheet row (names are examples)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateInvoice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;110000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-09-30&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;underpaid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&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;ul&gt;
&lt;li&gt;If your top level is only function definitions and &lt;code&gt;var&lt;/code&gt;s (the usual GAS style), loading succeeds even with empty-object stubs. They are only touched when a function actually runs.&lt;/li&gt;
&lt;li&gt;Functions that write to a sheet also run if you pass &lt;strong&gt;an in-memory sheet mock&lt;/strong&gt; that implements just &lt;code&gt;getRange().getValues()/setValues()/appendRow()&lt;/code&gt;. For formatting methods, an empty &lt;code&gt;return this&lt;/code&gt; was enough.&lt;/li&gt;
&lt;li&gt;This finds far more bugs than a static syntax check. For my invoice-checking GAS I run 39 assertions this way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deploying to GAS is &lt;code&gt;clasp push&lt;/code&gt;. It updates with the local token, without logging in through the browser again.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;If a GAS web app receives webhooks, pair &lt;strong&gt;a relay that hides the 302 from the sender&lt;/strong&gt; with &lt;strong&gt;idempotency keyed on the event ID&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Test with curl &lt;strong&gt;without &lt;code&gt;-X POST&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;GAS logic can be tested &lt;strong&gt;in Node's &lt;code&gt;vm&lt;/code&gt;, using the production code as is&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't write code myself; an AI agent wrote and maintains all of this under rules I set. How that arrangement works is in &lt;a href="https://dev.to/takuyaauto/i-dont-write-code-heres-how-i-made-claude-code-the-steward-of-my-one-person-business-jf5"&gt;I don't write code. Here's how I made Claude Code the steward of my one-person business&lt;/a&gt;, and as a short Kindle book: &lt;a href="https://www.amazon.com/dp/B0HK8Y4XV8" rel="noopener noreferrer"&gt;Let Claude Code Run Your One-Person Business&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>stripe</category>
      <category>webhooks</category>
      <category>javascript</category>
    </item>
    <item>
      <title>12 traps when Claude Code drives your real Chrome through Playwright MCP (and the fixes)</title>
      <dc:creator>Takuya Auto</dc:creator>
      <pubDate>Fri, 18 Sep 2026 23:25:42 +0000</pubDate>
      <link>https://dev.to/takuyaauto/12-traps-when-claude-code-drives-your-real-chrome-through-playwright-mcp-and-the-fixes-54dd</link>
      <guid>https://dev.to/takuyaauto/12-traps-when-claude-code-drives-your-real-chrome-through-playwright-mcp-and-the-fixes-54dd</guid>
      <description>&lt;p&gt;I have Claude Code drive my everyday Chrome — the real, logged-in profile — through the Playwright MCP server. It checks admin screens, fills forms and verifies that posts actually went out, every day.&lt;/p&gt;

&lt;p&gt;Automating a real browser is not like a headless test browser. Clicks wait 30 seconds and fail, non-ASCII text arrives with characters missing, and "I typed it" does not mean "it was saved". Here are 12 traps I actually hit, with the fix that worked. My setup is Windows 11 and Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. You cannot attach a debugging port to your normal Chrome profile
&lt;/h2&gt;

&lt;p&gt;Recent Chrome (136 and later) &lt;strong&gt;refuses &lt;code&gt;--remote-debugging-port&lt;/code&gt; on the default profile&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chrome.exe &lt;span class="nt"&gt;--remote-debugging-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9225 &lt;span class="nt"&gt;--user-data-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"C:/Users/you/chrome-cdp-work"&lt;/span&gt; &lt;span class="nt"&gt;--no-first-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Create a &lt;strong&gt;dedicated &lt;code&gt;--user-data-dir&lt;/code&gt;&lt;/strong&gt; for automation and log in there once by hand.&lt;/li&gt;
&lt;li&gt;Connect to &lt;code&gt;127.0.0.1&lt;/code&gt;, not &lt;code&gt;localhost&lt;/code&gt; (name resolution can go to IPv6 and fail).&lt;/li&gt;
&lt;li&gt;Pin one port and one profile per purpose, so you always know which account is being driven (for example 9225 for work A, 9226 for work B).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the Playwright MCP side, use a CDP connection:&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@playwright/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--cdp-endpoint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:9225"&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;h2&gt;
  
  
  2. "visible, enabled and stable" times out after 30 s → the window is covered
&lt;/h2&gt;

&lt;p&gt;This is the most common failure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TimeoutError: locator.click: Timeout 30000ms exceeded.
  - waiting for element to be visible, enabled and stable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks like an element problem. Almost always, the real cause is that &lt;strong&gt;the window is completely covered by another window, so the page is at &lt;code&gt;document.visibilityState === 'hidden'&lt;/code&gt;&lt;/strong&gt;. A hidden tab stops rendering, and Playwright cannot perform a real click.&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;// check this first&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt;  &lt;span class="c1"&gt;// "hidden" means it is not the element's fault&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to bring that Chrome window to the front. With several profiles on one monitor they overlap, so I use a small PowerShell script that calls Windows &lt;code&gt;SetWindowPos&lt;/code&gt; (HWND_TOPMOST) to pin just that window on top, and unpins it afterwards. &lt;strong&gt;If you forget to unpin, you get in the human's way&lt;/strong&gt;, so I treat "pin → work → unpin" as one unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A new tab is hidden right after it opens
&lt;/h2&gt;

&lt;p&gt;Tabs opened with &lt;code&gt;browser_tabs new&lt;/code&gt; or &lt;code&gt;window.open&lt;/code&gt; can start in the background as &lt;code&gt;hidden&lt;/code&gt;. Call &lt;code&gt;page.bringToFront()&lt;/code&gt; before doing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Display off or a locked screen makes everything hidden
&lt;/h2&gt;

&lt;p&gt;After some idle time the display turns off and every window becomes &lt;code&gt;hidden&lt;/code&gt;. You can prevent display-off with power settings or periodic input, but &lt;strong&gt;you cannot get past the Windows lock screen&lt;/strong&gt;. A person has to unlock it, so for overnight work I lean on methods that do not need a click (APIs and &lt;code&gt;evaluate&lt;/code&gt;). The most reliable way I found to detect the lock state was the output of &lt;code&gt;quser&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Use &lt;code&gt;insertText&lt;/code&gt; for non-ASCII input
&lt;/h2&gt;

&lt;p&gt;Sending key events one character at a time drops or reorders Japanese text. CDP's &lt;code&gt;Input.insertText&lt;/code&gt; (&lt;code&gt;page.keyboard.insertText()&lt;/code&gt; in Playwright) inserts the whole string reliably.&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.cm-content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;longMarkdown&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for CodeMirror editors too. Note that CodeMirror does not render off-screen lines, so &lt;code&gt;innerText.length&lt;/code&gt; looks shorter than what you inserted. Confirm in the preview instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. In React rich-text editors, "select all → delete" can leave the content behind
&lt;/h2&gt;

&lt;p&gt;In a Draft.js-style editor, Ctrl+A → Delete &lt;strong&gt;cleared what I could see while the React state kept the old text&lt;/strong&gt;, and submitting posted the old text. Select with &lt;code&gt;document.execCommand('selectAll')&lt;/code&gt; before typing, and confirm with a real number on screen such as the character counter.&lt;/p&gt;

&lt;p&gt;It also happens in the other direction. In one caption field, after pasting, the screen showed only the last line while the character counter showed the full length. If I had trusted the counter alone, I would have posted without the body. What you see and what the app holds can drift apart either way, so check both.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. ProseMirror editors accept formatting through an HTML paste event
&lt;/h2&gt;

&lt;p&gt;For ProseMirror-style editors (product description fields and the like), dispatching a &lt;code&gt;paste&lt;/code&gt; event that carries &lt;code&gt;text/html&lt;/code&gt; keeps headings, lists and bold intact.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.ProseMirror&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRange&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;selectNodeContents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;getSelection&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;removeAllRanges&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;getSelection&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;addRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&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;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataTransfer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClipboardEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paste&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;clipboardData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cancelable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The first heading sometimes merges into the paragraph before it&lt;/strong&gt;, so after pasting I count the structure (number of &lt;code&gt;h2&lt;/code&gt;, number of &lt;code&gt;li&lt;/code&gt;) to confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. To hand a local file to the page, go through a hidden &lt;code&gt;input[type=file]&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;browser_run_code&lt;/code&gt;, &lt;code&gt;import('fs')&lt;/code&gt; was not always available. When I need to pass a long draft to the page, I create a hidden file input, call &lt;code&gt;setInputFiles&lt;/code&gt;, and read it on the page side.&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tmp-file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#tmp-file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setInputFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;path&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;to&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;article.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tmp-file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;files&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="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for image uploads: on sites where a transparent overlay sits on top of the button and swallows the click, calling &lt;code&gt;setInputFiles&lt;/code&gt; directly on the &lt;code&gt;input[type=file]&lt;/code&gt; is the reliable route.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Confirm "it is published" while logged out
&lt;/h2&gt;

&lt;p&gt;A browser logged in as the admin sees the newest, cache-bypassed page. An ordinary visitor may still see an old cache or an unpublished state. &lt;strong&gt;Before reporting "done", open the page in a clean profile.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chrome.exe &lt;span class="nt"&gt;--headless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;new &lt;span class="nt"&gt;--user-data-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"%TEMP%&lt;/span&gt;&lt;span class="se"&gt;\a&lt;/span&gt;&lt;span class="s2"&gt;non-check"&lt;/span&gt; &lt;span class="nt"&gt;--virtual-time-budget&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15000 &lt;span class="nt"&gt;--dump-dom&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/page"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; page.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For JavaScript-rendered sites &lt;code&gt;curl&lt;/code&gt; returns an empty shell, so I take the rendered DOM with &lt;code&gt;--dump-dom&lt;/code&gt; and then count the headings and links I expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. A &lt;code&gt;beforeunload&lt;/code&gt; dialog freezes everything
&lt;/h2&gt;

&lt;p&gt;Navigating away from an editor with unsaved changes raises a &lt;code&gt;beforeunload&lt;/code&gt; confirmation, and every later action hangs. If it appears when you thought you had published, that is &lt;strong&gt;the signal that you had not&lt;/strong&gt;. Dismiss the dialog, check the save state, and redo it.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Small Windows traps: BOM and line endings
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;.ps1&lt;/code&gt; containing non-ASCII text must be saved as &lt;strong&gt;UTF-8 with BOM&lt;/strong&gt;, or Windows PowerShell 5.1 fails to parse it.&lt;/li&gt;
&lt;li&gt;Python on Windows writes CRLF with &lt;code&gt;open(path, "w")&lt;/code&gt;. For files you upload to a server, pass &lt;code&gt;newline="\n"&lt;/code&gt; explicitly.&lt;/li&gt;
&lt;li&gt;Command output captured over ssh can have &lt;strong&gt;stderr warnings mixed in&lt;/strong&gt;. Add &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; before you use the output as content.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  12. Traps when you also run your own Chrome extension (MV3)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Inline handlers such as &lt;code&gt;onclick&lt;/code&gt; are &lt;strong&gt;silently ignored&lt;/strong&gt; under CSP. Use &lt;code&gt;addEventListener&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The service worker stops after about 30 seconds. Long waits built on &lt;code&gt;setTimeout&lt;/code&gt; vanish midway.&lt;/li&gt;
&lt;li&gt;For an unpacked extension, &lt;strong&gt;the old service worker keeps running after you replace the files&lt;/strong&gt;; you have to reload the extension. Bumping &lt;code&gt;version&lt;/code&gt; in &lt;code&gt;manifest.json&lt;/code&gt; every time tells you which one is live.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;When real-browser automation fails, check these three before you suspect the element or the selector:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is &lt;code&gt;document.visibilityState&lt;/code&gt; &lt;code&gt;visible&lt;/code&gt; (is the window actually showing)?&lt;/li&gt;
&lt;li&gt;Did the input reach &lt;strong&gt;the app's state&lt;/strong&gt; (confirm with a counter, the save state or a preview, not with what you see)?&lt;/li&gt;
&lt;li&gt;Did you confirm the result &lt;strong&gt;while logged out&lt;/strong&gt;?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;How I make Claude Code follow this "verify, then report" rule is in my previous post: &lt;a href="https://dev.to/takuyaauto/i-dont-write-code-heres-how-i-made-claude-code-the-steward-of-my-one-person-business-jf5"&gt;I don't write code. Here's how I made Claude Code the steward of my one-person business&lt;/a&gt;. The whole setup is also written up as a short Kindle book, &lt;a href="https://www.amazon.com/dp/B0HK8Y4XV8" rel="noopener noreferrer"&gt;Let Claude Code Run Your One-Person Business&lt;/a&gt; (in Kindle Unlimited).&lt;/p&gt;

</description>
      <category>ai</category>
      <category>playwright</category>
      <category>automation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I don't write code. Here's how I made Claude Code the steward of my one-person business</title>
      <dc:creator>Takuya Auto</dc:creator>
      <pubDate>Wed, 16 Sep 2026 17:42:06 +0000</pubDate>
      <link>https://dev.to/takuyaauto/i-dont-write-code-heres-how-i-made-claude-code-the-steward-of-my-one-person-business-jf5</link>
      <guid>https://dev.to/takuyaauto/i-dont-write-code-heres-how-i-made-claude-code-the-steward-of-my-one-person-business-jf5</guid>
      <description>&lt;p&gt;Let me get the confession out of the way: I cannot program.&lt;/p&gt;

&lt;p&gt;At my day job I plan AI projects and help teams put them to work, but I have almost never written code with my own hands. Even so, a small business I run alone is operated, every day, by an AI agent. It posts. It replies. It checks for orders and messages. It tallies numbers. It updates the records. What I do is decide: "This can go out." "Stop this one." "End that experiment."&lt;/p&gt;

&lt;p&gt;The tool is Claude Code. It runs in a terminal, which I avoided for years. In practice you type plain language, it answers in plain language, it reads and writes files, drives a browser, and reports back. It was built as a coding tool. Used as a clerk that follows written instructions, it works just as well.&lt;/p&gt;

&lt;p&gt;It did not work on the first try, though. This post is about what I had to write down before it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three stumbles
&lt;/h2&gt;

&lt;p&gt;For the first few months I tripped over the same three things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every day started from zero.&lt;/strong&gt; Yesterday I told it "that post is published" and "this deal is stalled." Next morning, gone. An agent remembers nothing except what you hand it, so every morning I re-explained yesterday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There was never a moment to quit.&lt;/strong&gt; I would try something new, the response would be weak, and I'd say "let's give it a little longer." I had never decided, before starting, what would make me stop. So nothing ever stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Done" was not done.&lt;/strong&gt; It reported "posted," and the post was not there. It said "fixed," and the public page was unchanged. It was not lying. It had taken "I ran the steps" and rewritten it as "it is finished."&lt;/p&gt;

&lt;p&gt;While fixing these, I noticed something. What I needed was not a smarter model. I needed a proper steward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The steward
&lt;/h2&gt;

&lt;p&gt;In the merchant houses of old Japan there was a role called the &lt;em&gt;banto&lt;/em&gt;. The closest English word is steward, or head clerk. The steward ran the shop on the owner's behalf: kept the ledgers, handled orders, gave instructions to staff, and brought the big decisions to the owner. The owner did not stand behind the counter every day. With a good steward, the shop ran.&lt;/p&gt;

&lt;p&gt;A steward needs more than intelligence. He needs to know the house rules, where the ledgers are, what he may decide alone and what he must bring to the owner. And when he says "it sold," he has actually checked that it sold.&lt;/p&gt;

&lt;p&gt;Rules, ledgers, boundaries, and a way of checking. Give an agent those four things and a person who does not write code can have it run their business. Everything below is one of those four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary: what it decides, what it asks
&lt;/h2&gt;

&lt;p&gt;Tell someone you let an AI run your business and you get one of two reactions: "that sounds easy" or "that's frightening." Both are half right. I started with no boundary and got both failures at once.&lt;/p&gt;

&lt;p&gt;The agent asked me too much. "Is this wording all right?" "How should I reply to this one?" I spent the day talking to it, and handing over the work had gained me nothing.&lt;/p&gt;

&lt;p&gt;At the same time, it asked too little. One day I found part of a public page rewritten. I had not asked for it. It explained that it "judged it better to widen the scope of the fix." That one was reversible. I thought about what if it had been a payment setting, and went cold.&lt;/p&gt;

&lt;p&gt;The only way to prevent both is to write down, up front, what it asks about and what it does not. Here is the boundary I actually use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proceed without asking:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Routine work with a written procedure (posting, replying, patrolling, tallying)&lt;/li&gt;
&lt;li&gt;Updating the record files&lt;/li&gt;
&lt;li&gt;Writing drafts&lt;/li&gt;
&lt;li&gt;Checking and reporting on information that is already public&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Always stop and ask:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anything that moves money (charges, payments, contracts, plan changes)&lt;/li&gt;
&lt;li&gt;Important outgoing messages (email to a partner, the first reply to a new inquiry)&lt;/li&gt;
&lt;li&gt;Entering personal or payment details&lt;/li&gt;
&lt;li&gt;Obtaining or changing API keys and credentials&lt;/li&gt;
&lt;li&gt;Anything irreversible (deleting, unpublishing, changing account settings)&lt;/li&gt;
&lt;li&gt;Any new kind of task the rules do not cover&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail matters more than the rest: on the stop list, &lt;strong&gt;name the specific operations.&lt;/strong&gt; "Check with me on anything important" tells the agent nothing, because it does not know what you consider important. Three lines I rewrote:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Before: "Check with me before doing anything risky."&lt;br&gt;
After: "Stop and ask before any charge, payment, contract, or plan change."&lt;/p&gt;

&lt;p&gt;Before: "Don't send anything important without asking."&lt;br&gt;
After: "Stop and ask before email to a partner and before the first reply to a new inquiry. Follow-up replies in an existing thread do not need approval."&lt;/p&gt;

&lt;p&gt;Before: "Be careful with deletions."&lt;br&gt;
After: "Never delete a file, post, or record without asking. If something needs removing, move it to an archive folder and tell me."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pattern is always the same. Name the operation. Say what to do instead. Leave nothing to interpret. Adjectives ("careful," "thorough," "good judgment") are not instructions; an agent cannot act on an adjective.&lt;/p&gt;

&lt;p&gt;And the one line under all of it: &lt;strong&gt;buttons that cannot be un-pressed are pressed by a human.&lt;/strong&gt; Truly irreversible operations come up a few times a day at most. Press those yourself, and the other several dozen tasks can go to the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  One operating document
&lt;/h2&gt;

&lt;p&gt;Rules said out loud last one session. Conversation does not persist; only the document does. So the rules live in the file Claude Code reads at the start of every session (&lt;code&gt;CLAUDE.md&lt;/code&gt; in the project root). It is ordinary prose, not a program. My first version was about forty lines and covered six things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The role.&lt;/strong&gt; "You are the steward of this business. Keep routine work moving; bring anything that needs a decision to me."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operations that require confirmation.&lt;/strong&gt; The stop list above, copied in as is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working modes.&lt;/strong&gt; Ask-first by default. "Run it to the end" switches to finish-it mode, where questions are collected for the end. Even then: stop immediately on anything irreversible or obviously wrong. (Without that exception, it once kept "fixing" a visibly broken page because I had said no approvals were needed.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shape of a report.&lt;/strong&gt; Conclusion first. When presenting options, name one recommendation. This came from real irritation: "Plan A or Plan B, which do you prefer?" constantly, and me answering "so which one?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers and completion.&lt;/strong&gt; Numbers, URLs, and "done" are written only after seeing the real thing. Anything unverified is labeled "unverified." This is the single most effective line in the whole file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where the record files are.&lt;/strong&gt; Which plain Markdown file holds current state, next actions, experiments (with numeric exit conditions and a judgment date), decisions, and costs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The day after I put this in place, its behavior changed. Answers began with "My recommendation is." It started writing "unverified" on its own.&lt;/p&gt;

&lt;p&gt;Two shapes of report grew out of this. In the morning I type one word and get: status, what is overdue, what is stalled &lt;em&gt;with the number of days&lt;/em&gt;, and one first task with a reason. At the end of a session, before I ask, it produces: what was completed (with real URLs), what is stalled and for how many days, new to-dos, and what is waiting on me.&lt;/p&gt;

&lt;p&gt;There is one box I deliberately leave out: "next steps." With that box, the agent learns to propose and stop. Without it, whatever can be done now gets done before the report, and whatever waits on a date goes into the record file with the date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three failure patterns, each blocked with one line
&lt;/h2&gt;

&lt;p&gt;After more than half a year, the failures sorted into a handful of patterns. Every fix took the same form: one more line in a document.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Fabricated completion
&lt;/h3&gt;

&lt;p&gt;"Posted," but not posted. "Published," but the public page was the old one. The line between procedure and result had blurred.&lt;/p&gt;

&lt;p&gt;Three lines blocked it. "Write numbers, URLs, and completion only after seeing the real thing." "Verify each item individually, no batches." And the one I learned by stepping on it: "Confirm publication while logged out." Logged in as admin you see the newest state; a visitor may be looking at a cached or unpublished page. The agent had been describing the admin's screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Repeating last week's mistake
&lt;/h3&gt;

&lt;p&gt;A failure fixed last week happens again this week. Naturally: it does not remember last week.&lt;/p&gt;

&lt;p&gt;The rule became "a failure becomes a rule the same day." For every failure I write three things: what happened (one sentence), what caused it (suspect my own instructions first), and which document got which new line. My reply playbook's "what not to write" list grew exactly this way, one mistake at a time: "Do not assume things about the other person's situation." "Do not console someone who is not upset."&lt;/p&gt;

&lt;p&gt;A companion rule: "Before saying 'I can't,' check the past records." More than once it told me an operation was difficult and I should do it by hand, when it had done the same operation successfully before and the procedure was in the notes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Overstated outreach
&lt;/h3&gt;

&lt;p&gt;The one I consider most serious. I had it draft messages to outside parties about problems on their websites. It took the "possible problems" it had detected automatically and put them straight into the text. Of six drafts, five were wrong: things called problems that were not, or exaggerations that caused the recipient no visible harm. Sent unchecked, they would have cost those people's trust in one stroke.&lt;/p&gt;

&lt;p&gt;The rule: "Outgoing messages go through an inspection table first." For each sentence that asserts a fact: Is there evidence we confirmed on the real thing? Does it cause harm the recipient can see? Is it a false positive or an exaggeration? Can we actually do it within our authority and budget? A sentence that fails is deleted. Nothing detected automatically goes into text as is.&lt;/p&gt;

&lt;p&gt;An LLM produces text fluently. That is exactly why it produces ungrounded text fluently. Before a human reads it, attach evidence to every sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;It is not "let the AI do everything." That means the agent presses the irreversible button. It is not "check everything" either. That means you spend the day talking to it and hired a steward for nothing.&lt;/p&gt;

&lt;p&gt;It is a boundary table with named operations, one document the agent reads every time, a human on the irreversible buttons, and a habit of turning each failure into a line the same day. None of it requires code. All of it requires writing things down instead of saying them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The skill pack I use.&lt;/strong&gt; I packaged the operating document template, the record files, and the session-start / session-close routines as a Claude Code plugin. A free version with the setup, briefing, and close-out skills is at &lt;a href="https://takuyaauto.gumroad.com/l/solo-operator-os-lite" rel="noopener noreferrer"&gt;Solo Operator OS Lite&lt;/a&gt;; the full version adds experiments with exit conditions, judgment days, decision logs, and a two-opinion review for big decisions: &lt;a href="https://takuyaauto.gumroad.com/l/solo-operator-os" rel="noopener noreferrer"&gt;Solo Operator OS&lt;/a&gt;. You can also just copy the rules above into your own &lt;code&gt;CLAUDE.md&lt;/code&gt;; that is how it started. Not affiliated with Anthropic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The book
&lt;/h2&gt;

&lt;p&gt;I wrote the whole setup up as a short Kindle book: the delegate / don't-delegate table, the one-page operating rules the agent reads every session, the five record files, and the failure patterns I actually hit. It's in Kindle Unlimited.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/dp/B0HK8Y4XV8" rel="noopener noreferrer"&gt;Let Claude Code Run Your One-Person Business&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>claude</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
