<?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: Aleksandr Argunov</title>
    <description>The latest articles on DEV Community by Aleksandr Argunov (@argbetech).</description>
    <link>https://dev.to/argbetech</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%2F3790326%2Fdbb58c79-3080-4d9a-9a8e-710ca86c39b3.jpg</url>
      <title>DEV Community: Aleksandr Argunov</title>
      <link>https://dev.to/argbetech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/argbetech"/>
    <language>en</language>
    <item>
      <title>CVE statistics from the raw CVE list: a Python count and a doubling ladder</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Fri, 30 Oct 2026 14:35:00 +0000</pubDate>
      <link>https://dev.to/argbetech/cve-statistics-from-the-raw-cve-list-a-python-count-and-a-doubling-ladder-dc6</link>
      <guid>https://dev.to/argbetech/cve-statistics-from-the-raw-cve-list-a-python-count-and-a-doubling-ladder-dc6</guid>
      <description>&lt;p&gt;I counted the CVEs published each month, straight from the CVE Program's own list. Then I marked the first month above 750, then above each doubled level. Each crossing came faster than the last: 12 years, 7 years, 2 years, then 5 months.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://argbe.tech/blog/cve-doubling-time?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=cve-doubling-time" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2zqn2frgdjdyynjr33j6.gif" alt="Animated bar chart titled Security flaws published each month, and how long each doubling took. One thin bar per month of CVE records, from 1999 to October 22, 2026, draws in from left to right. A ceiling line marks the current level and bends each time a month breaks through. The gaps appear on the right: 12 years, 7 years, 2 years, 5 months. The last frame shows September 2026 at 14,934, more than all of 2017 at 14,642, a dotted line for October on pace for 18,400, and a question mark beside 24,000." width="720" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;September 2026 alone had 14,934 new CVEs. All of 2017 had 14,642. Below is the script that produces these numbers, in standard-library Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets counted
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The source is the cvelistV5 release &lt;code&gt;cve_2026-10-23_0400Z&lt;/code&gt; (&lt;a href="https://github.com/CVEProject/cvelistV5/releases/tag/cve_2026-10-23_0400Z" rel="noopener noreferrer"&gt;release&lt;/a&gt;). Its asset &lt;code&gt;2026-10-23_all_CVEs_at_midnight.zip.zip&lt;/code&gt; holds 413,350 records.&lt;/li&gt;
&lt;li&gt;Each &lt;code&gt;PUBLISHED&lt;/code&gt; record counts in the month of &lt;code&gt;cveMetadata.datePublished&lt;/code&gt;. That's when the record entered the CVE list (&lt;a href="https://github.com/CVEProject/cve-schema/blob/main/schema/CVE_Record_Format.json" rel="noopener noreferrer"&gt;record format&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;The 18,630 &lt;code&gt;REJECTED&lt;/code&gt; records are left out.&lt;/li&gt;
&lt;li&gt;2026 runs through October 22. Four records from early on October 23 are left out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this measures publication volume. It doesn't count new bugs. A record published today can describe a flaw found years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;First month above it&lt;/th&gt;
&lt;th&gt;CVEs that month&lt;/th&gt;
&lt;th&gt;Time since the last level&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;750&lt;/td&gt;
&lt;td&gt;February 2005&lt;/td&gt;
&lt;td&gt;821&lt;/td&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,500&lt;/td&gt;
&lt;td&gt;April 2017&lt;/td&gt;
&lt;td&gt;1,568&lt;/td&gt;
&lt;td&gt;12 years 2 months&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,000&lt;/td&gt;
&lt;td&gt;March 2024&lt;/td&gt;
&lt;td&gt;3,299&lt;/td&gt;
&lt;td&gt;6 years 11 months&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6,000&lt;/td&gt;
&lt;td&gt;March 2026&lt;/td&gt;
&lt;td&gt;6,236&lt;/td&gt;
&lt;td&gt;2 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12,000&lt;/td&gt;
&lt;td&gt;August 2026&lt;/td&gt;
&lt;td&gt;12,260&lt;/td&gt;
&lt;td&gt;5 months&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Above" means strictly above. The gaps are between calendar months, with no interpolated crossing day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;The asset is a zip with one zip inside, &lt;code&gt;cves.zip&lt;/code&gt;. The script extracts that inner zip, about 710 MB, to a temporary folder. Then it reads every record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# count_cves.py: published CVE records per month, from a cvelistV5 release asset.
# Usage: python3 count_cves.py ASSET.zip.zip [CUTOFF]
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;

&lt;span class="n"&gt;asset&lt;/span&gt; &lt;span class="o"&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;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&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;argv&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9999&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. 2026-10-23 skips that day on
&lt;/span&gt;
&lt;span class="n"&gt;per_month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# The asset is a zip with one zip inside. Extract the inner one first.
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;namelist&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="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;namelist&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CVE-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# folders, delta.json and deltaLog.json
&lt;/span&gt;            &lt;span class="n"&gt;meta&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;records&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;name&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cveMetadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="c1"&gt;# ISO timestamps sort as text, so a string comparison works.
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUBLISHED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datePublished&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;per_month&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datePublished&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_month&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# The doubling ladder: the first month above each level, starting at 750.
&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;750&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_month&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Above &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass a cutoff date to leave out the snapshot day's own records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 count_cves.py 2026-10-23_all_CVEs_at_midnight.zip.zip 2026-10-23 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; counts.txt
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^2026-09 '&lt;/span&gt; counts.txt                         &lt;span class="c"&gt;# 2026-09 14934&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^2026/ {s += $2} END {print s}'&lt;/span&gt; counts.txt    &lt;span class="c"&gt;# 85838&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt; counts.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last five lines are the ladder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Above 750: 2005-02 (821)
Above 1,500: 2017-04 (1,568)
Above 3,000: 2024-03 (3,299)
Above 6,000: 2026-03 (6,236)
Above 12,000: 2026-08 (12,260)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the cutoff, October shows 13,040 instead of 13,036. The extra four are the October 23 records.&lt;/p&gt;

&lt;p&gt;NVD keeps its own database. Its CVE API gives a quick cross-check for one month:&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;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://services.nvd.nist.gov/rest/json/cves/2.0?pubStartDate=2026-09-01T00:00:00.000&amp;amp;pubEndDate=2026-09-30T23:59:59.999&amp;amp;noRejected&amp;amp;resultsPerPage=1'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json, sys; print(json.load(sys.stdin)["totalResults"])'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On October 23 it returned 14,933 for September (&lt;a href="https://services.nvd.nist.gov/rest/json/cves/2.0?pubStartDate=2026-09-01T00%3A00%3A00.000&amp;amp;pubEndDate=2026-09-30T23%3A59%3A59.999&amp;amp;resultsPerPage=1&amp;amp;noRejected" rel="noopener noreferrer"&gt;query&lt;/a&gt;). In three months checked, NVD differed from the CVE list by zero to two records. Count all non-rejected records there, not just enriched ones.&lt;/p&gt;

&lt;p&gt;Pin the snapshot when you compare numbers. Records get rejected later. The CVE Program has also corrected historical dates (&lt;a href="https://github.com/CVEProject/cvelistV5/blob/cve_2026-10-23_0400Z/README.md" rel="noopener noreferrer"&gt;README&lt;/a&gt;). So a later snapshot will differ a little.&lt;/p&gt;

&lt;h2&gt;
  
  
  How solid the pattern is
&lt;/h2&gt;

&lt;p&gt;It depends on the starting level. I picked 750. Here are the gaps from other starts, same data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Start&lt;/th&gt;
&lt;th&gt;Gaps between first crossings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;12y 6m, 3y 10m, 5y 10m, 2y 2m (16,000 not reached yet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;750&lt;/td&gt;
&lt;td&gt;12y 2m, 6y 11m, 2y, 5m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;13y 2m, 5y 11m, 2y 2m, 4m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;900&lt;/td&gt;
&lt;td&gt;10y 9m, 5y 9m, 2y 2m, 3m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;3y 10m, 5y 10m, 2y 2m (16,000 not reached yet)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;800 and 900 give roughly the same shape, with different timings. 500 and 1,000 break it: the climb that ends in 2018 is shorter than the one after it.&lt;/p&gt;

&lt;p&gt;The date field matters too. Group the same records by &lt;code&gt;dateReserved&lt;/code&gt; instead. The gaps become 11y 9m, 4y 2m, 4y 3m, then 1y 5m. The third step is no faster than the second.&lt;/p&gt;

&lt;p&gt;A smoother check agrees on the direction. Twelve-month rolling totals first passed 5,000 in July 2005. Then 10,000 in June 2017, 20,000 in December 2021, 40,000 in January 2025 and 80,000 in September 2026. The gaps were 11.9, 4.5, 3.1 and 1.7 years.&lt;/p&gt;

&lt;p&gt;So the ladder describes this list, counted this way. It's not a law. It doesn't say when 24,000 comes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the count is rising
&lt;/h2&gt;

&lt;p&gt;In June, FIRST raised its 2026 forecast to about 66,000 CVEs (&lt;a href="https://www.first.org/newsroom/releases/20260615" rel="noopener noreferrer"&gt;FIRST&lt;/a&gt;). Its February median had been 59,427. It named AI-assisted discovery, changes in reporting, and CNAs working through backlogs. A CNA is an organization allowed to assign CVE IDs.&lt;/p&gt;

&lt;p&gt;My count passed 66,000 on September 16. By October 22 it was at 85,838. October 1–22 added 13,036 CVEs. Keeping that daily average would put October near 18,400. That's an extrapolation.&lt;/p&gt;

&lt;p&gt;AI is part of why. Google's threat intelligence team (GTIG) says AI is measurably changing how fast vulnerabilities are found (&lt;a href="https://cloud.google.com/blog/topics/threat-intelligence/vulnerability-discovery-and-exploitation-trends-in-the-ai-era" rel="noopener noreferrer"&gt;GTIG&lt;/a&gt;). Half its likely AI-found vulnerabilities could allow remote code execution. That compares with 26% in its non-AI group. That covers January to August 2026, and the AI label is GTIG's own heuristic.&lt;/p&gt;

&lt;p&gt;Some of the rise is bookkeeping. Kernel.org became a CNA in February 2024 (&lt;a href="https://www.cve.org/Media/News/item/news/2024/02/13/kernel-org-Added-as-CNA" rel="noopener noreferrer"&gt;CVE Program&lt;/a&gt;). Its policy leans toward assigning a CVE to fixes that could be security-relevant. That happens automatically once the fix reaches a stable tree (&lt;a href="https://www.kernel.org/doc/html/latest/process/cve.html" rel="noopener noreferrer"&gt;kernel.org&lt;/a&gt;). Not every commit gets one. GTIG counted about 5,000 CVEs from January to August whose descriptions mention the Linux kernel.&lt;/p&gt;

&lt;p&gt;Backlogs come in bursts. When old findings get published together, the month spikes. That spike isn't new bugs.&lt;/p&gt;

&lt;p&gt;This count can't say how much of the rise each cause explains.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to patch first
&lt;/h2&gt;

&lt;p&gt;Since April 15, NIST's NVD enriches three groups first (&lt;a href="https://www.nist.gov/news-events/news/2026/04/nist-updates-nvd-operations-address-record-cve-growth" rel="noopener noreferrer"&gt;NIST&lt;/a&gt;). Those are CVEs in CISA's KEV catalog, software the federal government uses, and critical software under Executive Order 14028. They're categories, not a severity cutoff. Other CVEs stay listed, as "Lowest Priority - not scheduled for immediate enrichment." You can still request enrichment.&lt;/p&gt;

&lt;p&gt;Only about 1 in 431 of this year's CVEs had been seen exploited by August. That's GTIG's figure: 0.23% of 2026 disclosures, January to August, by the exploitation it observed. It isn't a lifetime probability.&lt;/p&gt;

&lt;p&gt;This part is my opinion. Finding bugs got cheap. Fixing them didn't. Patching everything is no longer a plan. That's no reason to skip routine updates. It's a reason to decide what comes first.&lt;/p&gt;

&lt;p&gt;The public guidance supports an order like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with what you actually run.&lt;/strong&gt; A CVE in a package you don't deploy isn't on your list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Known exploitation first.&lt;/strong&gt; CISA's &lt;a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" rel="noopener noreferrer"&gt;KEV catalog&lt;/a&gt; lists vulnerabilities known to be exploited in the wild. Its &lt;a href="https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" rel="noopener noreferrer"&gt;JSON feed&lt;/a&gt; had 1,754 entries on October 21. It isn't complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EPSS for likelihood.&lt;/strong&gt; FIRST's &lt;a href="https://www.first.org/epss/faq" rel="noopener noreferrer"&gt;EPSS&lt;/a&gt; gives a probability from 0 to 1. It estimates observed exploitation in the wild over the next 30 days. Version 5 has run since June 15, 2026 (&lt;a href="https://www.first.org/newsroom/releases/202606-036" rel="noopener noreferrer"&gt;FIRST&lt;/a&gt;). Scores update daily. A score isn't the chance your system gets breached. A percentile isn't the probability. A low score never overrules known exploitation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSVC for context.&lt;/strong&gt; CISA's &lt;a href="https://www.cisa.gov/stakeholder-specific-vulnerability-categorization-ssvc" rel="noopener noreferrer"&gt;SSVC&lt;/a&gt; is a decision tree. It weighs exploitation, technical impact, automatability, mission prevalence and public well-being. It ends in Track, Track*, Attend or Act. Even Track means fixing within your normal update cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVSS severity as one input.&lt;/strong&gt; FIRST's &lt;a href="https://www.first.org/cvss/v4.0/faq" rel="noopener noreferrer"&gt;CVSS v4.0 FAQ&lt;/a&gt; says base scores describe technical severity. They shouldn't set patch priority on their own.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The maintained article is &lt;a href="https://argbe.tech/blog/cve-doubling-time?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=cve-doubling-time" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;. It has the yearly totals since 1999, the chart as a video and every source.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>What an AI agent harness does, in one short TypeScript loop</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Fri, 30 Oct 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/argbetech/what-an-ai-agent-harness-does-in-one-short-typescript-loop-20ec</link>
      <guid>https://dev.to/argbetech/what-an-ai-agent-harness-does-in-one-short-typescript-loop-20ec</guid>
      <description>&lt;p&gt;What is an AI harness? It might be the most used and least explained term in AI right now.&lt;/p&gt;

&lt;p&gt;Here's my short answer. The model writes text. The agent harness turns that text into actions.&lt;/p&gt;

&lt;p&gt;A model can't open a file, run a command or click a button by itself. It generates output, and part of that output can ask for a tool. So when Claude Code fixes a failing test, the model never touches your code directly. It asks for the tests to run, and the harness runs them. The model's choices still change your files.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://argbe.tech/blog/ai-agent-harness?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=ai-agent-harness" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgmwn9wy0gh2p2nh28xcw.gif" alt="Animated exploded view of an AI coding app, titled What is an AI harness? Three glass plates hang one above another: the interface you type into on top, the harness in the middle and the tools at the bottom. Across from them are you, the LLM and an MCP server. You type Fix the failing test. The LLM asks for four tools in turn: read a file, edit a file, run npm test in bash, and call GitHub through the MCP server. Each request turns orange as the harness catches it, runs it and sends the result back. A ring on the harness plate lights its four stations: lists tools, catches calls, runs code, returns results. The final reply asks for no tool and rises straight to you. Two lines at the bottom read: Claude Code equals interface plus harness, and Agent equals LLM plus harness." width="720" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The four jobs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lists the tools.&lt;/strong&gt; It tells the model which tools it may use. Each tool has a name, a description and an input schema (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catches the calls.&lt;/strong&gt; It reads the tool calls in the model's response and dispatches them. Over the API, calls arrive as structured blocks. Nobody has to search the answer text for them (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs them.&lt;/strong&gt; It checks that each call is allowed. Then it hands the call to the code that executes it. It can also say no (&lt;a href="https://code.claude.com/docs/en/agent-sdk/agent-loop" rel="noopener noreferrer"&gt;Agent SDK&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Returns the results.&lt;/strong&gt; It feeds each result back to the model and loops. The run ends with a final answer. Or something else stops it: a limit, an error, a refusal or a cancel (&lt;a href="https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What a real tool call looks like
&lt;/h2&gt;

&lt;p&gt;The tape in the animation says &lt;code&gt;&amp;lt;bash cmd="npm test"&amp;gt;&lt;/code&gt;. That's shorthand. It isn't Anthropic's or OpenAI's format. The model generates the request, and the API hands it to you as structured data.&lt;/p&gt;

&lt;p&gt;Say your harness defines a tool called &lt;code&gt;bash&lt;/code&gt; with a &lt;code&gt;command&lt;/code&gt; input. On Anthropic's Messages API, the response then carries a &lt;code&gt;tool_use&lt;/code&gt; block:&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;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool_use"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"toolu_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"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;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm test"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stop_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool_use"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ID is a placeholder. Thinking blocks and other fields are left out here. Your harness still sends them back.&lt;/p&gt;

&lt;p&gt;The harness runs the command. It appends the assistant's whole &lt;code&gt;content&lt;/code&gt; to the conversation. Then it adds a user message with a &lt;code&gt;tool_result&lt;/code&gt;, matched by ID:&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;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool_result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"tool_use_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"toolu_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;what npm test printed&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every call gets a result with its ID. Results come before any other text in that message (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;). Then the harness sends the conversation again. When the model is done, &lt;code&gt;stop_reason&lt;/code&gt; is &lt;code&gt;end_turn&lt;/code&gt; (&lt;a href="https://platform.claude.com/docs/en/api/messages/create" rel="noopener noreferrer"&gt;Messages API&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;OpenAI's Responses API works the same way, with other names. A &lt;code&gt;function_call&lt;/code&gt; item carries a &lt;code&gt;call_id&lt;/code&gt;. Your harness answers with a &lt;code&gt;function_call_output&lt;/code&gt; (&lt;a href="https://developers.openai.com/api/docs/guides/function-calling" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;Here's the whole loop with Anthropic's client, &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt;. It has one harmless tool: a clock. It needs &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; in the environment and runs as an ES module.&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="nx"&gt;Anthropic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@anthropic-ai/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// reads ANTHROPIC_API_KEY&lt;/span&gt;

&lt;span class="c1"&gt;// Lists tools: what the model may ask for. One harmless tool here.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Tool&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="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="s1"&gt;get_time&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Get the current UTC date and time.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;input_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;additionalProperties&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// Runs code: only the names you handle can run.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ToolUseBlock&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ToolResultBlockParam&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&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;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get_time&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool_result&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;tool_use_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown tool or invalid arguments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;is_error&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="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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool_result&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tool_use_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MessageParam&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-opus-5-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;messages&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="c1"&gt;// Keep every block, thinking included, exactly as it came back.&lt;/span&gt;
        &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end_turn&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;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&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;text&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;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool_use&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Stopped: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Catches calls: the tool_use blocks in the response.&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ToolUseBlock&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&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;tool_use&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Returns results: one for each call, all in one user message.&lt;/span&gt;
        &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runTool&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many turns&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;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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;What time is it in UTC?&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;A few things matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The whole &lt;code&gt;content&lt;/code&gt; goes back.&lt;/strong&gt; Claude Opus 5.5 always thinks (&lt;a href="https://platform.claude.com/docs/en/models/opus-5-5/overview" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;). Its thinking blocks must go back unchanged. So the loop never rebuilds history from text (&lt;a href="https://platform.claude.com/docs/en/build-with-claude/thinking-tool-workflows" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It stops on anything unexpected.&lt;/strong&gt; That covers &lt;code&gt;max_tokens&lt;/code&gt;, &lt;code&gt;refusal&lt;/code&gt;, and &lt;code&gt;pause_turn&lt;/code&gt; if you add server tools. Text alone doesn't mean the job is done. The &lt;code&gt;stop_reason&lt;/code&gt; says so (&lt;a href="https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's no &lt;code&gt;temperature&lt;/code&gt;.&lt;/strong&gt; API version 2026-10-21 rejects it, along with &lt;code&gt;top_p&lt;/code&gt; and &lt;code&gt;top_k&lt;/code&gt; (&lt;a href="https://platform.claude.com/docs/en/api/migration-2026-10-21" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;). The client sends that version by default (&lt;a href="https://platform.claude.com/docs/en/cli-sdks-libraries/sdks/typescript" rel="noopener noreferrer"&gt;TypeScript SDK&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The turn cap bounds requests.&lt;/strong&gt; It doesn't bound time or cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loop follows Anthropic's documented request and response shapes. It type-checks in strict mode against &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; 1.1.0. It also runs against a local mock of the API. It hasn't run against the live API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who runs the tool
&lt;/h2&gt;

&lt;p&gt;Not every tool runs inside your harness. Anthropic separates client tools from server tools (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client tools&lt;/strong&gt; run in your application's environment. Your harness executes them. The clock above is one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server tools&lt;/strong&gt; run on the provider's infrastructure. OpenAI's &lt;a href="https://developers.openai.com/api/docs/guides/tools-code-interpreter" rel="noopener noreferrer"&gt;Code Interpreter&lt;/a&gt; is one example: OpenAI runs the code for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP tools&lt;/strong&gt; run in an MCP server. The harness reaches them through an MCP client.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where MCP fits
&lt;/h2&gt;

&lt;p&gt;An MCP server offers tools, resources and prompts (&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;). It can run on your machine or on another one. A local server usually runs as a subprocess over &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio" rel="noopener noreferrer"&gt;stdio&lt;/a&gt;. A separately running server can use &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" rel="noopener noreferrer"&gt;Streamable HTTP&lt;/a&gt;, remote or on localhost.&lt;/p&gt;

&lt;p&gt;The host application runs one MCP client per server (&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/architecture" rel="noopener noreferrer"&gt;MCP architecture&lt;/a&gt;). That client calls the server's tools and hands the results back to the loop. To the model, an MCP tool can look like any other tool. The harness still handles the protocol, the login and the rules.&lt;/p&gt;

&lt;p&gt;In the animation, that's the GitHub call that leaves the plate. MCP is optional, though. The loop above uses none.&lt;/p&gt;

&lt;h2&gt;
  
  
  What production harnesses add
&lt;/h2&gt;

&lt;p&gt;The clock loop lacks compaction, approval controls and a sandbox. Coding-agent harnesses can provide these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context.&lt;/strong&gt; It decides what the model sees on each turn. The Claude Agent SDK compacts old history automatically near the context limit (&lt;a href="https://code.claude.com/docs/en/agent-sdk/agent-loop" rel="noopener noreferrer"&gt;Agent SDK&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions and approvals.&lt;/strong&gt; It decides which calls run alone and which wait for a person.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandboxing.&lt;/strong&gt; It limits what a command can reach. Codex documents how it enforces sandbox and approval rules (&lt;a href="https://developers.openai.com/blog/codex-as-a-platform" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding a shell means adding those limits too. Approvals aren't a detail. My coding agent once &lt;a href="https://argbe.tech/blog/ai-coding-agent-security?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=ai-agent-harness" rel="noopener noreferrer"&gt;opened a public tunnel without asking&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two products show the same split between interface and harness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code.&lt;/strong&gt; Its terminal app is the interface. The agent runtime under it is the harness. Anthropic ships the same tools and agent loop as the Claude Agent SDK, for Python and TypeScript (&lt;a href="https://code.claude.com/docs/en/agent-sdk/overview" rel="noopener noreferrer"&gt;Agent SDK overview&lt;/a&gt;). Anthropic calls it a harness too (&lt;a href="https://www.anthropic.com/research/effective-harnesses-for-long-running-agents" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;). The TypeScript package is &lt;code&gt;@anthropic-ai/claude-agent-sdk&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/agent-sdk/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;). The loop above uses the separate Messages API client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codex.&lt;/strong&gt; OpenAI calls the shared loop behind the Codex CLI, IDE extension and apps "the Codex harness" (&lt;a href="https://openai.com/index/unlocking-the-codex-harness/" rel="noopener noreferrer"&gt;OpenAI, February 4, 2026&lt;/a&gt;). Clients reach it through the App Server. That's a long-running process with a two-way JSON-RPC protocol (&lt;a href="https://learn.chatgpt.com/docs/app-server" rel="noopener noreferrer"&gt;App Server docs&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  LLM + harness = agent
&lt;/h2&gt;

&lt;p&gt;That's my shorthand. It's a teaching model, not a formal definition. Here, an agent is a model running in a harness. The harness manages context and tools. The model picks each next step. Anthropic draws a similar line between agents and fixed workflows (&lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My take: two "different" agents are often one model with different tools, instructions and MCP servers. OpenAI's Agents SDK builds an agent from those parts: instructions, a model and tools (&lt;a href="https://openai.github.io/openai-agents-python/agents/" rel="noopener noreferrer"&gt;OpenAI Agents SDK&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The maintained article is &lt;a href="https://argbe.tech/blog/ai-agent-harness?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=ai-agent-harness" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;. It has the animation as a video and every source.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Zaraz consent with your own prompt: the Consent API, Google Consent Mode and PostHog via Custom HTML</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:01:00 +0000</pubDate>
      <link>https://dev.to/argbetech/zaraz-consent-with-your-own-prompt-the-consent-api-google-consent-mode-and-posthog-via-custom-html-c7d</link>
      <guid>https://dev.to/argbetech/zaraz-consent-with-your-own-prompt-the-consent-api-google-consent-mode-and-posthog-via-custom-html-c7d</guid>
      <description>&lt;p&gt;Cloudflare Zaraz can run your consent without its own dialog. Set &lt;code&gt;consent.hideModal: true&lt;/code&gt;, ask with your own UI, and record the answers with &lt;code&gt;zaraz.consent.set()&lt;/code&gt;. Zaraz still decides which tools run.&lt;/p&gt;

&lt;p&gt;That's how my site works now. I really like the new consent prompt. It asks two questions, one at a time, and a small bot reacts to each answer. I think it's a better solution than the general static forms most sites show.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://argbe.tech/blog/zaraz-consent-prompt?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=zaraz-consent-prompt" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo9wu7ju40rrrbifn6go8.gif" alt="The consent prompt on a phone, docked over the privacy policy page. A small square bot sits beside the first question: Can I see how you use the site? Yes is pressed and the bot smiles. The second question follows: Can ad platforms count visits that came from an ad? No is pressed. The card says: Saved. Analytics is on; advertising measurement stays off. The bot smiles, then winks and sways goodbye, and the card slides away." width="720" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Behind it: GA4, PostHog, Google Ads and Microsoft Advertising, all gated by Zaraz. The site is Astro 7 on Cloudflare Workers. The code below is a minimal, generic version of what runs there, and it says what it leaves out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a prompt instead of a form
&lt;/h2&gt;

&lt;p&gt;A static consent form shows everything at once: legal text, toggles, a row of buttons. The EDPB's cookie banner taskforce listed what goes wrong: no refusal on the first layer, pre-ticked boxes, misleading refusal links, colours and contrast that push one answer (&lt;a href="https://www.edpb.europa.eu/system/files/2023-01/edpb_20230118_report_cookie_banner_taskforce_en.pdf" rel="noopener noreferrer"&gt;report&lt;/a&gt;). My prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Asks one question at a time, in plain words and in the page's language: "Can I see how you use the site?", then "Can ad platforms count visits that came from an ad?"&lt;/li&gt;
&lt;li&gt;Shows No and Yes as the same button, same size, side by side, No first. Each question takes one click, so refusing everything takes two, the same as accepting everything. A Reject all banner does it in one. CNIL says refusing should be as easy as accepting (&lt;a href="https://www.cnil.fr/en/dark-patterns-cookie-banners-cnil-issues-formal-notice-website-publishers" rel="noopener noreferrer"&gt;CNIL&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Is a non-modal card docked at the bottom. The page stays usable while it asks.&lt;/li&gt;
&lt;li&gt;Has a bot that looks at No and Yes with the same interest while you decide and is never sad about a No. A Yes gets a smile, a No a calm face. At the end it waves if anything was allowed and nods if both answers were No.&lt;/li&gt;
&lt;li&gt;Says what it saved and where to change it.&lt;/li&gt;
&lt;li&gt;Keeps no consent of its own. Zaraz stays the only consent authority.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Zaraz consent works
&lt;/h2&gt;

&lt;p&gt;Zaraz is Cloudflare's third-party tool manager, on every plan (&lt;a href="https://developers.cloudflare.com/zaraz/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). Catalog tools run as Managed Components, and the browser loads a lightweight loader from your domain, &lt;code&gt;/cdn-cgi/zaraz/i.js&lt;/code&gt; (&lt;a href="https://developers.cloudflare.com/zaraz/advanced/load-zaraz-manually/" rel="noopener noreferrer"&gt;manual loading&lt;/a&gt;). It isn't all server-side: a component can ask the browser to run JavaScript, make requests or set cookies (&lt;a href="https://developers.cloudflare.com/zaraz/advanced/load-custom-managed-component/" rel="noopener noreferrer"&gt;Managed Components&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Consent works by purpose (&lt;a href="https://developers.cloudflare.com/zaraz/consent-management/" rel="noopener noreferrer"&gt;consent management&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You create purposes and assign each tool to one.&lt;/li&gt;
&lt;li&gt;A tool waits until its purpose is granted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tool with no purpose skips consent checks.&lt;/strong&gt; Adding a tool doesn't give it a consent requirement.&lt;/li&gt;
&lt;li&gt;Choices live in a first-party cookie mapping purpose IDs to &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;. The cookie name is configurable; Cloudflare's example uses &lt;code&gt;cf_consent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;By default, Zaraz shows its modal to anyone who hasn't chosen. &lt;code&gt;consent.hideModal: true&lt;/code&gt; in the root &lt;code&gt;consent&lt;/code&gt; object of the configuration stops that, and the Consent API keeps working (&lt;a href="https://developers.cloudflare.com/api/resources/zaraz/subresources/config/methods/update/" rel="noopener noreferrer"&gt;config schema&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Consent API
&lt;/h2&gt;

&lt;p&gt;All of it (&lt;a href="https://developers.cloudflare.com/zaraz/consent-management/api/" rel="noopener noreferrer"&gt;Consent API&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIReady&lt;/span&gt; &lt;span class="c1"&gt;// boolean, read-only&lt;/span&gt;
&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purposes&lt;/span&gt; &lt;span class="c1"&gt;// read-only: id, name, description, order&lt;/span&gt;
&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;purposeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// true, false, or undefined for an unknown purpose&lt;/span&gt;
&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// every purpose's state&lt;/span&gt;
&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;purposeId&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="c1"&gt;// updates only the purposes you pass; returns undefined&lt;/span&gt;
&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAll&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="c1"&gt;// updates every purpose&lt;/span&gt;
&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendQueuedEvents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// sends pageview-based events withheld for lack of consent&lt;/span&gt;
&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;// opens Zaraz's own modal&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zarazConsentAPIReady&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zarazConsentChoicesUpdated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know. The setters return &lt;code&gt;undefined&lt;/code&gt;, not a receipt. And &lt;code&gt;sendQueuedEvents()&lt;/code&gt; only sends the withheld pageview-based events. It isn't documented to replay &lt;code&gt;zaraz.track()&lt;/code&gt; calls from before the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A two-question prompt
&lt;/h2&gt;

&lt;p&gt;The markup. &lt;code&gt;transition:persist&lt;/code&gt; is Astro's; drop it elsewhere:&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="c"&gt;&amp;lt;!-- In the layout, so it's on every page --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"consent"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Privacy choices"&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt; &lt;span class="na"&gt;transition:persist&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"consent-text"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"consent-detail"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"consent-error"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"alert"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"consent-answers"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- One element and one class for both answers: No is never the smaller option --&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"consent-answer"&lt;/span&gt; &lt;span class="na"&gt;data-answer=&lt;/span&gt;&lt;span class="s"&gt;"no"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;No&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"consent-answer"&lt;/span&gt; &lt;span class="na"&gt;data-answer=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Yes&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- In the footer --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;data-privacy-preferences&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Privacy preferences&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script is one module, in four parts. Purpose IDs and two helpers:&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;ANALYTICS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-analytics-purpose-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ADS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-ads-purpose-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COOKIE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf_consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// the consent cookie name in your Zaraz settings&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&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="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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;whenConsentReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;APIReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;else&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zarazConsentAPIReady&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Ask only when no choice is stored yet&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasChoice&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;part&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;part&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;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COOKIE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=`&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Google Consent Mode v2
&lt;/h3&gt;

&lt;p&gt;Zaraz supports Consent Mode v2 for GA4 and Google Ads (&lt;a href="https://developers.cloudflare.com/zaraz/advanced/google-consent-mode/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). Set denied defaults in the dashboard (Set Google Consent Mode v2 state) so they apply before tools load. Purposes don't map to Google's categories on their own, so send the stored choice yourself: when the API is ready, so a returning visitor's choice reaches Google, whenever the choice changes, and right after a save.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastGoogleState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;syncGoogleConsent&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;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAll&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;analytics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ANALYTICS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ADS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;analytics_storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;analytics&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ad_storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ads&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ad_user_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ads&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ad_personalization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// this example's policy: no question covers personalized ads&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&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;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastGoogleState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;// send only real changes&lt;/span&gt;
    &lt;span class="nx"&gt;lastGoogleState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;
    &lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google_consent_update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hardcoding &lt;code&gt;ad_personalization&lt;/code&gt; to denied is this example's policy: neither question covers personalized ads. The Google state is a signal. The purpose gate is what stops a tool from running. You need both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Asking and saving
&lt;/h3&gt;

&lt;p&gt;Answers stay in memory until the second question, then go to Zaraz in one call. A failed save shows a message and keeps the last question open:&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;QUESTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ANALYTICS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Can I see how you use the site?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Name the analytics tools and what they store.&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;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ADS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Can ad platforms count visits that came from an ad?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Name the ad platforms and what they store.&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;let&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;opener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;closeTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;
    &lt;span class="nf"&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;consent-text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;
    &lt;span class="nf"&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;consent-detail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;closeTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="nx"&gt;opener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byUser&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;activeElement&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="nf"&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;consent-error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
    &lt;span class="nf"&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;consent-answers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="nf"&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;consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&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;consent-text&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&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="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// both answers in one call&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;syncGoogleConsent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// Google's state first, then the held-back pageviews&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendQueuedEvents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;yes&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;step&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Nothing was recorded: say so, and let the last answer be given again&lt;/span&gt;
        &lt;span class="nf"&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;consent-error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your choice wasn't saved. Please answer again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&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;hadFocus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;contains&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;activeElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&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;consent-text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Saved. Change it any time under Privacy preferences.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="nf"&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;consent-detail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
    &lt;span class="nf"&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;consent-error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
    &lt;span class="nf"&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;consent-answers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;hadFocus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&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;consent-text&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;closeTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;focusInside&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;contains&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;activeElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&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;consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;focusInside&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opener&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-privacy-preferences]&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2500&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;h3&gt;
  
  
  Wiring it into Astro
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Astro runs a component's bundled script once per full page load, so these listeners never pile up&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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;event&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;target&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;target&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Element&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-answer], [data-privacy-preferences]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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;zaraz&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;APIReady&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-privacy-preferences&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;openPrompt&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="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yes&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;// A change made anywhere, Zaraz's own modal included&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zarazConsentChoicesUpdated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;syncGoogleConsent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After the first load and after every client-side navigation&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:page-load&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="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;whenConsentReady&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="nf"&gt;syncGoogleConsent&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="nf"&gt;hasChoice&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&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;consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;openPrompt&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="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;With &lt;code&gt;&amp;lt;ClientRouter /&amp;gt;&lt;/code&gt;, the document isn't replaced on navigation. &lt;code&gt;astro:page-load&lt;/code&gt; fires after the first load and after each client-side navigation, and &lt;code&gt;transition:persist&lt;/code&gt; keeps an open question on screen across one (&lt;a href="https://docs.astro.build/en/guides/view-transitions/" rel="noopener noreferrer"&gt;view transitions&lt;/a&gt;). Outside Astro, run that callback once on load.&lt;/p&gt;

&lt;p&gt;Left out: the bot and its animation, a summary naming what was turned on, and stepping aside while Zaraz's own modal is open, which only matters if that modal can still open. If Zaraz doesn't load, the prompt never appears and nothing optional runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting events to each tool
&lt;/h2&gt;

&lt;p&gt;Consent and delivery are separate. A purpose decides whether a tool may run; a trigger and an action decide what it receives (&lt;a href="https://developers.cloudflare.com/zaraz/reference/triggers/" rel="noopener noreferrer"&gt;triggers&lt;/a&gt;, &lt;a href="https://developers.cloudflare.com/zaraz/custom-actions/create-action/" rel="noopener noreferrer"&gt;actions&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Set up in Zaraz&lt;/th&gt;
&lt;th&gt;What it receives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GA4&lt;/td&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Catalog tool, with your measurement ID (&lt;code&gt;G-XXXXXXXXXX&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Page views and events, through actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostHog&lt;/td&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Custom HTML tool with the loader below&lt;/td&gt;
&lt;td&gt;The SDK's own capture calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Ads&lt;/td&gt;
&lt;td&gt;Advertising measurement&lt;/td&gt;
&lt;td&gt;Catalog tool, with your Google Ads conversion ID&lt;/td&gt;
&lt;td&gt;A pageview action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Advertising&lt;/td&gt;
&lt;td&gt;Advertising measurement&lt;/td&gt;
&lt;td&gt;Catalog tool listed as Bing, with your UET tag ID&lt;/td&gt;
&lt;td&gt;A pageview action&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Page views come from Zaraz's built-in &lt;code&gt;Pageview&lt;/code&gt; event: give each catalog tool a pageview action on the Pageview trigger. Don't mix up &lt;code&gt;Pageview&lt;/code&gt; (Zaraz's event), &lt;code&gt;zaraz.track('page_view')&lt;/code&gt; (a custom event you named) and the &lt;code&gt;pageview&lt;/code&gt; action type. GA4 turns a pageview action into &lt;code&gt;page_view&lt;/code&gt;; Bing turns it into &lt;code&gt;pageLoad&lt;/code&gt;, and an event action into &lt;code&gt;custom&lt;/code&gt; (&lt;a href="https://github.com/managed-components/bing/blob/2cf8432b8938417a260d68c64b489f9679a3f0fb/src/index.ts" rel="noopener noreferrer"&gt;Bing component&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A custom event goes through &lt;code&gt;zaraz.track(name, props)&lt;/code&gt; with a flat &lt;code&gt;props&lt;/code&gt; object (&lt;a href="https://developers.cloudflare.com/zaraz/web-api/track/" rel="noopener noreferrer"&gt;Track API&lt;/a&gt;). PostHog's SDK doesn't see that call, so capture it through PostHog's SDK too:&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;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&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;zaraz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ANALYTICS&lt;/span&gt;&lt;span class="p"&gt;)&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup_click&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;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&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;Then a trigger matches it by name and an action on the tool maps the fields (&lt;a href="https://developers.cloudflare.com/zaraz/custom-actions/create-trigger/" rel="noopener noreferrer"&gt;create a trigger&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Trigger          Signup click
  Rule           {{ client.__zarazTrack }}  equals  signup_click

GA4 action
  Action type    Event
  Firing trigger Signup click
  Event name     signup_click
  Field          location = {{ client.location }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{{ client.__zarazTrack }}&lt;/code&gt; is the event name and &lt;code&gt;{{ client.location }}&lt;/code&gt; its &lt;code&gt;location&lt;/code&gt; property (&lt;a href="https://developers.cloudflare.com/zaraz/reference/context/" rel="noopener noreferrer"&gt;context&lt;/a&gt;). Match the name exactly: a hostname or path rule alone matches every event on the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GA4.&lt;/strong&gt; Pass flat fields. The component adds the &lt;code&gt;ep.&lt;/code&gt;/&lt;code&gt;epn.&lt;/code&gt; prefixes itself, so pre-prefixing risks doubles (&lt;a href="https://github.com/managed-components/google-analytics-4/blob/f9ec6f67f32bad8b43ae093317948122dc641dce/src/requestBuilder.ts" rel="noopener noreferrer"&gt;request builder&lt;/a&gt;). Check two settings: originating-IP forwarding (&lt;code&gt;hideOriginalIP&lt;/code&gt;) and &lt;code&gt;ga-audiences&lt;/code&gt;. Audiences can make the browser call Google's ad endpoints (&lt;a href="https://github.com/managed-components/google-analytics-4/blob/f9ec6f67f32bad8b43ae093317948122dc641dce/src/index.ts" rel="noopener noreferrer"&gt;listeners&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Ads and Bing.&lt;/strong&gt; With advertising consent they get a page signal, so a later lead can be tied to its ad click through &lt;code&gt;gclid&lt;/code&gt; or &lt;code&gt;msclkid&lt;/code&gt;. The Ads component can run its conversion linker on a pageview without sending a conversion (&lt;a href="https://github.com/managed-components/google-ads/blob/da072705e87d9d34853bb96856ca4f979b04588a/src/index.ts" rel="noopener noreferrer"&gt;Ads component&lt;/a&gt;). The Bing component builds UET requests and sends them with &lt;code&gt;client.fetch&lt;/code&gt;, from the browser. On my site, that page signal is all they get from browsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostHog.&lt;/strong&gt; It isn't in the Zaraz catalog (&lt;a href="https://developers.cloudflare.com/zaraz/reference/supported-tools/" rel="noopener noreferrer"&gt;supported tools&lt;/a&gt;). I run it as a Custom HTML tool assigned to Analytics, so consent stays in one place. Custom HTML executes in the browser (&lt;a href="https://github.com/managed-components/custom-html/blob/14dad3233905257a293b6455d26cf16ed4ebc0b3/src/index.ts" rel="noopener noreferrer"&gt;source&lt;/a&gt;), so this is the regular JS SDK. Give the tool an action on the Pageview trigger, with this loader:&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="p"&gt;;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Zaraz can inject Custom HTML again on client-side pageviews: set up once, then only re-sync&lt;/span&gt;
        &lt;span class="k"&gt;if &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;__posthogConsent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;__posthogConsent&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;ANALYTICS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-analytics-purpose-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zaraz&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ANALYTICS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

        &lt;span class="c1"&gt;// Paste only the first statement of PostHog's web snippet here, the minified&lt;/span&gt;
        &lt;span class="c1"&gt;// !function(...) bootstrap. Leave out its &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;its&lt;/span&gt; &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;// loaded() runs the first sync&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has_opted_in_capturing&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opt_in_capturing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopSessionRecording&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opt_out_capturing&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;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__posthogConsent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt;

        &lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;project token&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://ph.example.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 first-party reverse proxy&lt;/span&gt;
            &lt;span class="na"&gt;ui_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://eu.posthog.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;opt_out_capturing_by_default&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="c1"&gt;// nothing is captured until sync() opts in&lt;/span&gt;
            &lt;span class="na"&gt;before_send&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;()&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// check consent per event; null drops it&lt;/span&gt;
            &lt;span class="na"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                &lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// the choice can change while the SDK downloads&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="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="s1"&gt;zarazConsentChoicesUpdated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&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;There's one &lt;code&gt;posthog.init()&lt;/code&gt;, and the SDK starts opted out; &lt;code&gt;loaded&lt;/code&gt; runs the first sync, and each change runs it again (&lt;a href="https://posthog.com/docs/libraries/js/config" rel="noopener noreferrer"&gt;config&lt;/a&gt;). &lt;code&gt;before_send&lt;/code&gt; can edit or drop an event (&lt;a href="https://posthog.com/docs/libraries/js/usage" rel="noopener noreferrer"&gt;usage&lt;/a&gt;). Withdrawal opts out and stops session recording, if you turned it on. No &lt;code&gt;cookieless_mode&lt;/code&gt; here, so a No means no PostHog capture, not cookieless measurement. Pick &lt;code&gt;persistence&lt;/code&gt; deliberately: &lt;code&gt;memory&lt;/code&gt; isn't the cookieless mode. &lt;code&gt;api_host&lt;/code&gt; points at your proxy and &lt;code&gt;ui_host&lt;/code&gt; at the PostHog app (&lt;a href="https://posthog.com/docs/advanced/proxy" rel="noopener noreferrer"&gt;proxy&lt;/a&gt;); EU Cloud is hosted in Frankfurt (&lt;a href="https://posthog.com/docs/privacy/data-storage" rel="noopener noreferrer"&gt;data storage&lt;/a&gt;). Zaraz's Ignore SPA option is the other way to stop repeat injection (&lt;a href="https://developers.cloudflare.com/zaraz/faq/" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping Zaraz changes
&lt;/h2&gt;

&lt;p&gt;Zaraz applies dashboard changes in real time by default. Turn on the Preview &amp;amp; Publish workflow and changes stage in a preview instead. &lt;code&gt;zaraz.preview('&amp;lt;debug key&amp;gt;')&lt;/code&gt; in the console reloads the page with the debugger and the preview config (&lt;a href="https://developers.cloudflare.com/zaraz/history/preview-mode/" rel="noopener noreferrer"&gt;Preview &amp;amp; Publish&lt;/a&gt;). History lists versions with date, user and description; restore one, then publish it (&lt;a href="https://developers.cloudflare.com/zaraz/history/versions/" rel="noopener noreferrer"&gt;versions&lt;/a&gt;). &lt;code&gt;zaraz.debug('&amp;lt;debug key&amp;gt;')&lt;/code&gt; shows events, triggers and component requests, which tells you what Zaraz did, not what the vendor accepted (&lt;a href="https://developers.cloudflare.com/zaraz/web-api/debug-mode/" rel="noopener noreferrer"&gt;debug mode&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Cloudflare's official &lt;code&gt;cf&lt;/code&gt; CLI, in open beta since 28 September 2026 (&lt;a href="https://blog.cloudflare.com/cloudflare-cf-cli-launch/" rel="noopener noreferrer"&gt;launch&lt;/a&gt;), wraps the Zaraz API (&lt;a href="https://developers.cloudflare.com/api/resources/zaraz/" rel="noopener noreferrer"&gt;API&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find a command by describing the task&lt;/span&gt;
cf cli search &lt;span class="s2"&gt;"export zaraz configuration"&lt;/span&gt;

&lt;span class="c"&gt;# "preview" stages changes; "realtime" publishes them at once&lt;/span&gt;
cf zaraz workflow get &lt;span class="nt"&gt;-z&lt;/span&gt; example.com

&lt;span class="c"&gt;# The export contains secret values: keep it out of git, logs and chat&lt;/span&gt;
cf zaraz &lt;span class="nb"&gt;export &lt;/span&gt;get &lt;span class="nt"&gt;-z&lt;/span&gt; example.com &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; private/zaraz-export.json

&lt;span class="c"&gt;# The versions you can restore&lt;/span&gt;
cf zaraz &lt;span class="nb"&gt;history &lt;/span&gt;list &lt;span class="nt"&gt;-z&lt;/span&gt; example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cf zaraz config update&lt;/code&gt; is a PUT of the whole configuration, not a patch, and it has &lt;code&gt;--dry-run&lt;/code&gt;. &lt;code&gt;cf zaraz publish create&lt;/code&gt; publishes the preview. &lt;code&gt;cf zaraz history update&lt;/code&gt; restores a version by ID. Check the workflow first: in &lt;code&gt;realtime&lt;/code&gt;, an update is live immediately. The CLI is a wrapper, not a safety net.&lt;/p&gt;

&lt;p&gt;On my site, an agent prepares the Zaraz change set, I approve the exact diff before anything is written to Cloudflare, and then it's published.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a change
&lt;/h2&gt;

&lt;p&gt;In the preview, with no consent cookie:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Answers&lt;/th&gt;
&lt;th&gt;Expect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;None yet&lt;/td&gt;
&lt;td&gt;No GA4, Google Ads, Bing or PostHog requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No, No&lt;/td&gt;
&lt;td&gt;Still none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yes, No&lt;/td&gt;
&lt;td&gt;GA4 and PostHog only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No, Yes&lt;/td&gt;
&lt;td&gt;Google Ads and Bing only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yes, Yes&lt;/td&gt;
&lt;td&gt;All four&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Repeat after a hard reload (the choice holds, no prompt) and a client-side navigation (nothing loads twice). Withdraw Analytics under Privacy preferences and check that GA4 and PostHog stop; grant it again and check that both resume.&lt;/p&gt;

&lt;p&gt;The Zaraz debugger shows triggers, actions and component requests. The Network panel shows what the page sends itself, including PostHog's SDK, which the debugger doesn't cover. Each vendor's event view shows what arrived. On Google's requests, read the consent values in &lt;code&gt;gcd&lt;/code&gt;, not just its presence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still on you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Zaraz loader and Consent API run before anyone answers. They have to, to record the first answer. "No JavaScript before consent" isn't true for this design.&lt;/li&gt;
&lt;li&gt;With a custom prompt, its fairness is on you, not Cloudflare. The EDPB calls visuals that make one choice feel rewarding and the other guilty "emotional steering" (&lt;a href="https://www.edpb.europa.eu/system/files/2023-02/edpb_03-2022_guidelines_on_deceptive_design_patterns_in_social_media_platform_interfaces_v2_en_0.pdf" rel="noopener noreferrer"&gt;guidelines&lt;/a&gt;, written for social media interfaces).&lt;/li&gt;
&lt;li&gt;After a yes, PostHog's SDK runs in the browser and Ads and Bing send requests from it. Masking in session recordings needs more than &lt;code&gt;maskAllInputs&lt;/code&gt; (&lt;a href="https://posthog.com/docs/session-replay/privacy" rel="noopener noreferrer"&gt;replay privacy&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Withdrawal stops future capture. It can't recall what was already sent.&lt;/li&gt;
&lt;li&gt;Every new tool needs a purpose, or it skips consent.&lt;/li&gt;
&lt;li&gt;Zaraz includes 1,000,000 events per account a month. Without paid usage, going past that turns Zaraz off until the next billing cycle (&lt;a href="https://developers.cloudflare.com/zaraz/pricing-info/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;If you need IAB TCF, Zaraz has an optional TCF-compliant modal (&lt;a href="https://developers.cloudflare.com/zaraz/consent-management/iab-tcf-compliance/" rel="noopener noreferrer"&gt;TCF&lt;/a&gt;). A two-question prompt isn't a TCF interface.&lt;/li&gt;
&lt;li&gt;None of this is legal advice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The maintained article, with the looping recording, every source and the last-checked date, is &lt;a href="https://argbe.tech/blog/zaraz-consent-prompt?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=zaraz-consent-prompt" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Denial of wallet: what a hard budget cap actually stops</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:11:00 +0000</pubDate>
      <link>https://dev.to/argbetech/denial-of-wallet-what-a-hard-budget-cap-actually-stops-22fb</link>
      <guid>https://dev.to/argbetech/denial-of-wallet-what-a-hard-budget-cap-actually-stops-22fb</guid>
      <description>&lt;p&gt;Simon Willison &lt;a href="https://simonwillison.net/2026/Oct/3/default-hard-budget-caps/" rel="noopener noreferrer"&gt;wrote on 2026-10-03&lt;/a&gt; that we need default hard budget caps on pretty much everything. I agree.&lt;/p&gt;

&lt;p&gt;Firebase has &lt;a href="https://firebase.google.com/docs/projects/billing/spend-caps" rel="noopener noreferrer"&gt;spend caps for some services&lt;/a&gt;. But its docs say they can take several minutes to kick in. Whatever gets spent in that time is still your bill.&lt;/p&gt;

&lt;p&gt;So you set a limit, but you're still not fully in control. That's terrifying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What denial of wallet means
&lt;/h2&gt;

&lt;p&gt;Denial of wallet is when someone runs up your usage on purpose, so that the bill breaks before the service does. OWASP lists it under &lt;a href="https://genai.owasp.org/llmrisk/llm102025-unbounded-consumption/" rel="noopener noreferrer"&gt;unbounded consumption&lt;/a&gt; for LLM applications: attackers "exploit the cost-per-use model of cloud-based AI services." A retry loop or a misconfigured deployment can run up the same bill without any attacker.&lt;/p&gt;

&lt;p&gt;OWASP's mitigations include rate limits, quotas and timeouts. They slow the spending down. A hard cap is what stops it, at a number you chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want from a cap
&lt;/h2&gt;

&lt;p&gt;I think financial control should belong to the user. Let me set a real ceiling. Stop spending when I reach it. Let me choose automatic or manual top-ups.&lt;/p&gt;

&lt;p&gt;When I see "spend cap", I expect a hard limit. Firebase's docs explicitly say otherwise: its spend caps &lt;a href="https://firebase.google.com/docs/projects/billing/spend-caps" rel="noopener noreferrer"&gt;"are not 'hard caps'"&lt;/a&gt;, and anything spent during the delay is billed as normal. If a provider calls it a cap and enforces it late, I think the provider should cover the difference.&lt;/p&gt;

&lt;p&gt;Agents make this even more important. They can help you deploy an app without explaining everything that can generate a bill. They can miss details and make mistakes too. Sometimes they act without asking, like the &lt;a href="https://argbe.tech/blog/ai-coding-agent-security?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=denial-of-wallet-hard-budget-caps" rel="noopener noreferrer"&gt;tunnel my coding agent opened&lt;/a&gt; to get files into Google Slides.&lt;/p&gt;

&lt;p&gt;Simon goes a step further: an agent that recommends a cloud provider should prefer ones with hard caps and warn inexperienced builders away from the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What providers actually stop
&lt;/h2&gt;

&lt;p&gt;Checked on 2026-10-21. These controls change often, so read the linked docs before relying on one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What happens at the limit&lt;/th&gt;
&lt;th&gt;What it doesn't cover&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://firebase.google.com/docs/projects/billing/spend-caps" rel="noopener noreferrer"&gt;Firebase&lt;/a&gt; and &lt;a href="https://docs.cloud.google.com/billing/docs/how-to/budgets-spend-caps" rel="noopener noreferrer"&gt;Google Cloud&lt;/a&gt; spend caps&lt;/td&gt;
&lt;td&gt;New usage of the capped service pauses for the rest of the month, after a delay that can be several minutes&lt;/td&gt;
&lt;td&gt;Firebase: AI Logic, App Hosting, Cloud Functions and Extensions only, in Preview on the Blaze plan. Google Cloud: Gemini API, Agent Platform, Cloud Run and Cloud Run functions. Usage during the delay is billed, and fixed compute and storage keep billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.aws.amazon.com/accounts/latest/reference/create-spend-limit.html" rel="noopener noreferrer"&gt;AWS project spend limits&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A project's resources stop when its pre-tax monthly cost reaches the limit&lt;/td&gt;
&lt;td&gt;Limited rollout, not every account. At most 10 projects, and the limit can't go below $20 or AWS's own estimate, whichever is higher. Data in a project left paused for 90 days is deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vercel.com/docs/spend-management" rel="noopener noreferrer"&gt;Vercel Spend Management&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Production deployments pause, if you turn that option on. A budget on its own only notifies&lt;/td&gt;
&lt;td&gt;Pro and eligible Enterprise teams. Usage is checked every few minutes, so charges can pass the threshold. AI Gateway, v0 and stored resources can keep billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://supabase.com/docs/guides/platform/cost-control" rel="noopener noreferrer"&gt;Supabase Spend Cap&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Covered usage past the plan quota is blocked, and the docs say it isn't charged&lt;/td&gt;
&lt;td&gt;Pro plan only. It follows the plan quota, not a dollar amount you pick. Compute, branching, read replicas and add-ons are excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.netlify.com/manage/accounts-and-billing/billing/billing-for-credit-based-plans/configure-auto-recharge/" rel="noopener noreferrer"&gt;Netlify credit-based plans&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;When credits run out, projects, deploys, requests and form submissions pause&lt;/td&gt;
&lt;td&gt;Auto recharge keeps buying credits until you turn it off. Legacy plans work differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developers.openai.com/api/docs/guides/spend-limits" rel="noopener noreferrer"&gt;OpenAI API spend limits&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Requests get a 429 error once an organization or project limit is reached, if you choose enforcement instead of alerts&lt;/td&gt;
&lt;td&gt;Enforcement isn't instant, and OpenAI says spend can slightly exceed the limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://platform.claude.com/docs/en/api/rate-limits" rel="noopener noreferrer"&gt;Claude API spend limits&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The Start, Build and Scale tiers each have a monthly cap that pauses API usage, and you can set lower limits per organization or workspace&lt;/td&gt;
&lt;td&gt;The Custom tier has no monthly cap; limits are arranged with the account team. The default workspace can't have its own limit. Claude subscriptions and billing through cloud providers are separate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developers.cloudflare.com/workers/platform/limits/" rel="noopener noreferrer"&gt;Cloudflare Workers Free&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Worker requests past 100,000 a day get an error until midnight UTC, or skip the Worker if the route fails open&lt;/td&gt;
&lt;td&gt;Other Cloudflare products have their own limits. &lt;a href="https://developers.cloudflare.com/workers/platform/pricing/" rel="noopener noreferrer"&gt;Workers pricing&lt;/a&gt; lists a CPU limit per request for paid Workers, not an account-wide dollar cap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Several of these really do stop spending. Each one stops a defined set of things, though, and the right-hand column is where the bill can still grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I chose for my own site
&lt;/h2&gt;

&lt;p&gt;My site, argbe.tech, runs on a plan with no paid overage, and the parts that agents or the public can trigger have hard daily limits and an off switch. Choosing that means a flood shows up as errors and unavailability rather than a bill.&lt;/p&gt;

&lt;p&gt;That lowers my cost exposure. It doesn't remove risk: the site can still be made unavailable or abused, and the caps don't cover everything.&lt;/p&gt;

&lt;p&gt;If you're adding an agent to a business workflow, give everything it can trigger a hard limit and a way to switch it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you deploy
&lt;/h2&gt;

&lt;p&gt;Check what your limit actually covers, what keeps billing after a shutdown, and how the provider handled past incidents. In more detail:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope.&lt;/strong&gt; Write down the account, plan, project and services the limit applies to, and whether it's in preview, in a limited rollout or switched on at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every billable line.&lt;/strong&gt; List what can bill: seats, requests, compute, database reads and writes, storage, egress, logs, backups, AI tokens and third-party APIs. Match each one to its own control. A paused frontend doesn't stop an API key used somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The action at the limit.&lt;/strong&gt; An email, a webhook, a paused project and a rejected request are different things. A budget number on its own proves little.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The delay.&lt;/strong&gt; Find out how long enforcement takes and what keeps running meanwhile. Set the cap below what you can afford to lose: a lower threshold buys headroom, not a guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top-ups and resets.&lt;/strong&gt; Check auto recharge, who can raise or remove the limit, and whether service comes back on its own next month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What users see.&lt;/strong&gt; Decide what happens at the limit: an error page, a queued order, a retry. Try it in a cheap test environment before real traffic finds it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What keeps billing after a shutdown.&lt;/strong&gt; Storage, fixed compute and subscriptions often continue. Check when paused data gets deleted: &lt;a href="https://docs.aws.amazon.com/accounts/latest/reference/create-spend-limit.html" rel="noopener noreferrer"&gt;AWS deletes it&lt;/a&gt; after a project sits paused for 90 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Past incidents.&lt;/strong&gt; Read how the provider handled earlier surprise bills in its own incident reports and refund terms, not in a thread about them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If an agent deployed it.&lt;/strong&gt; Ask for a list of everything it created and what each item bills for, then check that list against the provider's console.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;A shorter version of this first appeared &lt;a href="https://www.linkedin.com/posts/aleksandr-argunov_aiengineering-cloudcomputing-finops-share-7512399067457589248-rqj9" rel="noopener noreferrer"&gt;on LinkedIn&lt;/a&gt; on 2026-10-04. The maintained version, with every source and the date I last checked the provider limits, is &lt;a href="https://argbe.tech/blog/denial-of-wallet-hard-budget-caps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=denial-of-wallet-hard-budget-caps" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>security</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Affinity MCP vs code: why I make my thumbnails with a script</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/argbetech/affinity-mcp-vs-code-why-i-make-my-thumbnails-with-a-script-2dom</link>
      <guid>https://dev.to/argbetech/affinity-mcp-vs-code-why-i-make-my-thumbnails-with-a-script-2dom</guid>
      <description>&lt;p&gt;Failing is hard. Especially when the tutorial is already recorded.&lt;/p&gt;

&lt;p&gt;I wanted to show how an agent can make a good thumbnail with Affinity MCP. I tried it multiple times. It didn't work as expected.&lt;/p&gt;

&lt;p&gt;Affinity's &lt;a href="https://www.affinity.studio/blog/affinity-update-september-2026" rel="noopener noreferrer"&gt;September 2026 update&lt;/a&gt; added in-app scripting and an AI Connector for Claude. Affinity's pitch is that you &lt;a href="https://www.affinity.studio/blog/affinity-automation-scripting-claude" rel="noopener noreferrer"&gt;describe a process once, and Claude builds a reusable script&lt;/a&gt;. I connected it to Claude Code as an &lt;a href="https://argbe.tech/blog/webmcp-vs-mcp?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=affinity-mcp-code-first-thumbnails" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; server and asked for a finished thumbnail.&lt;/p&gt;

&lt;p&gt;Using &lt;a href="https://www.anthropic.com/claude-sonnet-5-5" rel="noopener noreferrer"&gt;Sonnet 5.5&lt;/a&gt; with high reasoning was better. Opus 5.5 on xHigh was just thinking the whole time, so slow that I even cancelled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt
&lt;/h2&gt;

&lt;p&gt;Here is the prompt I used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create an exceptional, finished, polished 1920 × 1080 LinkedIn video thumbnail in Affinity through MCP. Aim for award-winning, résumé-showreel quality. Go all out.

Create assets with codex imagen. You can reimagine my pose and restyle myself as needed, angle, camera, appearance. But make me look happy and smiling. And look at the user. Create the new asset together with details for max conversions. But keep in mind to request transparent background.

For other details use programmatic approach to make the scene complete.

Headline: "I hate making thumbnails."
Subtitle: "Made with Claude Code + Affinity MCP"
Subtle branding: favicon + "argbe.tech".

Add requirements graph.

- Affinity Studio
- Affinity MCP enabled
- Claude Code Affinity MCP enabled

You have 5 minutes. Fix obvious issues only; skip minor adjustments.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Code first
&lt;/h2&gt;

&lt;p&gt;I was so exhausted with the Affinity approach that I just tried code first. Same prompt, only the copy updated. And it worked much better.&lt;/p&gt;

&lt;p&gt;Code first means Claude Code writes a small Node script that builds the whole thumbnail as one SVG: the background, the headline, the branding and the photo of me as an embedded image. A command-line renderer then turns the SVG into a PNG. When I need extra assets, I create them with ChatGPT Image.&lt;/p&gt;

&lt;p&gt;A shortened version of the script from that run:&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;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;execSync&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="s1"&gt;node:child_process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="o"&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;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code-only/assets/alex-point.png&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="s1"&gt;code-only/assets/alex-point.png&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;assets/alex-cutout.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&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="nx"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&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;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;svg width="1920" height="1080" viewBox="0 0 1920 1080"
  xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"&amp;gt;
  &amp;lt;rect width="1920" height="1080" fill="#100C24"/&amp;gt;
  &amp;lt;image x="950" y="10" width="1120" height="1120"
    xlink:href="data:image/png;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"/&amp;gt;
  &amp;lt;text x="84" y="536" font-family="Helvetica Neue, Arial, sans-serif"
    font-weight="800" font-size="196" fill="#fff"&amp;gt;thumbnails.&amp;lt;/text&amp;gt;
  &amp;lt;!-- glows, grid, the rest of the headline, subtitle, favicon and "argbe.tech" --&amp;gt;
&amp;lt;/svg&amp;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;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code-only/scene.svg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rsvg-convert -w 1920 -h 1080 code-only/scene.svg -o code-only/thumbnail-code-only-1920x1080.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script is text. Claude Code can read all of it, change one coordinate, run it again and get the same image every time.&lt;/p&gt;

&lt;p&gt;The link previews on my site come from code too: a script renders one for each page when the site builds. If your product needs images like that, it's a small piece of software, not a design job.&lt;/p&gt;

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

&lt;p&gt;Affinity MCP took twelve prompts and seventeen minutes of active work. Code only took six prompts and three minutes fifty-four seconds.&lt;/p&gt;

&lt;p&gt;Those are my notes from these sessions, not a benchmark. The copy changed between the runs, and I tried more than one model on the Affinity side.&lt;/p&gt;

&lt;p&gt;The result is better than the original one. It finally generated me from a different angle. The proportions are still off. But at least it was fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  YouTube got there first
&lt;/h2&gt;

&lt;p&gt;I hate making thumbnails. But we are almost at the point where they are created automatically.&lt;/p&gt;

&lt;p&gt;When I first posted this, I predicted YouTube would soon offer it as a feature. It already had. On 2026-09-23, two weeks before my post, YouTube &lt;a href="https://blog.youtube/news-and-events/made-on-youtube-new-tools-power-creation-journey/" rel="noopener noreferrer"&gt;announced&lt;/a&gt; that creators can generate channel-matched thumbnails, and that Ask Studio can review older videos and suggest and test refreshed thumbnails. The announcement doesn't say who gets these features or when.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A shorter version of this first appeared on LinkedIn on 2026-10-07. The maintained version, with every source and the date I last checked them, is &lt;a href="https://argbe.tech/blog/affinity-mcp-code-first-thumbnails?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=affinity-mcp-code-first-thumbnails" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>mcp</category>
      <category>svg</category>
    </item>
    <item>
      <title>JavaScript number precision for large IDs, and ten new features for agent code</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:07:00 +0000</pubDate>
      <link>https://dev.to/argbetech/javascript-number-precision-for-large-ids-and-ten-new-features-for-agent-code-3oh1</link>
      <guid>https://dev.to/argbetech/javascript-number-precision-for-large-ids-and-ten-new-features-for-agent-code-3oh1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9rxa22ijfm9tgzahat9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9rxa22ijfm9tgzahat9.webp" alt="Ten new JavaScript features that shorten agent code, in two columns. Ships now: big IDs in tool results, where JSON.parse rounds 9007199254740993 to 9007199254740992 with no error and the source text keeps it exact; images for a model API; state per agent run; missing permissions; model text inside a regex. On the way: browser closes on a throw, dates across time zones, parallel tool calls, prompts as files and one trace per agent run." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A long numeric ID can come out of &lt;code&gt;JSON.parse&lt;/code&gt; as a different number, and nothing throws.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":1234567890123456789}&lt;/span&gt;&lt;span class="dl"&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;id&lt;/span&gt; &lt;span class="p"&gt;}&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;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;BigInt&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;// 1234567890123456768n&lt;/span&gt;
&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// '{"id":1234567890123456800}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JavaScript numbers are 64-bit floats. Every whole number up to 9,007,199,254,740,991 (&lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt;) is exact. Above that, only some whole numbers fit, and among 19-digit numbers they're 128 to 2,048 apart. &lt;code&gt;JSON.parse&lt;/code&gt; rounds the ID to the nearest one that fits (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;, &lt;a href="https://tc39.es/ecma262/multipage/structured-data.html#sec-json.parse" rel="noopener noreferrer"&gt;ECMA-262&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;So the ID changes twice. Parsing rounds it, which &lt;code&gt;BigInt(id)&lt;/code&gt; shows: the original digits are gone, and no conversion brings them back. Then &lt;code&gt;JSON.stringify&lt;/code&gt; writes the fewest digits that identify the float and fills the rest with zeros (&lt;a href="https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-numeric-types-number-tostring" rel="noopener noreferrer"&gt;ECMA-262&lt;/a&gt;). Even an ID that fits exactly can change that second way. Whatever reads the text next, the model or another API, gets a different ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which APIs send long IDs
&lt;/h2&gt;

&lt;p&gt;LinkedIn, X and Discord IDs can run to 19 digits. X and Discord send them as strings (&lt;a href="https://docs.x.com/x-api/posts/get-post-by-id" rel="noopener noreferrer"&gt;X&lt;/a&gt;, &lt;a href="https://docs.discord.com/developers/reference#snowflakes" rel="noopener noreferrer"&gt;Discord&lt;/a&gt;). Discord’s docs say why: its IDs are up to 64 bits, which would overflow as numbers in some languages. At 64 bits, an ID can even reach 20 digits. LinkedIn’s API uses strings as well, both for plain IDs and for URNs like &lt;code&gt;urn:li:share:1234&lt;/code&gt; (&lt;a href="https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In March, a bug report against Claude Code (&lt;a href="https://github.com/anthropics/claude-code/issues/37707" rel="noopener noreferrer"&gt;#37707&lt;/a&gt;) said its official Discord plugin turned channel IDs into numbers, and fetching messages failed. It was closed for inactivity on 20 April, so it neither confirms the cause nor shows a fix.&lt;/p&gt;

&lt;p&gt;So keep IDs as strings end to end: in your types, your schemas and the text you hand the model. The same goes for any integration that passes records between two systems, not only agent tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  When an API sends numbers anyway
&lt;/h2&gt;

&lt;p&gt;JavaScript now has a fix. A &lt;code&gt;JSON.parse&lt;/code&gt; reviver gets a third argument with each number’s original source text:&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":1234567890123456789}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&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;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&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;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;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="c1"&gt;// '1234567890123456789'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three limits (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It needs the raw response text. If an SDK has already parsed the response, a large ID may already be rounded, and &lt;code&gt;BigInt()&lt;/code&gt; on that number returns the rounded value, not the ID.&lt;/li&gt;
&lt;li&gt;Match the ID fields your schema names, not every number, and check the string against your ID format. JSON numbers can also be fractions or exponents like &lt;code&gt;1e21&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It needs Chrome 114, Firefox 135, Safari 18.4 or Node 21 and later (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/JSON.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;). On older runtimes the third argument is missing, and this callback throws.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This source-text reviver is the first of ten new features that shorten agent code. Five are in the language now, two are finished and expected in ES2027, and three are still proposals. Versions below are the first releases that support each feature, from MDN’s compatibility data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ships now: ES2025 and ES2026
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Numeric IDs from other APIs
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse" rel="noopener noreferrer"&gt;&lt;code&gt;JSON.parse&lt;/code&gt; source text&lt;/a&gt; · ES2026 · Chrome 114, Firefox 135, Safari 18.4, Node 21 (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/JSON.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before: &lt;code&gt;JSON.parse(text)&lt;/code&gt;, with the problem above. After: the reviver above.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Images for a model API
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64" rel="noopener noreferrer"&gt;&lt;code&gt;Uint8Array.prototype.toBase64()&lt;/code&gt;&lt;/a&gt; · ES2026 · Chrome 140, Firefox 133, Safari 18.2, Node 25 (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/Uint8Array.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before, which can hit the engine’s argument limit on large images:&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;// bytes: Uint8Array&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. State per agent run
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/getOrInsert" rel="noopener noreferrer"&gt;&lt;code&gt;Map.prototype.getOrInsert()&lt;/code&gt;&lt;/a&gt; · ES2026 · Chrome 145, Firefox 144, Safari 26.2, Node 26 (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/Map.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before:&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;// runs: Map&amp;lt;string, string[]&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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="p"&gt;[])&lt;/span&gt;
&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrInsert&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="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Missing permissions
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/difference" rel="noopener noreferrer"&gt;&lt;code&gt;Set.prototype.difference()&lt;/code&gt;&lt;/a&gt; · ES2025 · Chrome 122, Firefox 127, Safari 17, Node 22 (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/Set.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before:&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;// needed, granted: Set&amp;lt;string&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&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;Set&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;needed&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;granted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;needed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;difference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;granted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It finds what’s missing. Granting it is still your code’s decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Model text inside a regex
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape" rel="noopener noreferrer"&gt;&lt;code&gt;RegExp.escape()&lt;/code&gt;&lt;/a&gt; · ES2025 · Chrome 136, Firefox 134, Safari 18.2, Node 24 (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/RegExp.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before: a copied escape pattern, or none.&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$4.99+tax?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// from the model&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Total: $4.99+tax?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Total: $4.99+tax?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finished, expected in ES2027
&lt;/h2&gt;

&lt;p&gt;Both are finished proposals at Stage 4, due in the 2027 edition (&lt;a href="https://github.com/tc39/proposals/blob/main/finished-proposals.md" rel="noopener noreferrer"&gt;TC39&lt;/a&gt;). Stable Safari doesn’t have them yet, only Safari Technology Preview.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Closing the browser when a tool call throws
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/await_using" rel="noopener noreferrer"&gt;&lt;code&gt;await using&lt;/code&gt;&lt;/a&gt; · Chrome 134, Firefox 141, Node 24 (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/statements.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before:&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;browser&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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&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;await&lt;/span&gt; &lt;span class="nf"&gt;runTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;browser&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;After:&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;using&lt;/span&gt; &lt;span class="nx"&gt;browser&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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why: miss the &lt;code&gt;finally&lt;/code&gt;, and a failed browser task can leave Chromium running on your server. Playwright’s browser closes itself when the scope exits, even on a throw (&lt;a href="https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/browser.ts" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Dates across time zones
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime/add" rel="noopener noreferrer"&gt;&lt;code&gt;Temporal.ZonedDateTime.prototype.add()&lt;/code&gt;&lt;/a&gt; · Chrome 144, Firefox 139, Node 26 (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/Temporal.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before:&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;// t: epoch milliseconds for 15:00 on 21 October 2026 in Berlin&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;due&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;864&lt;/span&gt;&lt;span class="nx"&gt;e5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 14:00 on 28 October in Berlin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ZonedDateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-21T15:00[Europe/Berlin]&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;due&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// 2026-10-28T15:00:00+01:00[Europe/Berlin]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven days of milliseconds is 168 hours, and Berlin’s clocks go back on 25 October, so 3pm becomes 2pm. Temporal adds calendar days and keeps 3pm (&lt;a href="https://github.com/eggert/tz/blob/main/europe" rel="noopener noreferrer"&gt;tz database&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Still proposals
&lt;/h2&gt;

&lt;p&gt;These can still change. Some browsers already ship two of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Parallel tool calls
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/tc39/proposal-await-dictionary" rel="noopener noreferrer"&gt;&lt;code&gt;Promise.allKeyed&lt;/code&gt;&lt;/a&gt; · Stage 3 · Firefox 155; not yet in Chrome, Safari or Node (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/Promise.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before, with results matched by position:&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orders&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;getUser&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="nf"&gt;getOrders&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After, with results by name:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orders&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allKeyed&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getOrders&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why: add a tool call to &lt;code&gt;Promise.all&lt;/code&gt;, miss the destructuring, and results land in the wrong variables. How parallel calls to one API share a connection is a separate question, covered in &lt;a href="https://argbe.tech/blog/sse-vs-websockets-ai-apps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=javascript-number-precision-ids#pick-by-job" rel="noopener noreferrer"&gt;which web standard fits which job&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Prompts as files
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/tc39/proposal-import-text" rel="noopener noreferrer"&gt;Import Text&lt;/a&gt; · Stage 3 · Chrome 155, Firefox 153, Bun 1.1.5, Deno 2.8; not yet in Safari or Node (&lt;a href="https://github.com/mdn/browser-compat-data/blob/main/javascript/statements.json" rel="noopener noreferrer"&gt;compat data&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Before:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&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="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./prompt.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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;import&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./prompt.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  10. One trace per agent run
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/tc39/proposal-async-context" rel="noopener noreferrer"&gt;&lt;code&gt;AsyncContext&lt;/code&gt;&lt;/a&gt; · Stage 2, the least mature of the three.&lt;/p&gt;

&lt;p&gt;Before: a &lt;code&gt;runId&lt;/code&gt; passed through every call, or Node’s &lt;code&gt;AsyncLocalStorage&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AsyncLocalStorage&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="s1"&gt;node:async_hooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runId&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;AsyncLocalStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;runId&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;// anywhere deeper: log(runId.getStore(), message)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After: &lt;code&gt;AsyncContext&lt;/code&gt;, the same idea, meant for every runtime.&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;runId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;AsyncContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Variable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;runId&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;// anywhere deeper: log(runId.get(), message)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why: without a run ID on each log line, you can’t tell which run failed. For now it’s a direction to watch, not something to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtimes and types
&lt;/h2&gt;

&lt;p&gt;Node 26 runs features 1 to 7 and is scheduled to become LTS on 28 October 2026 (&lt;a href="https://github.com/nodejs/Release/issues/1152" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt;). Safari has 6 and 7 only in Technology Preview. TypeScript 7.1 beta, announced on 20 October, adds the ES2026 types (&lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/" rel="noopener noreferrer"&gt;TypeScript&lt;/a&gt;). Set TypeScript’s &lt;code&gt;lib&lt;/code&gt; to what your runtime has, not higher: the types don’t add the features.&lt;/p&gt;

&lt;p&gt;Then write your runtime and preferred APIs into CLAUDE.md or AGENTS.md, so your coding agent knows them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Runtime&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Node 26. Use Map.prototype.getOrInsert, Set methods, RegExp.escape and Temporal.
&lt;span class="p"&gt;-&lt;/span&gt; Keep IDs from external APIs as strings.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I first posted this &lt;a href="https://www.linkedin.com/posts/aleksandr-argunov_javascript-typescript-aiagents-share-7518572145740767232-AMS2" rel="noopener noreferrer"&gt;on LinkedIn&lt;/a&gt; on 21 October 2026. The maintained version, with every source and the date I last checked the support data, is &lt;a href="https://argbe.tech/blog/javascript-number-precision-ids?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=javascript-number-precision-ids" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>json</category>
      <category>ai</category>
    </item>
    <item>
      <title>WebMCP vs MCP: when an agent should go through the browser</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:47:00 +0000</pubDate>
      <link>https://dev.to/argbetech/webmcp-vs-mcp-when-an-agent-should-go-through-the-browser-4bec</link>
      <guid>https://dev.to/argbetech/webmcp-vs-mcp-when-an-agent-should-go-through-the-browser-4bec</guid>
      <description>&lt;p&gt;I've built a few WebMCP integrations, and I'm still not sure about it.&lt;/p&gt;

&lt;p&gt;The short version: WebMCP only makes sense to me when the agent is part of the browser, where you can watch and step in. When you work through chat, it feels unnatural. Why keep a browser involved when the agent can call the service directly? For a chat app, a desktop client, code or a job nobody watches, build a direct MCP connection. Shopify draws roughly the same line for its checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebMCP, browser-control tools and server MCP
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WebMCP&lt;/strong&gt; is a &lt;a href="https://github.com/webmachinelearning/webmcp" rel="noopener noreferrer"&gt;proposed web standard&lt;/a&gt;. The page registers tools, and the browser's built-in agent calls them, so the agent doesn't have to guess what a button does. Chrome runs it as an &lt;a href="https://developer.chrome.com/docs/ai/webmcp" rel="noopener noreferrer"&gt;origin trial from Chrome 149&lt;/a&gt;, with a flag for local development, and says the API may still change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser-control MCP tools&lt;/strong&gt; are MCP servers that operate a browser by clicking and typing. The site declares nothing, so the agent reads the page and works out what each element is for. Chrome calls this &lt;a href="https://developer.chrome.com/docs/ai/webmcp" rel="noopener noreferrer"&gt;actuation&lt;/a&gt; and describes WebMCP as the more reliable option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server MCP&lt;/strong&gt; means the service exposes its own tools, for example over MCP's &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" rel="noopener noreferrer"&gt;Streamable HTTP transport&lt;/a&gt;, which streams with &lt;a href="https://argbe.tech/blog/sse-vs-websockets-ai-apps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=webmcp-vs-mcp" rel="noopener noreferrer"&gt;SSE&lt;/a&gt;. No browser is involved. An MCP client such as ChatGPT Desktop or Claude Code calls the service directly, from a chat or from code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.chrome.com/docs/ai/webmcp/compare-mcp" rel="noopener noreferrer"&gt;Chrome's own comparison&lt;/a&gt; is clear that WebMCP doesn't replace MCP. WebMCP tools exist only while the page is open. An MCP server is there whenever a client calls it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found building both
&lt;/h2&gt;

&lt;p&gt;The latest WebMCP integration I built let agents work on screenplay projects in a video web service, using the interface and the functions behind it.&lt;/p&gt;

&lt;p&gt;It worked. But I ended up replacing it with a direct MCP connection. I could control projects from ChatGPT Desktop or Claude Code without keeping the web interface in the loop. In my workflow, I found it faster, and it used fewer tokens. Without the browser, it was also much easier to build automated agent workflows in code. I haven't gone back to the WebMCP version.&lt;/p&gt;

&lt;p&gt;The automation part follows from the design: code can reach WebMCP tools only through a browser that has the page loaded. A server MCP has no page to keep open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shopify checkout: two ways in
&lt;/h2&gt;

&lt;p&gt;Shopify &lt;a href="https://shopify.dev/changelog/posts/webmcp-support-for-checkout" rel="noopener noreferrer"&gt;added WebMCP support to checkout on 2026-09-28&lt;/a&gt;. Browser agents can now read and update eligible checkouts, and submit them once the buyer confirms. Shopify's docs describe this and a server option side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkout WebMCP&lt;/strong&gt; works on the checkout open in the buyer's tab. It registers &lt;code&gt;get_checkout&lt;/code&gt;, &lt;code&gt;update_checkout&lt;/code&gt; and &lt;code&gt;complete_checkout&lt;/code&gt;, plus &lt;code&gt;navigate_to_storefront&lt;/code&gt; when the store has an online storefront. Before calling &lt;code&gt;complete_checkout&lt;/code&gt;, the agent has to show the buyer the order and total and get their permission. Shop Pay login and payment challenges stay with the buyer, on the page. (&lt;a href="https://shopify.dev/docs/agents/carts-and-checkout/checkout-webmcp" rel="noopener noreferrer"&gt;Checkout WebMCP reference&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The tools only appear on eligible checkouts. &lt;a href="https://shopify.dev/docs/agents/carts-and-checkout" rel="noopener noreferrer"&gt;Shopify lists the exceptions&lt;/a&gt;: B2B checkouts, embedded and mobile-SDK checkouts, checkouts with another shop's merchandise, draft orders, order edits, payment collection, and the standard three-page checkout unless the buyer uses Shop Pay. &lt;code&gt;update_checkout&lt;/code&gt; also leaves line items alone; the buyer changes those on the page. If you build on Shopify, check that list against your store's checkout before planning around WebMCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkout MCP&lt;/strong&gt; runs from your agent's server and doesn't depend on the buyer's browser. It's a JSON-RPC endpoint at &lt;code&gt;https://{shop-domain}/api/ucp/mcp&lt;/code&gt;, and every request needs authentication or a signed request. When the buyer has to enter something or review the order, the agent hands them a checkout link to finish there. (&lt;a href="https://shopify.dev/docs/agents/carts-and-checkout/checkout-mcp" rel="noopener noreferrer"&gt;Checkout MCP reference&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Shopify's docs &lt;a href="https://shopify.dev/docs/agents/carts-and-checkout" rel="noopener noreferrer"&gt;recommend Checkout MCP&lt;/a&gt; unless the agent has to run in the buyer's browser. Either way, the buyer still approves the purchase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is heading
&lt;/h2&gt;

&lt;p&gt;Long term, I think agent-native will become the standard. The UI, WebMCP and direct MCP should all work with the same underlying logic and state. People and agents can then use whichever interface fits, without rebuilding the workflow for each one.&lt;/p&gt;

&lt;p&gt;Looks like Shopify is going this way too. Its WebMCP tools use &lt;a href="https://shopify.dev/changelog/posts/webmcp-support-for-checkout" rel="noopener noreferrer"&gt;the same state as the checkout UI&lt;/a&gt;, and both MCP options &lt;a href="https://shopify.dev/docs/agents/carts-and-checkout" rel="noopener noreferrer"&gt;use the same checkout object&lt;/a&gt;. That's the direction I'm most interested in.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A shorter version of this first appeared &lt;a href="https://www.linkedin.com/posts/aleksandr-argunov_shopify-webmcp-aiagents-share-7511125388266479616-jaWe" rel="noopener noreferrer"&gt;on LinkedIn&lt;/a&gt; on 2026-09-30. The maintained version, with the date I last checked Chrome's and Shopify's docs, is &lt;a href="https://argbe.tech/blog/webmcp-vs-mcp?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=webmcp-vs-mcp" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webmcp</category>
      <category>mcp</category>
      <category>ai</category>
      <category>shopify</category>
    </item>
    <item>
      <title>SSE vs WebSockets for AI apps: ten web standards and two patterns</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:21:00 +0000</pubDate>
      <link>https://dev.to/argbetech/sse-vs-websockets-for-ai-apps-ten-web-standards-and-two-patterns-1cn1</link>
      <guid>https://dev.to/argbetech/sse-vs-websockets-for-ai-apps-ten-web-standards-and-two-patterns-1cn1</guid>
      <description>&lt;p&gt;Out of the box, the official OpenAI and Anthropic Python SDKs talk HTTP/1.1, a protocol from 1997 (&lt;a href="https://www.rfc-editor.org/info/rfc2068/" rel="noopener noreferrer"&gt;RFC 2068&lt;/a&gt;). Both build on HTTPX2, which leaves HTTP/2 off unless you turn it on (&lt;a href="https://developers.openai.com/api/reference/python" rel="noopener noreferrer"&gt;OpenAI SDK&lt;/a&gt;, &lt;a href="https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_base_client.py" rel="noopener noreferrer"&gt;Anthropic SDK&lt;/a&gt;, &lt;a href="https://pydantic.dev/docs/httpx2/guides/http2/" rel="noopener noreferrer"&gt;HTTPX2&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Streaming answers? When they stream over HTTP, both APIs use Server-Sent Events, a W3C standard since 2015 (&lt;a href="https://developers.openai.com/api/docs/guides/streaming-responses" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;, &lt;a href="https://platform.claude.com/docs/en/build-with-claude/streaming" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;, &lt;a href="https://www.w3.org/standards/history/eventsource/" rel="noopener noreferrer"&gt;W3C&lt;/a&gt;). Live voice? OpenAI recommends WebRTC for browser and mobile clients (&lt;a href="https://developers.openai.com/api/docs/guides/voice-webrtc?voice-api=realtime" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;). Even MCP’s Streamable HTTP transport, which &lt;a href="https://argbe.tech/blog/webmcp-vs-mcp?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=sse-vs-websockets-ai-apps" rel="noopener noreferrer"&gt;remote MCP servers&lt;/a&gt; use, sends HTTP POST requests, with SSE for streaming. Since the 2026-07-28 revision, each POST gets back either JSON or its own SSE stream, and the old always-open GET stream is gone (&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Here are ten web standards and two patterns, with what each is good for in AI and where it falls short. Years mark standardization, not adoption. They don’t all compete: some are HTTP versions, others are transports, browser APIs or an authentication scheme, and several stack. HTTP/3 runs on QUIC (&lt;a href="https://www.rfc-editor.org/rfc/rfc9114.html" rel="noopener noreferrer"&gt;RFC 9114&lt;/a&gt;), and SSE works over any HTTP version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://argbe.tech/blog/sse-vs-websockets-ai-apps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=sse-vs-websockets-ai-apps" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxppxo9njkizjp44yc8em.webp" alt="How AI apps talk to servers: ten web standards and two patterns, from 1997 to 2026, each drawn as messages moving between your app and a server." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SSE or WebSockets?
&lt;/h2&gt;

&lt;p&gt;If only the server talks while an answer streams, SSE is often enough. Reach for WebSockets when both sides talk at once.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;SSE&lt;/th&gt;
&lt;th&gt;WebSocket&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;Server to client&lt;/td&gt;
&lt;td&gt;Both ways&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;UTF-8 text&lt;/td&gt;
&lt;td&gt;Text and binary frames&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs over&lt;/td&gt;
&lt;td&gt;An ordinary HTTP response&lt;/td&gt;
&lt;td&gt;Its own protocol, after an HTTP upgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use it for&lt;/td&gt;
&lt;td&gt;Streamed answers, progress, notifications&lt;/td&gt;
&lt;td&gt;Live sessions where both sides talk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standardized&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://html.spec.whatwg.org/multipage/server-sent-events.html" rel="noopener noreferrer"&gt;WHATWG HTML&lt;/a&gt;, W3C since 2015&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc6455.html" rel="noopener noreferrer"&gt;RFC 6455&lt;/a&gt;, in 2011&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With SSE, the client still talks back, just with separate HTTP requests. Binary data has to be encoded as text first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick by job
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need to&lt;/th&gt;
&lt;th&gt;Start with&lt;/th&gt;
&lt;th&gt;Spec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Make one API or tool call at a time&lt;/td&gt;
&lt;td&gt;HTTP/1.1 is fine&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9112.html" rel="noopener noreferrer"&gt;RFC 9112&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream a model’s answer, progress or notifications&lt;/td&gt;
&lt;td&gt;SSE&lt;/td&gt;
&lt;td&gt;&lt;a href="https://html.spec.whatwg.org/multipage/server-sent-events.html" rel="noopener noreferrer"&gt;WHATWG HTML&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a live session where both sides talk&lt;/td&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6455.html" rel="noopener noreferrer"&gt;RFC 6455&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add live voice or camera from a browser or phone&lt;/td&gt;
&lt;td&gt;WebRTC&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.w3.org/TR/webrtc/" rel="noopener noreferrer"&gt;W3C&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make several API or tool calls at once to one origin&lt;/td&gt;
&lt;td&gt;HTTP/2, or HTTP/3 on lossy networks&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9113.html" rel="noopener noreferrer"&gt;RFC 9113&lt;/a&gt;, &lt;a href="https://www.rfc-editor.org/rfc/rfc9114.html" rel="noopener noreferrer"&gt;RFC 9114&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a complex read-only search with a body&lt;/td&gt;
&lt;td&gt;HTTP QUERY, once every hop supports it&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc10008.html" rel="noopener noreferrer"&gt;RFC 10008&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mix must-arrive results with droppable live updates&lt;/td&gt;
&lt;td&gt;WebTransport&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.w3.org/TR/2026/CR-webtransport-20260730/" rel="noopener noreferrer"&gt;W3C CR&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show a site which agent is calling&lt;/td&gt;
&lt;td&gt;Web Bot Auth&lt;/td&gt;
&lt;td&gt;&lt;a href="https://datatracker.ietf.org/wg/webbotauth/documents/" rel="noopener noreferrer"&gt;IETF draft&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Follow a long job&lt;/td&gt;
&lt;td&gt;Polling, or a webhook to your server&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://modelcontextprotocol.io/extensions/tasks/overview" rel="noopener noreferrer"&gt;MCP Tasks&lt;/a&gt;, &lt;a href="https://developers.openai.com/api/docs/guides/webhooks" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Established
&lt;/h2&gt;

&lt;h3&gt;
  
  
  HTTP/1.1
&lt;/h3&gt;

&lt;p&gt;1997 · Simple requests and responses, first specified in &lt;a href="https://www.rfc-editor.org/info/rfc2068/" rel="noopener noreferrer"&gt;RFC 2068&lt;/a&gt;. &lt;a href="https://www.rfc-editor.org/rfc/rfc9112.html" rel="noopener noreferrer"&gt;RFC 9112&lt;/a&gt; is the current version.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; single API calls and tool calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; no multiplexing. Parallel requests usually need several connections, and pipelined responses still come back in order.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  WebSocket
&lt;/h3&gt;

&lt;p&gt;2011 · A two-way connection kept open, carrying text or binary frames (&lt;a href="https://www.rfc-editor.org/rfc/rfc6455.html" rel="noopener noreferrer"&gt;RFC 6455&lt;/a&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; live sessions where both sides talk, like server-side voice agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; for one-way text streaming, SSE is often enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  SSE
&lt;/h3&gt;

&lt;p&gt;2015 · Text pushed from server to client over HTTP (&lt;a href="https://html.spec.whatwg.org/multipage/server-sent-events.html" rel="noopener noreferrer"&gt;WHATWG HTML&lt;/a&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; streamed answers, progress updates and notifications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; no binary messages, and no two-way traffic on the same stream.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  HTTP/2
&lt;/h3&gt;

&lt;p&gt;2015, updated 2022 · Many streams over one TCP connection. &lt;a href="https://www.rfc-editor.org/rfc/rfc9113.html" rel="noopener noreferrer"&gt;RFC 9113&lt;/a&gt; replaced the original &lt;a href="https://www.rfc-editor.org/rfc/rfc7540" rel="noopener noreferrer"&gt;RFC 7540&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; an agent making several API or tool calls at once to the same origin. Native gRPC uses it (&lt;a href="https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md" rel="noopener noreferrer"&gt;gRPC&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; one lost TCP packet can delay every stream on that connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  QUIC
&lt;/h3&gt;

&lt;p&gt;2021 · Encrypted transport over UDP with independent streams (&lt;a href="https://www.rfc-editor.org/rfc/rfc9000.html" rel="noopener noreferrer"&gt;RFC 9000&lt;/a&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; a connection can survive a device changing networks, if the server allows it (&lt;a href="https://www.rfc-editor.org/rfc/rfc9000.html#section-9" rel="noopener noreferrer"&gt;§9&lt;/a&gt;). A lost packet holds up only the streams with data in it, which can be several, while the rest keep going (&lt;a href="https://www.rfc-editor.org/rfc/rfc9000.html#section-13" rel="noopener noreferrer"&gt;§13&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; some networks block UDP. Fallback is up to the app (&lt;a href="https://www.rfc-editor.org/rfc/rfc9114.html" rel="noopener noreferrer"&gt;RFC 9114&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  WebRTC
&lt;/h3&gt;

&lt;p&gt;2021 · Real-time audio, video and data. It first became a &lt;a href="https://www.w3.org/TR/webrtc/" rel="noopener noreferrer"&gt;W3C Recommendation&lt;/a&gt; in &lt;a href="https://www.w3.org/press-releases/2021/webrtc-rec/" rel="noopener noreferrer"&gt;January 2021&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; live voice and camera with a model, from a browser or phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; often unnecessary for text. It needs connection setup, and maybe a relay server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  HTTP/3
&lt;/h3&gt;

&lt;p&gt;2022 · HTTP over QUIC instead of TCP (&lt;a href="https://www.rfc-editor.org/rfc/rfc9114.html" rel="noopener noreferrer"&gt;RFC 9114&lt;/a&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; it can cut setup time and cross-stream delays on lossy networks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; making the model faster.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  New standard
&lt;/h2&gt;

&lt;h3&gt;
  
  
  HTTP QUERY
&lt;/h3&gt;

&lt;p&gt;June 2026 · Like GET, but with a request body. It’s safe and idempotent (&lt;a href="https://www.rfc-editor.org/rfc/rfc10008.html" rel="noopener noreferrer"&gt;RFC 10008&lt;/a&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; complex read-only searches like AI retrieval, with safe retries and caching where supported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; relying on it before your whole stack supports it: client, CDN, proxies and server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Still developing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  WebTransport
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.w3.org/TR/2026/CR-webtransport-20260730/" rel="noopener noreferrer"&gt;W3C Candidate Recommendation&lt;/a&gt; · Reliable streams plus droppable datagrams. Major browsers have shipped it since March 2026; older ones and embedded webviews need a check (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebTransport" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; results that must arrive next to live updates that can go stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; code that can’t absorb changes. The IETF protocol is still a draft (&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/" rel="noopener noreferrer"&gt;IETF&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Web Bot Auth
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://datatracker.ietf.org/wg/webbotauth/documents/" rel="noopener noreferrer"&gt;IETF draft&lt;/a&gt; · Signed requests that show a site which agent is calling.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; telling real agents from bots posing as them. Cloudflare already checks these signatures (&lt;a href="https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; deciding what an agent may do. It proves identity, not &lt;a href="https://argbe.tech/blog/ai-coding-agent-security?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=sse-vs-websockets-ai-apps" rel="noopener noreferrer"&gt;permission&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Patterns, not protocols
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Polling
&lt;/h3&gt;

&lt;p&gt;Asking the server again and again whether something changed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; long AI jobs where checking every few seconds is fine, like MCP tasks. Where client and server support the &lt;a href="https://modelcontextprotocol.io/extensions/tasks/overview" rel="noopener noreferrer"&gt;Tasks extension&lt;/a&gt;, each job gets a handle to poll, and a client can pick it up again after reconnecting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; live updates. You either waste requests or lag behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Webhooks
&lt;/h3&gt;

&lt;p&gt;An HTTP request a service sends you when something happens.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for:&lt;/strong&gt; telling your system that a batch run or agent task has finished. OpenAI, for one, sends them for batch and background jobs (&lt;a href="https://developers.openai.com/api/docs/guides/webhooks" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Falls short:&lt;/strong&gt; they can’t reach a browser tab directly. Receiving them is a small integration of its own: a server that verifies signatures, handles duplicate deliveries and passes the event on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I first shared this list &lt;a href="https://www.linkedin.com/posts/aleksandr-argunov_aiagents-webdevelopment-softwareengineering-share-7518197240377634816-eqYw" rel="noopener noreferrer"&gt;on LinkedIn&lt;/a&gt; on 20 October 2026. The maintained version, with the animated diagram, every source and the date I last checked them, is &lt;a href="https://argbe.tech/blog/sse-vs-websockets-ai-apps?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=sse-vs-websockets-ai-apps" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>websockets</category>
      <category>http</category>
    </item>
    <item>
      <title>AI-generated unit tests: two patterns that pass with the bug, and a red-green fix</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:49:54 +0000</pubDate>
      <link>https://dev.to/argbetech/ai-generated-unit-tests-two-patterns-that-pass-with-the-bug-and-a-red-green-fix-11kh</link>
      <guid>https://dev.to/argbetech/ai-generated-unit-tests-two-patterns-that-pass-with-the-bug-and-a-red-green-fix-11kh</guid>
      <description>&lt;p&gt;Two tests can pass while &lt;code&gt;applyDiscount(100)&lt;/code&gt; returns 100 instead of the ticket's 90. One replaces the function with a mock. The other copies its boundary bug into the expected value.&lt;/p&gt;

&lt;p&gt;Agents love to create a thousand unit tests. All green. Then review smashes the whole thing.&lt;/p&gt;

&lt;p&gt;The agent already implemented a trash solution, and only then wrote generalized unit tests for it. Those tests describe the code it just wrote. So they all pass, bugs included.&lt;/p&gt;

&lt;p&gt;When an agent writes both the code and its tests, don't count the green checks. Look at where each expected value comes from. If it comes from the agent's code, the test can repeat the agent's mistake instead of catching it. If it comes from the ticket, the spec or a real example, and the test runs the real code and failed because the behavior was missing, it checks something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two patterns that pass with the bug
&lt;/h2&gt;

&lt;p&gt;Here are both in TypeScript with Vitest. Suppose an agent writes this for a discount ticket:&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="c1"&gt;// pricing.ts. The ticket: orders of 100 or more get 10% off.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="c1"&gt;// bug: should be &amp;gt;=&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test that mocks the function it's supposed to test:&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="c1"&gt;// mocked.test.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vi&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="s1"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;applyDiscount&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="s1"&gt;./pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pricing&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gives 10% off at 100&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// checks the mock, not the code&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test that calculates the expected value with the code's formula:&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="c1"&gt;// same-formula.test.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&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="s1"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;applyDiscount&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="s1"&gt;./pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;applies the discount&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="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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="c1"&gt;// the code's own formula, bug included&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both pass, and the code still charges 100 for an order of exactly 100. With the mock, the real &lt;code&gt;applyDiscount&lt;/code&gt; never runs. Mocking a dependency, like a payment service, is a different and legitimate use (&lt;a href="https://abseil.io/resources/swe-book/html/ch13.html" rel="noopener noreferrer"&gt;Software Engineering at Google&lt;/a&gt;). Copying the formula copies the bug. Google's testing blog shows the same trap in &lt;a href="https://testing.googleblog.com/2014/07/testing-on-toilet-dont-put-logic-in.html" rel="noopener noreferrer"&gt;Don't Put Logic in Tests&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Researchers have measured a version of this. In a July 2026 study, 11 models in 13 model and mode setups wrote tests for 318 Java methods behind 233 real bugs. Averaged across those setups, 3.84% of the tests written from the buggy code passed on the bug and failed on the fix. Written from the fixed code, 0.46% did (&lt;a href="https://arxiv.org/html/2607.22883v1#S3.SS1" rel="noopener noreferrer"&gt;Zhao, Zhou and Cohen&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  More tests don't fix it
&lt;/h2&gt;

&lt;p&gt;In a study first posted in February 2026 on 500 SWE-bench Verified tasks, pushing agents to write more tests barely changed how many they solved (&lt;a href="https://arxiv.org/html/2602.07900v2#S5" rel="noopener noreferrer"&gt;Chen et al.&lt;/a&gt;). GPT-5.2 solved 359 tasks either way, and Gemini 3 Pro Preview went from 371 to 366. Telling Kimi K2 Thinking and DeepSeek v3.2 Reasoner not to write tests moved them from 317 to 304 and from 300 to 291. None of these changes was statistically significant.&lt;/p&gt;

&lt;p&gt;The cost depended on the model: GPT-5.2 made more API calls and used more tokens without solving more, and the two models told to skip tests used fewer (&lt;a href="https://arxiv.org/html/2602.07900v2#S5" rel="noopener noreferrer"&gt;results&lt;/a&gt;). It's one benchmark and one agent scaffold, mini-SWE-agent, and the study didn't test TDD or the workflow below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red and green slices, then review
&lt;/h2&gt;

&lt;p&gt;In my experience, TDD with small red and green slices, and reviewers after, works much better:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define what done means, in a way that can fail.&lt;/li&gt;
&lt;li&gt;Take the expected values from something the agent didn't write: the ticket, the spec, a real example.&lt;/li&gt;
&lt;li&gt;Write one failing test (red). It has to fail because the behavior is missing.&lt;/li&gt;
&lt;li&gt;Write only enough code to pass it (green).&lt;/li&gt;
&lt;li&gt;Next slice. Reviewers come in after green.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the discount, the test takes its expected value straight from the ticket:&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="c1"&gt;// spec.test.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&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="s1"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;applyDiscount&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="s1"&gt;./pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// From the ticket: an order of exactly 100 pays 90.&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gives 10% off an order of exactly 100&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&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;Run against the agent's code, it fails red with &lt;code&gt;expected 100 to be 90&lt;/code&gt;. Change &lt;code&gt;&amp;gt;&lt;/code&gt; to &lt;code&gt;&amp;gt;=&lt;/code&gt; and it goes green. The mock test still passes, because it never ran the code. The formula test now fails: it was guarding the bug. Checked with Vitest 4.1.12: save the four blocks under their file names and run &lt;code&gt;npx vitest run&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mattpocock/skills/blob/3f599139104dfceabc7754eeb91319802f85e042/skills/engineering/tdd/SKILL.md" rel="noopener noreferrer"&gt;Matt Pocock's &lt;code&gt;/tdd&lt;/code&gt; skill&lt;/a&gt; is his version of this workflow. One slice at a time, and writing all the tests up front is an anti-pattern there. Refactoring belongs to review. That part is a choice: &lt;a href="https://newsletter.kentbeck.com/p/canon-tdd" rel="noopener noreferrer"&gt;Kent Beck's Canon TDD&lt;/a&gt; lets you refactor inside each cycle, after green.&lt;/p&gt;

&lt;p&gt;Lauren Tan (poteto), the author of &lt;a href="https://github.com/cursor/plugins/tree/eb4db2b55b2b7dec3d9282754cddf0a56bf4ff10/pstack" rel="noopener noreferrer"&gt;pstack&lt;/a&gt;, builds step 1 into it. Its &lt;a href="https://github.com/cursor/plugins/blob/ce649c03ac3b75b6ae80cbd15b7e2c536caedb5a/pstack/skills/figure-it-out/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;/figure-it-out&lt;/code&gt;&lt;/a&gt; skill asks the agent to state what done means, in a way that can fail, before it starts the run.&lt;/p&gt;

&lt;p&gt;TDD and &lt;code&gt;/figure-it-out&lt;/code&gt; can work magic when you understand what you are doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each check covers
&lt;/h2&gt;

&lt;p&gt;Even good unit tests are one row of this chart. Here's what every row covers. The When column is my rough rhythm, not a rule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://argbe.tech/blog/ai-generated-unit-tests?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=ai-generated-unit-tests" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgihw88c191r2rowqjok3.gif" alt="Animated chart titled What your 1,000 tests actually check. Ten rows show when each check runs and what it covers, across the screen, server and database of your app and an outside payment service. Unit, every edit: one function, alone. Mocks, inside unit tests: a fake stand-in for a real service. Smoke, every build: does it even start? Integration, every PR: do the parts work together? Regression, every PR: did the change break something old? Review, after green: a second pair of eyes, human or model. End-to-end, before release: a real user flow, start to finish. Security, before release: can someone get what isn't theirs? Mutation, rarely: plant a bug, see if any test notices. Set apart in gold at the bottom, Manual, by a human: actually uses it." width="600" height="750"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit&lt;/td&gt;
&lt;td&gt;Every edit&lt;/td&gt;
&lt;td&gt;A small unit of behavior, run fast. The unit can be one function or a few that work together.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://martinfowler.com/bliki/UnitTest.html" rel="noopener noreferrer"&gt;Fowler&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mocks&lt;/td&gt;
&lt;td&gt;In unit tests and narrow integration tests&lt;/td&gt;
&lt;td&gt;Stand-ins for real collaborators, like a payment service. A mock also checks how it was called.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://martinfowler.com/bliki/TestDouble.html" rel="noopener noreferrer"&gt;Fowler&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smoke&lt;/td&gt;
&lt;td&gt;Every build&lt;/td&gt;
&lt;td&gt;Does it start, and do the critical basics work, before deeper testing?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://glossary.istqb.org/en_US/term/smoke-testing" rel="noopener noreferrer"&gt;ISTQB&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration&lt;/td&gt;
&lt;td&gt;Every PR&lt;/td&gt;
&lt;td&gt;Do separately built parts work together once connected?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://martinfowler.com/bliki/IntegrationTest.html" rel="noopener noreferrer"&gt;Fowler&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regression&lt;/td&gt;
&lt;td&gt;Every PR&lt;/td&gt;
&lt;td&gt;Did the change break something that used to work? Unit, integration and end-to-end tests can all do this job.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://api.glossary.istqb.org/storage/help/R0uz58NqLzUz48LVUuyGSF76NFj4LHQazSs0GlNS.pdf" rel="noopener noreferrer"&gt;ISTQB&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review&lt;/td&gt;
&lt;td&gt;After green&lt;/td&gt;
&lt;td&gt;Someone other than the author reads the change: a person or a review model.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://google.github.io/eng-practices/review/" rel="noopener noreferrer"&gt;Google&lt;/a&gt;, &lt;a href="https://docs.github.com/en/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/code-review" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-to-end&lt;/td&gt;
&lt;td&gt;Before release&lt;/td&gt;
&lt;td&gt;A whole user flow, start to finish, in production-like conditions.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://glossary.istqb.org/en_US/term/end-to-end-testing" rel="noopener noreferrer"&gt;ISTQB&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Throughout development&lt;/td&gt;
&lt;td&gt;Whether the security controls hold. "Can someone get what isn't theirs?" is one question of many.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wstg.owasp.org/v4.2/2-Introduction/" rel="noopener noreferrer"&gt;OWASP&lt;/a&gt;, &lt;a href="https://wstg.owasp.org/v4.2/3-The_OWASP_Testing_Framework/0-The_Web_Security_Testing_Framework/" rel="noopener noreferrer"&gt;framework&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mutation&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;Change the code on purpose and see whether any test fails. A surviving change isn't always a real bug.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://stryker-mutator.io/docs/" rel="noopener noreferrer"&gt;Stryker&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;By a human&lt;/td&gt;
&lt;td&gt;A person uses the product and checks that it behaves as expected.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.atlassian.com/continuous-delivery/software-testing" rel="noopener noreferrer"&gt;Atlassian&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The maintained article, with the animated chart, source links and the last-checked date, is &lt;a href="https://argbe.tech/blog/ai-generated-unit-tests?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=ai-generated-unit-tests" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>tdd</category>
      <category>typescript</category>
    </item>
    <item>
      <title>AI coding security: the tunnel my agent opened without asking</title>
      <dc:creator>Aleksandr Argunov</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:45:02 +0000</pubDate>
      <link>https://dev.to/argbetech/ai-coding-security-the-tunnel-my-agent-opened-without-asking-4ldn</link>
      <guid>https://dev.to/argbetech/ai-coding-security-the-tunnel-my-agent-opened-without-asking-4ldn</guid>
      <description>&lt;p&gt;In October 2026, Claude Code opened a Cloudflare tunnel on my machine. The Google Slides connector couldn't import my media files, so the agent put them on a public URL and let Google fetch them from there.&lt;/p&gt;

&lt;p&gt;It was a smart workaround, and it didn't ask for approval or warn me. The command just went by in its output, and I only caught it because I was reading along. While it ran, anyone with that link could reach those files on my machine.&lt;/p&gt;

&lt;p&gt;That was one session, with my settings. But most people won't be reading along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the agent wanted a public URL
&lt;/h2&gt;

&lt;p&gt;Google's Slides API inserts an image from a URL only if &lt;a href="https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/request#CreateImageRequest" rel="noopener noreferrer"&gt;the URL is publicly accessible&lt;/a&gt;. It fetches the image once, at insertion, and keeps its own copy. So the files only had to be public while Google fetched them.&lt;/p&gt;

&lt;p&gt;A tunnel is a quick way to get a public URL. &lt;a href="https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/" rel="noopener noreferrer"&gt;Cloudflare's quick tunnels&lt;/a&gt; give a local service a temporary address, and by default anyone with that address can reach the service until the &lt;code&gt;cloudflared&lt;/code&gt; process stops.&lt;/p&gt;

&lt;p&gt;The workaround wasn't the problem. Doing it without asking was. Files only my machine could read became reachable from the internet, and the only notice was a line in the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check before an agent can expose anything
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Make the agent ask before anything that opens your machine. Tunnels, port forwarding, public share links and public buckets all turn something private into something reachable.&lt;/li&gt;
&lt;li&gt;Keep exposure as short as the job. If a service needs to fetch a file once, the tunnel can close right after; stopping &lt;code&gt;cloudflared&lt;/code&gt; ends access for everyone.&lt;/li&gt;
&lt;li&gt;Before an agent connects your email, files or calendar to an &lt;a href="https://argbe.tech/blog/webmcp-vs-mcp?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=ai-coding-agent-security" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;, check who wrote the server and what it can reach. MCP's &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices" rel="noopener noreferrer"&gt;security best practices&lt;/a&gt; describe the authorization risks to look for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agents in a business workflow need the same rule: a person approves anything that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Software isn't only prototyping
&lt;/h2&gt;

&lt;p&gt;Prototyping slop is solved. Pieter Levels calls it &lt;a href="https://levels.io/era-of-personal-software" rel="noopener noreferrer"&gt;"software is dead."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;He's right that prototyping got cheap. But software isn't only prototyping. It takes time, effort, money and users before one approach is good enough for many people to reuse.&lt;/p&gt;

&lt;p&gt;One developer has been publishing &lt;a href="https://github.com/storytold/craft-launcher" rel="noopener noreferrer"&gt;the Craft apps&lt;/a&gt;, an early-alpha suite for image editing, documents, slides and more, each written from scratch in Rust. Even one of them, PhotoCraft, stands on hundreds of open source libraries: &lt;a href="https://github.com/storytold/photocraft/blob/1e2bea1060c3b281fea825cf367bc1317e3c75c5/Cargo.lock" rel="noopener noreferrer"&gt;its lockfile on 20 October&lt;/a&gt; listed 839 packages, counting its own crates and everything they pull in, and 803 of them came from crates.io. I don't think what a chatbot builds for you usually gets that far.&lt;/p&gt;

&lt;p&gt;I think most people will just ask for something and let the agent in the chat handle it. Chat apps can already connect to &lt;a href="https://support.claude.com/en/articles/10166901-use-google-workspace-connectors" rel="noopener noreferrer"&gt;files, email and calendars&lt;/a&gt;. For a lot of people the chat will be the only UI they use. They're about to own software and not know it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whose responsibility it is
&lt;/h2&gt;

&lt;p&gt;Now picture your agent wiring your email to an MCP server some stranger wrote. A &lt;a href="https://www.trendaisecurity.com/en-us/resources-insights/deep-research/stars-dont-save-you-popularity-is-not-security-in-the-mcp-ecosystem" rel="noopener noreferrer"&gt;June 2026 TrendAI study&lt;/a&gt; found confirmed security issues in 2,259 of 9,695 MCP servers, even after setting aside the ones flagged only for missing authentication. The servers came from public MCP directories crawled between November 2025 and March 2026.&lt;/p&gt;

&lt;p&gt;If that server leaks your inbox, that's on you. You connected it, even if the agent did it for you and you never noticed. It's the same thing as my tunnel, just with nobody reading the output.&lt;/p&gt;

&lt;p&gt;By "on you" I mean responsibility, not legal liability: someone has to know what's connected and what it can reach. Who is liable after a leak is a legal question. It depends on contracts, jurisdiction and the facts, and I'm not answering it here.&lt;/p&gt;

&lt;p&gt;Software isn't dying. More of it is moving to where people can't see it. And so is the responsibility for it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A shorter version of this first appeared &lt;a href="https://www.linkedin.com/posts/aleksandr-argunov_ai-aiagents-softwareengineering-share-7515678302993297408-GVN3" rel="noopener noreferrer"&gt;on LinkedIn&lt;/a&gt; on 2026-10-13. The maintained version, with every source and the date I last checked them, is &lt;a href="https://argbe.tech/blog/ai-coding-agent-security?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=ai-coding-agent-security" rel="noopener noreferrer"&gt;on argbe.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>claudecode</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
