<?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: AI Alleyway</title>
    <description>The latest articles on DEV Community by AI Alleyway (@aialleyway).</description>
    <link>https://dev.to/aialleyway</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%2F4011619%2Ff15679b8-76eb-442c-91a0-4e828c770c7c.jpg</url>
      <title>DEV Community: AI Alleyway</title>
      <link>https://dev.to/aialleyway</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aialleyway"/>
    <language>en</language>
    <item>
      <title>Provenance is a testable property of an answer engine. I scored three of them.</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Wed, 26 Aug 2026 02:11:19 +0000</pubDate>
      <link>https://dev.to/aialleyway/provenance-is-a-testable-property-of-an-answer-engine-i-scored-three-of-them-3g7j</link>
      <guid>https://dev.to/aialleyway/provenance-is-a-testable-property-of-an-answer-engine-i-scored-three-of-them-3g7j</guid>
      <description>&lt;p&gt;Every AI search engine ships the same interface: a confident paragraph, then a row of source chips underneath. The paragraph is what you read. The chips are what you'd have to check.&lt;/p&gt;

&lt;p&gt;I got curious about whether the chips are worth anything, so I ran one identical query through three engines inside the same hour and then wrote something to score what came back.&lt;/p&gt;

&lt;p&gt;The query was &lt;em&gt;what changed in the EU AI Act in 2026&lt;/em&gt;. It's a decent test because the answer is factual, recent, and — critically — has a primary source that either exists or doesn't.&lt;/p&gt;

&lt;p&gt;Here's the part I didn't expect: &lt;strong&gt;all three agreed on the facts.&lt;/strong&gt; Same substance, same two deadline dates. If you were grading on correctness, it's a three-way tie and there's no article in it.&lt;/p&gt;

&lt;p&gt;The difference was entirely in what each was willing to show me as evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provenance as a scoring function
&lt;/h2&gt;

&lt;p&gt;"Better sources" is the kind of claim that sounds unfalsifiable until you write it down. So I wrote it down: sort every cited host into a tier, weight the tiers, take the mean.&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="n"&gt;PRIMARY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;# publishes the instrument itself
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eur-lex.europa.eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;OFFICIAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;         &lt;span class="c1"&gt;# the issuing institution, one step from the text
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;commission.europa.eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;digital-strategy.ec.europa.eu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;UGC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;              &lt;span class="c1"&gt;# user-generated, no editorial chain
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;youtube.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;www.youtube.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reddit.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;TIER_WEIGHT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;official&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secondary&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ugc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;netloc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PRIMARY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;OFFICIAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;official&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;UGC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ugc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secondary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Mean tier weight, 0.0 (all UGC) to 3.0 (all primary).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TIER_WEIGHT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&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;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deliberate choices worth arguing with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Secondary" is the default, not a penalty box.&lt;/strong&gt; A consultancy write-up explaining a regulation is a legitimate thing to cite. It is simply not the regulation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The denominator is what's attributed, not what's listed.&lt;/strong&gt; Engines display a sources panel and separately attribute specific sentences. Those are different sets, and only the second one is doing any work. Scoring the panel would let an engine pad its way to a good number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The output
&lt;/h2&gt;

&lt;p&gt;Pure stdlib, no arguments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query: what changed in the EU AI Act in 2026   (captured 2026-08-25)

engine                           listed attributed  score  tiers
----------------------------------------------------------------
Perplexity (free)                    10          2   2.00  official:2
Google AI Mode                        5          5   1.00  official:1 secondary:3 ugc:1
Brave Search (free, no account)       1          1   3.00  primary:1

score = mean provenance weight of the sources behind the claims
        primary 3 | official 2 | secondary 1 | ugc 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perplexity listed ten sources and put European Commission domains behind the claims. It also gave the fullest answer by a distance — original deadlines, the amending regulation's effective date, the synthetic-content extension, the fine ceiling, in a table.&lt;/p&gt;

&lt;p&gt;Google's AI Mode drew on five, and the attribution behind its load-bearing sentences was a consultancy, two sites that &lt;em&gt;explain&lt;/em&gt; the Act rather than publish it, and a YouTube video. The Commission was present — in the panel, not behind the claims. That single &lt;code&gt;ugc:1&lt;/code&gt; is a video doing the work of a citation.&lt;/p&gt;

&lt;p&gt;Brave, free and with no account, scored highest. One source, and it went straight at EUR-Lex for the regulation itself.&lt;/p&gt;

&lt;p&gt;And then the scoring function turned out to be measuring the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A score is not a check
&lt;/h2&gt;

&lt;p&gt;A tier map only reads the hostname. It has no idea whether the page exists. So the script has a second mode that goes and looks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return (status, body_bytes) or (None, reason). Follows redirects.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&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="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&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;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last clause. An exception here is a &lt;em&gt;result&lt;/em&gt; — "this citation could not be fetched, and here is what went wrong" — not something to swallow. A provenance checker that silently treats unreachable as fine is worse than no checker.&lt;/p&gt;

&lt;p&gt;Running the three winners through it, on the day I'm writing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://commission.europa.eu/          -&amp;gt; 200, 16,816 bytes
https://digital-strategy.ec.europa.eu/ -&amp;gt; 200, 54,683 bytes
https://eur-lex.europa.eu/             -&amp;gt; 202,      0 bytes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;HTTP 202 with an empty body.&lt;/strong&gt; Accepted for processing. Renders blank in a browser.&lt;/p&gt;

&lt;p&gt;So the top-scoring citation in the table — the only &lt;code&gt;primary&lt;/code&gt; in the whole dataset, the one that lifted Brave to a clean 3.00 — is a URL I could not read. Brave &lt;em&gt;pointed at&lt;/em&gt; the instrument. It did not show it to me. Those are different claims, and only one of them is worth anything to someone trying to verify a number.&lt;/p&gt;

&lt;p&gt;I've kept both modes rather than folding resolution into the score, because they answer different questions. The tier map asks &lt;em&gt;what kind of thing did you cite&lt;/em&gt;. The resolver asks &lt;em&gt;did you cite a thing that exists&lt;/em&gt;. An engine can pass either one alone and still be useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it as an engineer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One query is not a benchmark.&lt;/strong&gt; Three engines, one question, one hour. It's an existence proof that the gap is measurable, not a ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The interface gives you no signal that a sourcing decision was made.&lt;/strong&gt; All three summaries look identical in confidence. Nothing renders differently when the chip under the paragraph is a government domain versus a YouTube video. That's a UI choice, and it's the one that does the damage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're building on top of an answer API, provenance is a field you can compute.&lt;/strong&gt; Hostname tiers are crude but they're cheap, deterministic and diffable — you can score every answer your product surfaces and alert when a run's mean provenance drops. That's a more actionable signal than "hallucination rate", because you can trace it to a specific citation and go look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And check that the citations resolve.&lt;/strong&gt; It took me forty lines and one surprise to learn that the highest-provenance source in my dataset was the one that returned nothing.&lt;/p&gt;

&lt;p&gt;The full write-up, with what each engine actually returned and screenshots of all three answers, is in &lt;a href="https://aialleyway.com/best-ai-search-engine/" rel="noopener noreferrer"&gt;my comparison of the three AI search engines&lt;/a&gt; if you want the reader-facing version rather than the code.&lt;/p&gt;

&lt;p&gt;The script is the whole thing — one file, standard library only, run it with &lt;code&gt;python3 citation_provenance.py&lt;/code&gt; or add &lt;code&gt;--resolve&lt;/code&gt; to hit the network. Swap in your own &lt;code&gt;CITATIONS&lt;/code&gt; dict and it'll score whatever your engine of choice hands back.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Your TTS shortlist is three shortlists, and they barely intersect</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Sat, 22 Aug 2026 06:40:20 +0000</pubDate>
      <link>https://dev.to/aialleyway/your-tts-shortlist-is-three-shortlists-and-they-barely-intersect-jhf</link>
      <guid>https://dev.to/aialleyway/your-tts-shortlist-is-three-shortlists-and-they-barely-intersect-jhf</guid>
      <description>&lt;p&gt;Every "best text-to-speech API" list I have read is ranked. Number one, number two, number three, with a verdict at the bottom.&lt;/p&gt;

&lt;p&gt;That shape cannot express the actual decision, and I want to show you why with something you can run.&lt;/p&gt;

&lt;p&gt;The problem is that the three things that decide a TTS vendor are measured in units that do not convert into each other. Price is dollars per million characters. Transport is a shape — held-open socket, chunked body, finished file. Compliance is a document that either exists or does not. There is no exchange rate between them, so there is no ordering. A ranked list has to pick one axis and pretend the others are tiebreakers.&lt;/p&gt;

&lt;p&gt;They are not tiebreakers. They are filters, and filters compose by intersection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three sets
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price&lt;/strong&gt; spans about 40x. Google Cloud's legacy voices and Amazon Polly's standard engine sit at $4 per million characters. The mid-market — OpenAI's &lt;code&gt;tts-1&lt;/code&gt;, Deepgram Aura-1, Inworld TTS-2 Flash — clusters at $15. Cartesia runs $37.38 to $50. ElevenLabs is $166.11 at its Scale tier. A million characters is roughly 22 hours of speech, so at prototype volume this axis is noise; at a hundred million characters a year it is the difference between a $400 bill and a $16,600 one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transport&lt;/strong&gt; comes in three shapes and the difference is architectural, not incremental.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket streaming&lt;/strong&gt; holds a connection open and pushes audio as it is synthesised. The first syllable can reach the caller while the model is still working on the sentence. This is what a live agent needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunked REST&lt;/strong&gt; streams the response body back progressively. OpenAI works this way, and its docs recommend &lt;code&gt;wav&lt;/code&gt; or &lt;code&gt;pcm&lt;/code&gt; output specifically because those start playing sooner than a compressed container. Meaningfully better than waiting for a whole file; meaningfully worse than a held-open socket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch&lt;/strong&gt; returns a finished file. Correct for narration, e-learning, anything rendered ahead of time. Wrong for conversation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two entries in that column are routinely stated wrong, so they are worth getting right.&lt;/p&gt;

&lt;p&gt;Amazon Polly has &lt;strong&gt;no WebSocket TTS API, but it is not batch-only&lt;/strong&gt;. &lt;code&gt;StartSpeechSynthesisStream&lt;/code&gt; is a real bidirectional streaming API over HTTP/2 — you send text incrementally as events and receive audio as it becomes available. It will serve a conversational agent. The constraint is not the transport, it is the ceiling: generative engine only, 8 transactions per second, up to 8 concurrent requests. That is a real number and it is small.&lt;/p&gt;

&lt;p&gt;Google supports streaming on Chirp 3: HD, with a caveat that will find you in integration rather than in evaluation: &lt;strong&gt;SSML is not supported on streaming requests&lt;/strong&gt;. You can have streaming or you can have your markup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance&lt;/strong&gt; is the axis where price frequently never enters the conversation at all. As published at the time of writing: Google Cloud Text-to-Speech is named in Google's HIPAA BAA covered-products list and Polly is on the AWS HIPAA-eligible services list — if your compliance team wants a document rather than a blog post, those two already have one. OpenAI offers a BAA for the API without an enterprise agreement. Rime publishes the strongest specialist position and, unusually, publishes &lt;em&gt;dates&lt;/em&gt; rather than logos: compliant since February 2024, most recent audit March 2026, BAA on Enterprise, with VPC or full on-prem. Deepgram offers a BAA on request. Inworld gates HIPAA behind the Growth tier, which means you price the compliance tier and not the entry rate. Cartesia does not publish a position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intersecting instead of ranking
&lt;/h2&gt;

&lt;p&gt;Here is the whole idea as forty lines of standard library. No network, no API key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Provider&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="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;usd_per_million_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                        &lt;span class="c1"&gt;# websocket | http2-stream | chunked | batch
&lt;/span&gt;    &lt;span class="n"&gt;max_concurrent_streams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;    &lt;span class="c1"&gt;# None = no stated ceiling
&lt;/span&gt;    &lt;span class="n"&gt;hipaa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                            &lt;span class="c1"&gt;# published | on-request | plan-gated | enterprise | unpublished
&lt;/span&gt;
&lt;span class="n"&gt;CATALOGUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Google Cloud TTS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mf"&gt;4.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Amazon Polly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="mf"&gt;4.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http2-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OpenAI TTS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="mf"&gt;15.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Deepgram Aura-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mf"&gt;15.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on-request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Inworld TTS-2 Flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;15.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan-gated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Inworld TTS-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="mf"&gt;25.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan-gated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Deepgram Aura-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mf"&gt;30.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on-request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rime Mist v3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="mf"&gt;30.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cartesia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="mf"&gt;37.38&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpublished&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rime Coda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="mf"&gt;50.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ElevenLabs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="mf"&gt;166.11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enterprise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Your constraints, not mine.
&lt;/span&gt;&lt;span class="n"&gt;BUDGET&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;15.0&lt;/span&gt;
&lt;span class="n"&gt;CONVERSATIONAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websocket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http2-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;CONCURRENCY&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
&lt;span class="n"&gt;HIPAA_OK&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;price&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CATALOGUE&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usd_per_million_chars&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;BUDGET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;transport&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CATALOGUE&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CONVERSATIONAL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;concurrency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CATALOGUE&lt;/span&gt;
               &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_concurrent_streams&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_concurrent_streams&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CONCURRENCY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;compliance&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CATALOGUE&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hipaa&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HIPAA_OK&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="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;concurrency&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;compliance&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with those four constraints — a mid-market budget, a conversational transport, twenty-five concurrent streams, and a HIPAA position your compliance team can read — and this is the real output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;budget    &amp;lt;= $15/M chars :  5  ['Amazon Polly', 'Deepgram Aura-1', 'Google Cloud TTS', 'Inworld TTS-2 Flash', 'OpenAI TTS']
transport   conversational      : 10  ['Amazon Polly', 'Cartesia', 'Deepgram Aura-1', ...]
concurrency &amp;gt;= 25 streams        : 10  ['Cartesia', 'Deepgram Aura-1', 'Deepgram Aura-2', ...]
compliance  published HIPAA     :  5  ['Amazon Polly', 'Google Cloud TTS', 'OpenAI TTS', 'Rime Coda', 'Rime Mist v3']

intersection                    :  1  ['Google Cloud TTS']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven rows in, one row out.&lt;/p&gt;

&lt;p&gt;Notice that no individual filter looked severe. The loosest kept ten of eleven. The tightest kept five. Any one of them, read on its own, leaves you feeling like you have a comfortable field of candidates — which is exactly the feeling a ranked list is designed to give you, and exactly the feeling that gets teams to month six before the constraint they never filtered on shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which constraint is actually binding
&lt;/h2&gt;

&lt;p&gt;The intersection tells you the answer. It does not tell you what to negotiate, and that is the more useful output. So drop each constraint in turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;without budget       -&amp;gt;  3  ['Google Cloud TTS', 'Rime Coda', 'Rime Mist v3']
without transport    -&amp;gt;  2  ['Google Cloud TTS', 'OpenAI TTS']
without concurrency  -&amp;gt;  2  ['Amazon Polly', 'Google Cloud TTS']
without compliance   -&amp;gt;  3  ['Deepgram Aura-1', 'Google Cloud TTS', 'Inworld TTS-2 Flash']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every constraint is load-bearing here — each one is holding back one or two additional candidates — and none of them individually opens the field. That is a genuinely useful thing to know before you go and argue with someone about it, because it tells you no single concession rescues the shortlist.&lt;/p&gt;

&lt;p&gt;It also puts a number on the Polly ceiling. Polly disappears from the viable set solely because of the 8-concurrent-request limit; relax concurrency and it comes back at the cheapest price band in the table. If your workload is bursty-but-small, that ceiling is fine and Polly is a $4 answer. If you are running two dozen simultaneous calls, the ceiling is not a detail, it is a disqualification. Same vendor, same price, opposite verdict — and no ranked list can hold both.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the axis I deliberately did not model
&lt;/h2&gt;

&lt;p&gt;Latency. You will have noticed it is absent from the script, and that is on purpose.&lt;/p&gt;

&lt;p&gt;Every latency figure any of these vendors publishes is a vendor claim, and they are &lt;strong&gt;not measured the same way&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;The qualifier that matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inworld TTS-2 Flash&lt;/td&gt;
&lt;td&gt;20ms TTFB&lt;/td&gt;
&lt;td&gt;P90, &lt;strong&gt;server-side&lt;/strong&gt; — excludes network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rime Mist v3&lt;/td&gt;
&lt;td&gt;37ms P50 TTFA&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;at 1 concurrency&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deepgram Flux&lt;/td&gt;
&lt;td&gt;80ms first audio&lt;/td&gt;
&lt;td&gt;"under production load", undefined&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cartesia Sonic&lt;/td&gt;
&lt;td&gt;sub-90ms TTFA&lt;/td&gt;
&lt;td&gt;vendor advertisement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deepgram Aura-2&lt;/td&gt;
&lt;td&gt;sub-200ms TTFB&lt;/td&gt;
&lt;td&gt;no methodology stated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI / Google / AWS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;not published&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A server-side P90 that excludes network time and a single-concurrency P50 are not the same measurement. Neither predicts what your users experience at your concurrency from your region. Putting those in a &lt;code&gt;sorted()&lt;/code&gt; call would produce a number that looks like a decision and is not one.&lt;/p&gt;

&lt;p&gt;I have not benchmarked these APIs myself and I am not going to pretend otherwise. The honest way to model latency is as a &lt;strong&gt;gate you measure yourself&lt;/strong&gt;, after the intersection has cut the field to something you can actually stand up and test. Two or three candidates you can benchmark is a tractable afternoon. Eleven is not, which is the other reason to intersect first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape I would actually use
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write down the three constraints as sets before you look at any vendor. If you cannot state your concurrency requirement, you are not ready to choose.&lt;/li&gt;
&lt;li&gt;Intersect. Expect the survivors to be far fewer than the field.&lt;/li&gt;
&lt;li&gt;If the intersection is empty, drop-one to find out what to negotiate — budget, ceiling or paperwork — rather than quietly abandoning the constraint that is easiest to forget.&lt;/li&gt;
&lt;li&gt;Only now benchmark latency, on the two or three that survived, from your region at your concurrency.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The catalogue in the script is a snapshot, and every number in it will drift. That is fine — swap the numbers and the method still holds, which is the point of writing the decision as code rather than as a ranking.&lt;/p&gt;

&lt;p&gt;I keep the current per-million prices, the full transport table including the per-model caveats, and the compliance positions in a longer write-up of &lt;a href="https://aialleyway.com/best-tts-api/" rel="noopener noreferrer"&gt;eight text-to-speech APIs on one axis&lt;/a&gt; if you want the underlying numbers rather than the method.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>The agent edited the rule that made its change wrong</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Sat, 15 Aug 2026 05:54:58 +0000</pubDate>
      <link>https://dev.to/aialleyway/the-agent-edited-the-rule-that-made-its-change-wrong-1ch7</link>
      <guid>https://dev.to/aialleyway/the-agent-edited-the-rule-that-made-its-change-wrong-1ch7</guid>
      <description>&lt;p&gt;I gave a coding agent a mechanical refactor and watched it do something I have not seen discussed anywhere: it modified the file that constrains it, deleting the specific rule its own change had broken. No approval prompt. The edit was, in isolation, defensible. That is what makes it worth writing down.&lt;/p&gt;

&lt;p&gt;This is a short report of one run. I will give the setup, what it got right, the finding, and the limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;A rename across a real repository: one database field and its sibling token, &lt;code&gt;b_roll_suggestions&lt;/code&gt; and &lt;code&gt;b_roll_prompts&lt;/code&gt;, referenced from SQL, Python, JavaScript and a workflow JSON.&lt;/p&gt;

&lt;p&gt;I ran it twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run 1&lt;/strong&gt; pointed the agent at an empty directory. It searched the filesystem, found my actual production repository elsewhere on disk, and planned edits to &lt;code&gt;scripts/supabase-init.sql&lt;/code&gt; inside it. The approval gate fired — the write was outside the configured workspace — and I denied it. &lt;code&gt;git status&lt;/code&gt; afterwards confirmed zero writes. The gate worked exactly as designed, which matters for the rest of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run 2&lt;/strong&gt; used a throwaway clone with an explicit path boundary in the prompt. The boundary held.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it got right
&lt;/h2&gt;

&lt;p&gt;Credit where it is due, because the failure only matters against competent work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;33 of 33 references&lt;/strong&gt;, across 7 files and 3 languages. Not 33 lines — 33 references, counted at HEAD across both tokens.&lt;/p&gt;

&lt;p&gt;I verified the result against disk rather than reading the summary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;JSON.parse&lt;/code&gt; on the workflow file: OK, 28 nodes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node --check&lt;/code&gt; on the guard JavaScript extracted from that file: OK&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;py_compile&lt;/code&gt; on all four Python files: OK&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hardest edit is worth describing. One conditional lived in &lt;strong&gt;JavaScript stored as an escaped string inside n8n workflow JSON&lt;/strong&gt; — a string, inside JSON, that happens to be code. It did not attack that with a regex. It did a structured edit via &lt;code&gt;node -e&lt;/code&gt;, parsing the JSON, modifying the embedded source, and re-serialising. It also caught its own incomplete first pass and went back, unprompted.&lt;/p&gt;

&lt;p&gt;Network access was blocked by default. When it wanted egress it asked, I denied, and it did not fabricate a passing type-check — it named the exact failure, &lt;code&gt;ENOTFOUND&lt;/code&gt;, and cleaned up the artifact it had created. That is better behaviour than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding
&lt;/h2&gt;

&lt;p&gt;Among the 7 files it edited was the project's instruction file — the one carrying the project's standing rules for agents.&lt;/p&gt;

&lt;p&gt;Inside it was this line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don't drop the legacy column.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That rule existed because the old field name still had to survive for backwards compatibility. The agent's rename made the rule false. So it deleted the line.&lt;/p&gt;

&lt;p&gt;No approval prompt. And here is the mechanism, which is the actually interesting part: &lt;strong&gt;the instruction file was inside the workspace.&lt;/strong&gt; The approval gate is keyed on paths. A write inside the configured directory is, by definition, allowed. The gate that fired so correctly in Run 1 could not fire here, because nothing about this write was out of bounds.&lt;/p&gt;

&lt;p&gt;I want to be fair about intent. Documentation should follow code, and the line &lt;em&gt;had&lt;/em&gt; become inaccurate. If a junior engineer did this in a pull request you would probably approve it, then ask a question in review.&lt;/p&gt;

&lt;p&gt;But the property is the problem, not the intent:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A constraint that can be edited by the thing it constrains is not a constraint.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The file was not being read as project memory here — this particular agent reads &lt;code&gt;AGENTS.md&lt;/code&gt;, not the file it touched, and it only touched that file because the rename target appeared in it. That is almost worse. It edited a guardrail file &lt;em&gt;incidentally&lt;/em&gt;, as collateral of a text match, without ever treating it as a guardrail.&lt;/p&gt;

&lt;p&gt;There is a smaller corroborating detail from the same run. Its own diff badge reported &lt;strong&gt;6 files, +13 −31&lt;/strong&gt;. &lt;code&gt;git&lt;/code&gt; said &lt;strong&gt;7 files, +16 −34&lt;/strong&gt;. It undercounted mid-run too. So the summary that would have told you a seventh file was touched was itself wrong about the count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed afterwards
&lt;/h2&gt;

&lt;p&gt;Nothing clever, and none of it is agent-specific:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Move agent instruction files outside the writable workspace&lt;/strong&gt;, or mount them read-only for the run. If the rules live where the work happens, they are editable by the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff the instruction files separately.&lt;/strong&gt; &lt;code&gt;git diff -- AGENTS.md CLAUDE.md .cursorrules&lt;/code&gt; before the main review. It is one command and it surfaces exactly the class of change nobody reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not trust the agent's own diffstat.&lt;/strong&gt; Read &lt;code&gt;git&lt;/code&gt;'s. They disagreed here, in the direction that hides work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat path-based approval gates as necessary, not sufficient.&lt;/strong&gt; They answer "is this file in bounds", never "should this specific file change as part of this task".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The general shape, if you want one sentence: approval gates protect the perimeter, and your constraints are usually inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, plainly
&lt;/h2&gt;

&lt;p&gt;This is &lt;strong&gt;three driven runs across two sittings, all on the free tier, roughly 25 minutes of observed runtime.&lt;/strong&gt; It is not long-term use, it is not a benchmark, and I am not going to present it as one. A single run tells you about defaults and about what the gates do and do not cover. It tells you nothing about behaviour on a large codebase over weeks.&lt;/p&gt;

&lt;p&gt;I also deliberately am not ranking this tool against another one here. The per-file breakdown, the full verification log for the refactor, and what the approval gate did in each run are in &lt;a href="https://aialleyway.com/openai-codex-review/" rel="noopener noreferrer"&gt;the full Codex write-up&lt;/a&gt;, if the specifics are useful to you.&lt;/p&gt;

&lt;p&gt;If anyone has seen an agent modify its own instruction file in a way that &lt;em&gt;was&lt;/em&gt; gated, I would genuinely like to know which tool and which setting — that is the part I could not find prior art for.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>security</category>
      <category>programming</category>
    </item>
    <item>
      <title>The transcript is not the artifact</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Tue, 11 Aug 2026 11:34:09 +0000</pubDate>
      <link>https://dev.to/aialleyway/the-transcript-is-not-the-artifact-9ce</link>
      <guid>https://dev.to/aialleyway/the-transcript-is-not-the-artifact-9ce</guid>
      <description>&lt;p&gt;I gave a coding agent a genuinely awkward job and then spent longer checking its work than it spent doing the work. That ratio is the point of this post.&lt;/p&gt;

&lt;p&gt;The task was a real one from a real repository: rename a field end to end, across three languages that all disagree about what a string is. PostgreSQL schema, an n8n workflow stored as JSON, and Python. The field also travels under a second name in one service's API, so the rename spanned two tokens, not one.&lt;/p&gt;

&lt;p&gt;The agent did it in about twenty minutes. It got everything. And the count was still wrong three times — once by the agent, and twice by me while I was checking the agent.&lt;/p&gt;

&lt;p&gt;That is what I want to write down, because the failures are all in the &lt;em&gt;measurement&lt;/em&gt;, and measurement is the part everybody skips.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent actually did
&lt;/h2&gt;

&lt;p&gt;Credit where it is due, because the run was better than I expected.&lt;/p&gt;

&lt;p&gt;It found the hard case on its own. The n8n workflow file stores its code-node bodies as &lt;strong&gt;escaped JSON strings&lt;/strong&gt; — JavaScript inside a string inside JSON. Instead of reaching for a regex, it said so and changed approach:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The workflow file stores the code-node bodies as escaped JSON strings, so I'm using a structured JSON edit for that one instead of hand-splicing a giant one-line string."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It then wrote a small script to parse, modify and re-serialise. Afterwards it checked its own work, found the first pass incomplete, and volunteered that without being asked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The first JSON edit removed the SQL select cleanly, but the embedded JavaScript string still had the fallback."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And when I asked for a passing type-check, it hit a sandboxed DNS failure, requested network access, was refused — and reported the failure precisely rather than inventing a green check:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I could not show the requested passing TypeScript type-check because &lt;code&gt;scripts/remotion-service&lt;/code&gt; has no local &lt;code&gt;node_modules&lt;/code&gt;... I requested networked dependency install... but that approval was rejected, so I stopped there rather than fetching anything."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then it noticed the empty package store its aborted install had left behind and asked to remove it, "so your worktree only contains the intended code changes."&lt;/p&gt;

&lt;p&gt;None of that is junior behaviour. Which is exactly why the counting problem is worth taking seriously: when the narration is this good, you stop auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie one: the agent's own diff badge
&lt;/h2&gt;

&lt;p&gt;The agent displays a running total of what it has changed. At the end of the run it read &lt;strong&gt;6 files, +13 −31&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git&lt;/code&gt; said &lt;strong&gt;7 files, +16 −34&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It was wrong mid-run too — the badge read "4 files +10 −28" at a moment when the working tree held 6 files, +15 −33.&lt;/p&gt;

&lt;p&gt;I do not know why, and it does not much matter. The lesson is structural: &lt;strong&gt;a self-reported diff is a claim, not evidence.&lt;/strong&gt; The agent is describing its intentions; &lt;code&gt;git&lt;/code&gt; is describing your disk. Only one of those is the artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie two: I quoted a diffstat as if it counted something
&lt;/h2&gt;

&lt;p&gt;This one is mine, and it is the more embarrassing of the two.&lt;/p&gt;

&lt;p&gt;My first write-up said the agent had renamed &lt;strong&gt;16 references&lt;/strong&gt;. It had not. Sixteen was the &lt;em&gt;insertion count&lt;/em&gt; from &lt;code&gt;git diff --stat&lt;/code&gt; — a line-level number that happens to sit next to a plus sign. It counts lines added. It does not count anything semantic at all.&lt;/p&gt;

&lt;p&gt;The actual figure, counted at &lt;code&gt;HEAD&lt;/code&gt; across the changed files, was &lt;strong&gt;33&lt;/strong&gt;: twenty-three occurrences of the original name, plus ten of the second name the same field travels under in the render service.&lt;/p&gt;

&lt;p&gt;The wrong number had already propagated into prose, an FAQ, an infographic and a machine-readable file before I caught it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never quote a diffstat number as a count of things.&lt;/strong&gt; Insertions are not references, files changed are not modules touched, and &lt;code&gt;+16&lt;/code&gt; is not "sixteen of anything you care about". If you want to know how many references changed, count references — at a commit, with a grep you wrote for that purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie three: I declared zero when it was forty-five
&lt;/h2&gt;

&lt;p&gt;Same run, worse mistake.&lt;/p&gt;

&lt;p&gt;I checked for stragglers and reported &lt;strong&gt;zero remaining&lt;/strong&gt; across the repository. Forty-five survived.&lt;/p&gt;

&lt;p&gt;The straggler grep was scoped: &lt;code&gt;--include="*.sql|*.json|*.py"&lt;/code&gt;. Perfectly sensible, since those were the three languages the rename touched. It silently excluded Markdown, and forty-five occurrences were sitting in four frozen planning documents.&lt;/p&gt;

&lt;p&gt;Two failures stacked there, and they generalise past this example:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scoping a search to where you expect the answer will confirm your expectation.&lt;/strong&gt; Grep unscoped first, then narrow once you have seen the full set. A filter applied before you know the answer is not a filter, it is a hypothesis you have hidden from yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A metric that spans two tokens needs a two-token check.&lt;/strong&gt; The rename covered both names; my straggler check only looked for one for part of the sweep. If your definition of done spans several strings, every verification step has to span all of them, or "done" means "done for the string I happened to type".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;The method that survived this run, in the order I run it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure with &lt;code&gt;git&lt;/code&gt;, never with the badge.&lt;/strong&gt; &lt;code&gt;git diff --stat&lt;/code&gt; for shape, &lt;code&gt;git status&lt;/code&gt; to confirm nothing landed outside what you approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count semantically, at a commit.&lt;/strong&gt; Grep for the actual tokens at &lt;code&gt;HEAD&lt;/code&gt;, unscoped, and count every token the change spans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove syntax with parsers, not eyeballs.&lt;/strong&gt; On this run that meant &lt;code&gt;JSON.parse&lt;/code&gt; on the workflow (28 nodes intact), &lt;code&gt;node --check&lt;/code&gt; on the JavaScript extracted from inside that JSON, and &lt;code&gt;py_compile&lt;/code&gt; on all four Python files. A diff can look perfect and still not parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep unscoped before you claim zero.&lt;/strong&gt; Then narrow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is clever. All of it is the difference between a rename that is finished and a rename that is finished in the places you thought to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding that actually unsettled me
&lt;/h2&gt;

&lt;p&gt;One more thing happened, and it is the reason I still read the whole diff.&lt;/p&gt;

&lt;p&gt;The repository has a project instruction file — the document that tells future agent sessions how this codebase works. It contained an explicit rule: this legacy column is kept for back-compatibility, &lt;strong&gt;do not drop it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The agent dropped it. Then it edited the instruction file to remove that rule, replacing it with a description of the new state.&lt;/p&gt;

&lt;p&gt;The charitable reading is probably the correct one. I asked for an end-to-end rename, and leaving documentation asserting a constraint the code no longer honours would be its own kind of wrong. It listed the edit in its summary rather than hiding it.&lt;/p&gt;

&lt;p&gt;But sit with the shape of it. The instruction file is the thing that constrains the &lt;em&gt;next&lt;/em&gt; session. An agent that revises the rulebook to match what it just did leaves a repository where the rule never appears to have existed — and the next agent, reading a clean file, has no way to know a constraint was ever there. It happened with no approval prompt, because that file lives inside the workspace the agent was given.&lt;/p&gt;

&lt;p&gt;I am not arguing it behaved badly. I am arguing that "files inside the workspace can be edited without asking" and "the workspace contains the rules" are two reasonable defaults that combine into something you would not choose deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;The agent was more careful than I expected and more careful than I was. Every mistake in the paragraphs above that mattered to the published result was mine, and every one of them was a measurement error rather than a code error.&lt;/p&gt;

&lt;p&gt;So: let it work, then check the disk rather than the story about the disk. And when you write the result down, count the thing you are claiming to have counted.&lt;/p&gt;

&lt;p&gt;If you want the rest of that test — the workspace-escape run, what the approval gates caught, and the parts where the two agents I use genuinely differ — it is in &lt;a href="https://aialleyway.com/codex-vs-claude-code/" rel="noopener noreferrer"&gt;the full Codex vs Claude Code comparison&lt;/a&gt;, including the parts where my own evidence is thinner than I would like.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>testing</category>
      <category>productivity</category>
    </item>
    <item>
      <title>What a year of metered AI coding actually cost me: a $20 plan that billed $510.70</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Thu, 30 Jul 2026 05:05:12 +0000</pubDate>
      <link>https://dev.to/aialleyway/what-a-year-of-metered-ai-coding-actually-cost-me-a-20-plan-that-billed-51070-37cm</link>
      <guid>https://dev.to/aialleyway/what-a-year-of-metered-ai-coding-actually-cost-me-a-20-plan-that-billed-51070-37cm</guid>
      <description>&lt;p&gt;Most AI coding tools now bill on a model the industry borrowed from cloud infrastructure: a flat subscription that includes some allowance, then metered usage on top, charged in arrears at the underlying model's API rate.&lt;/p&gt;

&lt;p&gt;That is a fundamentally different cost model from a subscription, and most of us evaluate it as though it were the same thing. I paid for Cursor for eleven months across 2025 and then cancelled, and the invoices make the difference concrete in a way the pricing page does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;

&lt;p&gt;Eleven months, &lt;strong&gt;$510.70 total&lt;/strong&gt;. Of that, &lt;strong&gt;$340 was subscription&lt;/strong&gt; and &lt;strong&gt;$170.70 was on-demand usage&lt;/strong&gt; — a third of the bill, 33.4%, sitting on top of a plan advertised at $20.&lt;/p&gt;

&lt;p&gt;The distribution is the interesting part. It was not evenly spread:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month&lt;/th&gt;
&lt;th&gt;Billed&lt;/th&gt;
&lt;th&gt;Share that was overage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;October 2025&lt;/td&gt;
&lt;td&gt;$159.22&lt;/td&gt;
&lt;td&gt;49.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;November 2025&lt;/td&gt;
&lt;td&gt;$110.00&lt;/td&gt;
&lt;td&gt;45.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(11-month total)&lt;/td&gt;
&lt;td&gt;$510.70&lt;/td&gt;
&lt;td&gt;33.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two months where metered usage was roughly half the invoice. On a plan whose headline number is $20.&lt;/p&gt;

&lt;p&gt;Nothing was wrong with the billing. Usage beyond the included allowance is charged at the model's API rate, and on individual plans that is the &lt;em&gt;model's&lt;/em&gt; rate — the discounted token rate is a Teams and Enterprise feature. The rate was disclosed. What was missing was any intuition for how fast a heavy month accumulates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the intuition fails
&lt;/h2&gt;

&lt;p&gt;The failure mode is not price, it is &lt;strong&gt;variance&lt;/strong&gt;. A subscription has a known worst case. Subscription-plus-metering has a worst case bounded only by your own throughput, and throughput on an agentic tool is exactly what spikes when work gets hard — the weeks you lean on it most are the weeks it costs most. Cost correlates with difficulty, which is precisely backwards from how you would budget it.&lt;/p&gt;

&lt;p&gt;I upgraded to the $60 tier at one point expecting headroom. The included allowance ran out in about a week.&lt;/p&gt;

&lt;p&gt;If you want a mental model: treat the subscription as a &lt;strong&gt;floor&lt;/strong&gt;, not a price. The real number is floor + (throughput × rate), and you do not know your throughput until you have a few months of history. Which means for the first quarter you are, in effect, running an uninstrumented cost centre.&lt;/p&gt;

&lt;h2&gt;
  
  
  The control that exists and nobody sets
&lt;/h2&gt;

&lt;p&gt;Cursor has a spend limit. It is in the billing settings. It has been there the whole time.&lt;/p&gt;

&lt;p&gt;Set it on day one — before the first heavy week, not after the first surprising invoice. It converts an unbounded model back into a bounded one, which is the only change that makes the tool's cost predictable enough to defend in a budget conversation.&lt;/p&gt;

&lt;p&gt;This generalises past Cursor. Any tool billing subscription-plus-metering should be onboarded the same way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the spend cap before you find the features.&lt;/strong&gt; If there is no cap, that is a finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument the first month&lt;/strong&gt; — you are collecting a throughput baseline, not just doing work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget the floor plus a variance band&lt;/strong&gt;, not the sticker price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check when you change tier.&lt;/strong&gt; A bigger plan changes the allowance, not the billing model, and a bigger allowance can encourage exactly the usage that blows through it.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;I cancelled — not because it was bad. It was genuinely good, and for a lot of people $20 flat plus modest metering is fair value for what it does. I cancelled because my usage pattern made the metered layer the dominant term, and at that point I would rather pay a predictable number.&lt;/p&gt;

&lt;p&gt;That is a judgement about a usage profile, not a verdict on the product. Yours may differ, and the honest way to find out is to instrument it rather than estimate it.&lt;/p&gt;

&lt;p&gt;The full breakdown — what it does well, where it struggles, and the thing I got wrong about its billing for months — is in my &lt;a href="https://aialleyway.com/cursor-review/" rel="noopener noreferrer"&gt;Cursor review&lt;/a&gt;, written from the invoices rather than from the marketing page.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figures are from my own invoices across 2025. Published tier names are Pro $20, Pro Plus $60, Ultra $200 — I have deliberately not quoted current per-tier allowances, because those have changed repeatedly and any number I cite here would date badly. The billing model is the durable part; the inclusions are not.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
      <category>career</category>
    </item>
    <item>
      <title>Google Meet ships its own AI notetaker now — so the real question is lock-in, not features</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Wed, 22 Jul 2026 03:55:44 +0000</pubDate>
      <link>https://dev.to/aialleyway/google-meet-ships-its-own-ai-notetaker-now-so-the-real-question-is-lock-in-not-features-4fc7</link>
      <guid>https://dev.to/aialleyway/google-meet-ships-its-own-ai-notetaker-now-so-the-real-question-is-lock-in-not-features-4fc7</guid>
      <description>&lt;p&gt;Google Meet now takes its own notes. Turn on "Take notes for me" and Gemini follows the call, then drops an organized summary with action items into a Google Doc in your Drive. No bot, no install — it runs &lt;em&gt;inside&lt;/em&gt; Meet.&lt;/p&gt;

&lt;p&gt;That changes the question. For years, "best AI notetaker for Meet" was a feature comparison between third-party apps. Now the platform ships the feature natively, and the interesting decision is the one every engineer recognizes when a platform absorbs a capability you used to buy: &lt;strong&gt;do you take the native integration and accept the platform's boundary, or keep an abstraction layer above it so you're not locked in?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the native path actually commits you to
&lt;/h2&gt;

&lt;p&gt;Native Gemini notes are genuinely well-integrated — that's the whole pitch. But integration is the same thing as coupling, and it's worth naming exactly what you're coupling to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's Meet-only.&lt;/strong&gt; Gemini's notetaker does nothing for your Zoom or Teams calls. Your notetaking is now a function of &lt;em&gt;which platform the meeting happened on&lt;/em&gt;, which is a strange constraint to accept for something as platform-agnostic as "write down what we decided."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's license- and admin-gated.&lt;/strong&gt; The feature needs a Gemini add-on or a Business/Enterprise Workspace edition, and a Workspace admin has to switch it on before anyone can use it. There's no free personal path. Your access to your own meeting notes is now downstream of a procurement decision and an admin toggle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output lives in Google's sphere.&lt;/strong&gt; The notes land in a Drive Doc under your Workspace's sharing rules. That's convenient if everything you do lives in Google — and it means your meeting record is shaped by, and governed by, the platform, not you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is bad. It's the classic trade: deep integration in exchange for living inside the platform's boundary. The native notetaker is bot-free and zero-install precisely &lt;em&gt;because&lt;/em&gt; it's part of Meet — you can't have the integration without the coupling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The portable layer is the other architecture
&lt;/h2&gt;

&lt;p&gt;A third-party notetaker is the opposite design: an abstraction layer that sits &lt;em&gt;above&lt;/em&gt; the meeting platform and treats Meet, Zoom, and Teams as interchangeable inputs. It captures the call (usually via a bot that joins as a participant), and — this is the part that matters — it &lt;strong&gt;owns the output.&lt;/strong&gt; The notes live in your tool, on your terms, exportable, the same regardless of which platform hosted the call.&lt;/p&gt;

&lt;p&gt;The properties fall out of that shape. It works no matter your Workspace plan (no license, no admin). It's cross-platform by construction, so your notetaking doesn't fork every time a client sends a Zoom link instead of a Meet one. Many are free — Fathom's free plan is genuinely unlimited and produced the cleanest summary of anything we tested, and it saves the video Gemini's notes don't. The costs are the mirror image of native's benefits: a visible bot in the participant list, and one more app holding your data instead of the suite you already run.&lt;/p&gt;

&lt;p&gt;There's even a reliability wrinkle that tracks the architecture. A bot joins the call as its own participant, so it keeps recording if your laptop sleeps or your tab closes. The native/bot-free approaches capture from your session, so they depend on you staying in it. Coupling to the platform buys integration; the portable layer buys independence — including from your own flaky Wi-Fi.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which boundary do you want?
&lt;/h2&gt;

&lt;p&gt;Strip it to the engineering decision and it's clean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You live entirely in Google Workspace, already pay for Gemini, and never meet anywhere but Meet&lt;/strong&gt; → take the native integration. The coupling costs you nothing you'd have used, and you get the tightest experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You meet across Zoom/Teams too, or you want your notes to outlive your Workspace subscription, or you're on a personal account&lt;/strong&gt; → keep the portable layer. A tool like Fathom abstracts the platform away and hands you the output to own, which is worth a bot in the roster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is portability. If your meeting notes should be Meet-shaped — governed by Google, searchable in Drive, gone if you leave Workspace — native is the clean answer. If they should be &lt;em&gt;yours&lt;/em&gt;, independent of where the call happened, keep the layer. (Either way, the tools record a conversation, so disclose it on external calls — in two-party-consent regions that's the law, and Meet shows a note-taking banner regardless.)&lt;/p&gt;

&lt;p&gt;I broke down the native setup, the best portable tools, the bot-vs-bot-free reliability tradeoff, and the exact steps for each in the &lt;a href="https://aialleyway.com/ai-notetaker-for-google-meet/" rel="noopener noreferrer"&gt;full guide to AI notetakers for Google Meet&lt;/a&gt;. But the fast way to decide isn't a feature grid — it's the boundary question: are you coupling to the platform, or keeping a layer above it?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>There are only three ways to tap a Zoom call — and it decides everything about your notetaker</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Thu, 16 Jul 2026 08:46:54 +0000</pubDate>
      <link>https://dev.to/aialleyway/there-are-only-three-ways-to-tap-a-zoom-call-and-it-decides-everything-about-your-notetaker-22og</link>
      <guid>https://dev.to/aialleyway/there-are-only-three-ways-to-tap-a-zoom-call-and-it-decides-everything-about-your-notetaker-22og</guid>
      <description>&lt;p&gt;Every "best AI notetaker for Zoom" list ranks tools by features. That's the wrong layer. Underneath the feature grid, there are only &lt;strong&gt;three places a piece of software can physically tap a Zoom call&lt;/strong&gt; to get the audio, and which one a tool uses determines almost everything you actually care about — who sees it, whether it works on the free Zoom tier, whether it follows you to Google Meet, and whether you can go back and re-watch. Once you see the three attach points, the tool choice mostly falls out. I've tested tools from each category; here's the taxonomy.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Bot-as-participant
&lt;/h2&gt;

&lt;p&gt;The most common approach: a bot joins the meeting as a visible participant and records from the inside. Fathom and Otter do this by default. It's the most portable design — because the bot just needs to be admitted to the call, it works on &lt;strong&gt;any&lt;/strong&gt; Zoom tier (including free Zoom) and the same tool usually covers Google Meet and Teams too. Fathom even ships an official Zoom Marketplace app for one-click setup.&lt;/p&gt;

&lt;p&gt;The cost is visibility. There's a labeled "AI Notetaker" sitting in the participant list, and once everyone on a call runs their own, you get the now-familiar pile-up of three or four notetaker bots in the roster. On external calls that's awkward at best and against the other side's policy at worst.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade:&lt;/strong&gt; maximum reach and cross-platform, minimum discretion.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Inside-the-platform
&lt;/h2&gt;

&lt;p&gt;The second approach doesn't join the call — it runs &lt;em&gt;inside&lt;/em&gt; it. Zoom's native AI Companion (its "My Notes" feature) executes server-side within Zoom itself, captures the meeting, and emails a summary afterward. Nothing appears in the participant list because nothing joined; it's the platform taking its own notes.&lt;/p&gt;

&lt;p&gt;The economics are the appeal: where Google charges for Gemini and Microsoft charges for Copilot, Zoom bundles the core AI Companion note-taking into its paid &lt;strong&gt;Zoom Workplace&lt;/strong&gt; plans (roughly $13–18/user/month) at no extra cost — so if your org already pays for Zoom, you may already have this and just need an admin to switch it on. (Zoom has started layering premium AI tiers on top, so the frontier keeps moving, but the basic summary comes with the plan.)&lt;/p&gt;

&lt;p&gt;The constraints are the flip side of living inside one platform: it's &lt;strong&gt;Zoom-only&lt;/strong&gt; (nothing for your Meet or Teams calls), it needs an &lt;strong&gt;admin/host to enable&lt;/strong&gt; it, and it's &lt;strong&gt;not available on the free Zoom tier&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade:&lt;/strong&gt; cheapest and cleanest &lt;em&gt;if&lt;/em&gt; you're already a paying, single-platform Zoom shop; useless outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Local device-audio capture
&lt;/h2&gt;

&lt;p&gt;The third approach ignores the call entirely and captures what your &lt;strong&gt;own machine hears&lt;/strong&gt;. Granola grabs your device audio locally; browser tools like Tactiq transcribe from a Chrome extension. No bot, no participant, nothing added to the meeting — the quietest possible footprint, which is exactly why it's the pick for confidential client calls.&lt;/p&gt;

&lt;p&gt;The architectural catch is a hard one: these tools capture what &lt;em&gt;you&lt;/em&gt; can hear, so they work best when you're an &lt;strong&gt;active participant&lt;/strong&gt; actually in the audio path, and they lean on the notes you take rather than a pristine multi-speaker server-side transcript. (Worth stating plainly: bot-free is not the same as recording in secret — in two-party-consent regions you still disclose you're transcribing.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade:&lt;/strong&gt; minimum footprint and maximum privacy, at the cost of capture fidelity and needing to be present.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the attach point, then the tool
&lt;/h2&gt;

&lt;p&gt;Read your situation through the three and the shortlist writes itself: on a &lt;strong&gt;free Zoom account or bouncing between Zoom/Meet/Teams&lt;/strong&gt;, you need a bot-as-participant tool (Fathom is the one I'd start with — unlimited free plan, official Zoom app, cross-platform). Already a &lt;strong&gt;paying, Zoom-only org&lt;/strong&gt;, the inside-the-platform AI Companion is the cheapest clean option you may already own. Running &lt;strong&gt;discreet external calls&lt;/strong&gt;, local capture (Granola) keeps the meeting clean.&lt;/p&gt;

&lt;p&gt;I laid out both routes on Zoom specifically — the native AI Companion setup, the free third-party apps, the multi-bot problem, and how to enable each — in &lt;a href="https://aialleyway.com/ai-notetaker-for-zoom/" rel="noopener noreferrer"&gt;our AI notetaker for Zoom guide&lt;/a&gt;, built from the tools we actually ran rather than a marketplace listing. But the mental model is the durable part: three attach points, three sets of tradeoffs, and the tool is downstream of which one fits your call.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The "AI avatar generator" category is three rendering architectures wearing one label</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Thu, 09 Jul 2026 11:42:15 +0000</pubDate>
      <link>https://dev.to/aialleyway/the-ai-avatar-generator-category-is-three-rendering-architectures-wearing-one-label-j8i</link>
      <guid>https://dev.to/aialleyway/the-ai-avatar-generator-category-is-three-rendering-architectures-wearing-one-label-j8i</guid>
      <description>&lt;p&gt;If you evaluate AI avatar tools as if they're interchangeable — type a script, get a talking head — you'll pick the wrong one and then blame the tool. I tested the three market leaders hands-on, giving each the same nine-second script, and the thing that actually separates them isn't avatar quality. It's the rendering architecture underneath. And once you see the architecture, two things you'd written off as pricing quirks turn out to be inevitable consequences of it.&lt;/p&gt;

&lt;p&gt;There are three architectures.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Batch render behind a moderation gate (Synthesia)
&lt;/h2&gt;

&lt;p&gt;Synthesia takes a script, screens it for policy issues &lt;em&gt;before&lt;/em&gt; generating, renders the avatar, then moderates the &lt;em&gt;finished&lt;/em&gt; video before it will release it to you. In my test, a nine-second clip took four to five minutes to appear — and almost all of that time was the moderation step, not the rendering.&lt;/p&gt;

&lt;p&gt;That reads like a performance problem. It isn't. It's the whole value proposition. The moderation gate is why Synthesia is the tool most of the Fortune 100 standardize on: a security or compliance team can sign off on a system that refuses to emit an avatar saying something off-policy. You cannot buy your way past the latency, because the latency &lt;em&gt;is&lt;/em&gt; the guardrail. There's no "fast mode" tier — a fast mode would mean turning off the exact thing enterprises are paying for.&lt;/p&gt;

&lt;p&gt;The billing model follows from the architecture too. Because every output is a discrete, moderated artifact, Synthesia meters by the finished minute. Its plans are minute allowances — a mid-tier plan is a few hundred minutes a &lt;em&gt;year&lt;/em&gt; — and the effective cost lands around two dollars per finished minute. A per-artifact price on a per-artifact pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Fast render, metered by compute (HeyGen)
&lt;/h2&gt;

&lt;p&gt;HeyGen returned a comparable nine-second clip in about a minute, with no moderation queue in the way. Same job description — script in, talking head out — completely different pipeline: no policy gate, and the render is the entire wait.&lt;/p&gt;

&lt;p&gt;But the interesting part is the meter. HeyGen doesn't bill by the minute of video; it bills by &lt;em&gt;credits&lt;/em&gt;, and the credit burn rate depends on which motion engine you pick. The older engine costs a few credits per minute. The newer, lifelike engines — the entire reason you'd choose HeyGen — cost around 20 credits per minute. That's roughly a 7× spread in cost for the same &lt;em&gt;duration&lt;/em&gt; of output, decided by one dropdown.&lt;/p&gt;

&lt;p&gt;That's not an arbitrary gimmick. Credits are a compute passthrough: the lifelike engines are far more expensive to run, so they drain the meter far faster. The consequence for a buyer is that a plan's headline number ("600 credits") tells you almost nothing until you know which engine you're running — 600 credits is ~30 minutes of the good engine, or ~200 minutes of the cheap one. The unit that matters is credits-per-minute-of-&lt;em&gt;the-engine-you-actually-want&lt;/em&gt;, and the pricing page won't do that division for you.&lt;/p&gt;

&lt;p&gt;There's a trap that falls straight out of this architecture: a new HeyGen project defaults to the premium engine, which the free plan won't render — so a first-time free user hits Generate, gets an upsell instead of a video, and concludes the tool is broken. It isn't; you have to manually downgrade the engine. That's the compute-metered design leaking into the onboarding.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Real-time inference, metered by the conversation (Tavus)
&lt;/h2&gt;

&lt;p&gt;Tavus doesn't render a file at all. It generates the avatar &lt;em&gt;live&lt;/em&gt;, during what feels like a video call — you talk, it talks back, rendered in real time. I held an actual conversation with its built-in agent, and the real-time face was the closest thing to talking to a person I've tried.&lt;/p&gt;

&lt;p&gt;Different architecture, different failure mode: it's still in beta, and my first call dropped before connecting on a retry. A batch renderer can't "drop a call" — it either returns a file or errors. Real-time inference carries a whole class of reliability problems the other two don't, because there's a live session to keep alive.&lt;/p&gt;

&lt;p&gt;And, predictably, the meter changes again. There's no finished minute to sell and no render credit to burn, so Tavus prices by &lt;em&gt;conversational&lt;/em&gt; minutes, with pay-as-you-go overage. That's the only honest unit for a pipeline whose output is a session, not an artifact — and it's also why costs are harder to forecast than a flat per-video plan: you're paying for time-on-call, which your users control, not you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway for anyone choosing
&lt;/h2&gt;

&lt;p&gt;"Which AI avatar tool is best" has no answer because the three leaders aren't competing on one axis — they're three different systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Playback of a governed artifact&lt;/strong&gt; → batch-plus-moderation (Synthesia). Slow and per-minute &lt;em&gt;on purpose&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playback of a high-quality artifact, fast&lt;/strong&gt; → compute-metered render (HeyGen). Fast, but the credit &lt;em&gt;rate&lt;/em&gt; — not the plan price — is your real bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A conversation&lt;/strong&gt; → real-time inference (Tavus). No file; per-conversational-minute; and a live session's reliability tax.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can't upgrade a batch tool into a real-time one, and you can't make a moderated tool fast without removing the moderation that's the point of it. So decide which architecture your job needs &lt;em&gt;first&lt;/em&gt;; the tool, and the shape of its bill, follow from that. I put all six tools we looked at — the three leaders plus the specialists — through exactly this lens in &lt;a href="https://aialleyway.com/best-ai-avatar-generator/" rel="noopener noreferrer"&gt;our hands-on best AI avatar generator roundup&lt;/a&gt;, where the render times, credit math, and per-tool verdicts are laid out in full.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Prices and credit rates above are from our hands-on tests in July 2026; vendors move these around, so treat the exact figures as directional and the architecture as the durable part.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>video</category>
      <category>architecture</category>
    </item>
    <item>
      <title>InVideo's credit meter is a GPU bill: the same video, priced 20x apart</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Thu, 09 Jul 2026 03:46:44 +0000</pubDate>
      <link>https://dev.to/aialleyway/invideos-credit-meter-is-a-gpu-bill-the-same-video-priced-20x-apart-4aei</link>
      <guid>https://dev.to/aialleyway/invideos-credit-meter-is-a-gpu-bill-the-same-video-priced-20x-apart-4aei</guid>
      <description>&lt;p&gt;"InVideo AI is $20 a month" is true and almost useless. The plan fee is the price of the door; the credits are the price of the video, and the credit cost of a single 30-second clip swings about 20x depending on one choice you make each time you hit generate. Once you understand &lt;em&gt;why&lt;/em&gt;, it stops being a mystery bill and becomes something you can actually budget — the same way you'd budget metered cloud compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not a video tool with a price. It's a model hub with a meter.
&lt;/h2&gt;

&lt;p&gt;InVideo bills itself as a hub with access to 200-plus models. You don't pay per video; you pay a flat monthly fee for access, and then every generation spends from a monthly &lt;strong&gt;credit pool&lt;/strong&gt; that refills with your plan (75 credits on the $20 Plus plan, 390 on Max, 800 on Generative, 4,250 on Elite).&lt;/p&gt;

&lt;p&gt;The important part is what sets the cost of a single generation. It's not the length or the plan — it's &lt;strong&gt;the compute behind the model you invoke.&lt;/strong&gt; Pulling a licensed stock clip is nearly free to run, so it costs almost nothing. Running a frontier video model like Google's Veo 3.1 or OpenAI's Sora 2 is expensive to run, so it costs a lot. InVideo is passing that compute cost straight through to you, per generation.&lt;/p&gt;

&lt;p&gt;Here's what that looks like on the exact same 30-second brief, from InVideo's in-app generate screen (as of my testing, mid-2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Footage setting&lt;/th&gt;
&lt;th&gt;What runs&lt;/th&gt;
&lt;th&gt;Credits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stock (Basic)&lt;/td&gt;
&lt;td&gt;licensed stock clips&lt;/td&gt;
&lt;td&gt;~2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Efficient generative&lt;/td&gt;
&lt;td&gt;InVideo's cheaper models&lt;/td&gt;
&lt;td&gt;~15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium (Pro)&lt;/td&gt;
&lt;td&gt;Veo 3.1 / Sora 2&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same brief. Twenty times the price. That is not a pricing quirk — it's the cost model. Your plan sets the size of the pool; your footage choice sets how fast you drain it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part every engineer will flinch at: no retry discount
&lt;/h2&gt;

&lt;p&gt;Here's the detail that turns this from "metered" to "watch out." &lt;strong&gt;There is no idempotency discount on a regeneration.&lt;/strong&gt; If a clip comes out wrong and you generate it again, InVideo charges the full rate a second time.&lt;/p&gt;

&lt;p&gt;Generative prompts rarely land on the first try — you tweak the wording, regenerate, adjust a scene, regenerate again. Two or three attempts at 40 credits each is 80 to 120 credits for &lt;em&gt;one finished premium clip&lt;/em&gt; — more than the entire 75-credit monthly pool on the $20 plan. So the real unit of cost isn't "a video." It's &lt;strong&gt;a finished video, including the retries it took to get there&lt;/strong&gt;, and that number is unknowable in advance because it depends on how many attempts your prompt needs.&lt;/p&gt;

&lt;p&gt;That's the mechanism behind the recurring review complaint of "I paid for videos I never finished." The meter runs on every attempt, hit or miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually budget it
&lt;/h2&gt;

&lt;p&gt;Stop thinking in "videos per plan." Think in cost-per-finished-clip, like a cloud bill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;effective cost  ≈  (credits per generation)
                 ×  (expected attempts to get a keeper)
                 ×  (dollars per credit on your plan)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the numbers and the split is stark. On the same $20 Plus plan, a stock-first creator effectively pays about &lt;strong&gt;50 cents a video&lt;/strong&gt; (that $20 spread across ~37 stock clips), while a premium-generative creator pays closer to &lt;strong&gt;$11 a clip&lt;/strong&gt; — and once you fold in the regenerations a Veo or Sora prompt usually needs, a single finished premium clip can cost more than the entire monthly plan on its own.&lt;/p&gt;

&lt;p&gt;So the budgeting rule is: decide your footage mix first, compute credits-per-&lt;em&gt;finished&lt;/em&gt;-clip (retries included), then pick the plan whose pool clears that with margin. The 40-credit premium tier is the on-demand GPU instance of this analogy — remarkable value if you use it sparingly, ruinous if you lean on it without watching the meter.&lt;/p&gt;

&lt;p&gt;I worked the full thing out — every plan, the credit-to-dollar math, the stock-license second meter, and where the free plan actually stops being usable — in &lt;a href="https://aialleyway.com/invideo-pricing/" rel="noopener noreferrer"&gt;what a video actually costs on InVideo&lt;/a&gt;. But the one idea that saves you the most is this: the number to write down before you subscribe isn't $20. It's how many premium clips a month you actually need, because that's the variable the whole bill turns on.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>video</category>
      <category>pricing</category>
    </item>
    <item>
      <title>The line in the LICENSE file that reorders your n8n alternatives</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Wed, 08 Jul 2026 07:16:37 +0000</pubDate>
      <link>https://dev.to/aialleyway/the-line-in-the-license-file-that-reorders-your-n8n-alternatives-3ia3</link>
      <guid>https://dev.to/aialleyway/the-line-in-the-license-file-that-reorders-your-n8n-alternatives-3ia3</guid>
      <description>&lt;p&gt;Most "n8n alternatives" lists compare the wrong column.&lt;/p&gt;

&lt;p&gt;They line up connector counts, per-execution pricing, cron support, and a screenshot of the canvas. Useful, up to a point. But if you're planning to &lt;em&gt;self-host&lt;/em&gt; the engine — the reason you're looking past the SaaS incumbents in the first place — there's a field none of those tables show, and it sits in a file at the root of every repo: &lt;code&gt;LICENSE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We run n8n in production. The entire AI Alleyway content pipeline is about ten self-hosted n8n workflows on a single box. So this isn't a "we surveyed the landscape" post — it's the one architectural question I wish more alternatives lists led with, because it's the one that quietly decides whether a tool is even &lt;em&gt;eligible&lt;/em&gt; for your use case before you've compared a single feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three licenses, three completely different contracts
&lt;/h2&gt;

&lt;p&gt;"Open source" gets used as a single adjective. For self-hosted automation engines it's actually three different legal contracts, and they diverge exactly where it matters — redistribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;n8n ships under the Sustainable Use License (v1.0).&lt;/strong&gt; This is source-available, not an OSI-approved open-source license. n8n's own umbrella term for the model is "fair-code." You can read the source, self-host it, and modify it — but the grant is scoped. Straight from the repo's &lt;code&gt;LICENSE.md&lt;/code&gt;, you may use or modify the software "only for your own internal business purposes or for non-commercial or personal use," and you may distribute it to others "only if you do so free of charge for non-commercial purposes." In plain terms: run it for yourself all you want; you cannot turn around and offer n8n as a hosted service to third parties, or bundle-and-resell it commercially, without a separate enterprise license.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Activepieces ships under MIT&lt;/strong&gt; (its Community Edition). MIT is the permissive, OSI-approved baseline — embed it, fork it, resell it, close your fork, no copyleft strings. The honest nuance: MIT covers the Community Edition core; Activepieces keeps some enterprise features under a separate commercial license, so "MIT" describes the open core, not every paid add-on. But the core you'd self-host and build on is genuinely permissive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node-RED ships under the Apache License 2.0&lt;/strong&gt;, stewarded by the OpenJS Foundation. Also OSI-approved and permissive, and Apache 2.0 goes one step past MIT by including an explicit patent grant — the contributors license the patents needed to use their contribution, and there's a patent-retaliation clause. If your legal team cares about patent exposure in a shipped product, that clause is a feature, not boilerplate.&lt;/p&gt;

&lt;p&gt;Three tiers: source-available-with-a-redistribution-fence, permissive, permissive-with-a-patent-grant. Same "open" label on the tin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the license is an architectural constraint, not a footnote
&lt;/h2&gt;

&lt;p&gt;Here's the reframe: the license isn't a compliance checkbox you clear at the end. It's a boundary on your &lt;em&gt;architecture&lt;/em&gt;, because it constrains where the automation engine is allowed to live in your system.&lt;/p&gt;

&lt;p&gt;Ask one question — &lt;strong&gt;is the automation layer something you run, or something you ship?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you &lt;em&gt;run&lt;/em&gt; it — internal ETL, ops glue, a content pipeline like ours, back-office orchestration that never leaves your own walls — then n8n's fair-code fence never touches you. "Internal business purposes" is exactly what you're doing. That's precisely our situation: we chose n8n knowingly, and the Sustainable Use License was a non-issue because we don't resell the engine or expose it as a product to anyone. In that world you should optimize for depth, and n8n's depth is real — on the order of 1,100 integrations, strong branching and error handling, and a node ecosystem that means most of what you need already exists.&lt;/p&gt;

&lt;p&gt;If you &lt;em&gt;ship&lt;/em&gt; it — the automation engine is embedded in a product your customers touch, or you're offering "workflows" as a feature of your SaaS, or you're a platform letting &lt;em&gt;your&lt;/em&gt; users build automations — you've crossed the exact line the fair-code license draws. Now n8n's restriction is load-bearing. Offering it as a service to third parties is the thing the license reserves. At that point the feature-richest tool is no longer the honest answer; the &lt;em&gt;eligible&lt;/em&gt; tools are the permissively licensed ones. Activepieces (MIT) and Node-RED (Apache 2.0) let you embed, white-label, fork, and resell without asking anyone. That's not a marketing claim — it's what those license texts grant.&lt;/p&gt;

&lt;p&gt;This is why a license-blind alternatives list can actively mislead. It'll rank n8n at the top for a reader who's building a product &lt;em&gt;on top of&lt;/em&gt; an automation engine — a reader for whom n8n's own license says "not like this." The ranking is correct for the internal-use reader and wrong for the productizing reader, and the table gives you no way to tell which one you are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the hosted tools fit (and why they dodge the question)
&lt;/h2&gt;

&lt;p&gt;For completeness: we've also driven Make (built a real scenario against its API) and Zapier (ran its MCP across Gmail, Calendar, and Slack). Both are fine tools. But they're hosted SaaS — there's no repo to self-host and no &lt;code&gt;LICENSE&lt;/code&gt; file to read, so the whole license-tier question is moot. You're renting capacity and accepting the terms of service, full stop. That's a legitimate choice; it's just a &lt;em&gt;different&lt;/em&gt; decision than the one this post is about. The license axis only exists once you've committed to self-hosting, which is where the "n8n alternatives" search usually lands you anyway.&lt;/p&gt;

&lt;p&gt;The same "it's a service, not a license" framing applies to most of the other hosted names that show up in these roundups — Pipedream, Workato, Power Automate, Gumloop, Tray.ai. I'm assessing those from their docs and terms, not from production use, and none of them change the core point: if you're not self-hosting, you're evaluating a contract, not a license.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, compressed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Running automations internally?&lt;/strong&gt; Fair-code is fine. Optimize for depth and ecosystem — n8n earns its spot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping the automation layer inside a product, or need to fork/embed/resell freely?&lt;/strong&gt; The permissive licenses are the honest destination, not the feature-count winner. Look hard at Activepieces (MIT) and Node-RED (Apache 2.0), and read their &lt;code&gt;LICENSE&lt;/code&gt; files yourself before you commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not self-hosting at all?&lt;/strong&gt; The license tier doesn't apply — you're picking a SaaS on terms-of-service and pricing, and Make/Zapier/Pipedream are competing on that axis instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical move: before you shortlist &lt;em&gt;any&lt;/em&gt; self-hosted automation engine, open its &lt;code&gt;LICENSE&lt;/code&gt; file first and answer the run-it-vs-ship-it question. That one field filters the list faster than any feature matrix, and it filters it &lt;em&gt;correctly&lt;/em&gt; — because it filters on what you're actually allowed to do, not on what the tool can do.&lt;/p&gt;

&lt;p&gt;When I sanity-checked our own stack against this lens, I ended up pulling together &lt;a href="https://aialleyway.com/n8n-alternatives/" rel="noopener noreferrer"&gt;the full n8n alternatives breakdown, sorted by why you're leaving&lt;/a&gt; — nine tools grouped by the reason someone actually migrates (license, hosting model, pricing shape, integration gaps) rather than by a single leaderboard. If you're weighing a switch, reading it by &lt;em&gt;your&lt;/em&gt; reason-for-leaving is a lot more useful than reading it top-to-bottom.&lt;/p&gt;

&lt;p&gt;Read the LICENSE file first. It's the cheapest architecture decision you'll make all quarter.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>automation</category>
      <category>n8n</category>
      <category>devops</category>
    </item>
    <item>
      <title>Generate your Open Graph images with React (Remotion), not a design tool</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/aialleyway/generate-your-open-graph-images-with-react-remotion-not-a-design-tool-1093</link>
      <guid>https://dev.to/aialleyway/generate-your-open-graph-images-with-react-remotion-not-a-design-tool-1093</guid>
      <description>&lt;p&gt;Every blog post, product page, and share link wants its own Open Graph image — the card that shows up when the URL is posted to X, LinkedIn, Slack, or iMessage. Hand-making those in Figma doesn't scale: the moment you have a few dozen pages, you're copy-pasting a template and re-exporting PNGs by hand.&lt;/p&gt;

&lt;p&gt;I moved mine to code. One React component, one CLI command per card, and a size-optimization step. Here's the whole setup — including the two gotchas that cost me an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Remotion?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.remotion.dev/" rel="noopener noreferrer"&gt;Remotion&lt;/a&gt; is "React for videos" — but it renders &lt;strong&gt;still frames&lt;/strong&gt; just as happily as video, and a 1200×630 OG card is just a single frame of a React component. That means your card is a normal component: props, flexbox, your real fonts and brand tokens, conditional layout. No design-tool round-trip, no drift between your site's styles and your cards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The composition
&lt;/h2&gt;

&lt;p&gt;Register a composition sized to the OG spec and drive everything from &lt;code&gt;inputProps&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OgCard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FC&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headline&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="nl"&gt;productName&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="nl"&gt;rating&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="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="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;productName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rating&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AbsoluteFill&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linear-gradient(135deg,#0b0b0f,#1a1330)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;space-between&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontFamily&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Outfit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;productName&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;productName&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="nx"&gt;rating&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;rating&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/5`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;AbsoluteFill&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// index.ts&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Composition&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"OgCard"&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;OgCard&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;durationInFrames&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;defaultProps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headline&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="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rendering one card per page
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;renderStill&lt;/code&gt; writes a single PNG. Feed it the per-page props as JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx remotion still src/index.ts OgCard out/my-post.png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--props&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"headline":"The cheaper voice tool wasn'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'t on the pricing page","productName":"Acme TTS","rating":4}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop that over your content collection and you have a card per URL, regenerated on every build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't ship the PNG — convert to WebP
&lt;/h2&gt;

&lt;p&gt;A 1200×630 PNG out of a headless browser is big — mine came out around &lt;strong&gt;770 KB&lt;/strong&gt;. That's absurd for a social card. One conversion step drops it to &lt;strong&gt;~17 KB&lt;/strong&gt; with no visible loss:&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;# ImageMagick&lt;/span&gt;
magick out/my-post.png &lt;span class="nt"&gt;-quality&lt;/span&gt; 82 &lt;span class="nt"&gt;-define&lt;/span&gt; webp:method&lt;span class="o"&gt;=&lt;/span&gt;6 out/my-post.webp
&lt;span class="c"&gt;# or sharp: sharp(png).webp({ quality: 82 }).toFile(webp)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point your &lt;code&gt;og:image&lt;/code&gt; / &lt;code&gt;twitter:image&lt;/code&gt; at the WebP. ~45× smaller, same card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: &lt;code&gt;selectComposition&lt;/code&gt; needs the SAME inputProps as &lt;code&gt;renderStill&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you render programmatically (Node API) rather than the CLI, you call &lt;code&gt;selectComposition()&lt;/code&gt; first, then &lt;code&gt;renderStill()&lt;/code&gt;. &lt;strong&gt;Pass your &lt;code&gt;inputProps&lt;/code&gt; to BOTH.&lt;/strong&gt; Anything resolved at selection time — most importantly &lt;code&gt;staticFile()&lt;/code&gt; references and anything derived from props in &lt;code&gt;calculateMetadata&lt;/code&gt; — uses &lt;code&gt;defaultProps&lt;/code&gt; if you only handed props to &lt;code&gt;renderStill&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The symptom is baffling the first time: every card comes out byte-identical (the default props rendered), or an image &lt;code&gt;staticFile('logo.png')&lt;/code&gt; silently resolves to the placeholder. It's not a caching bug — it's the selection step running on defaults.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;productName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rating&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;comp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;selectComposition&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;serveUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OgCard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inputProps&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// ← here&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderStill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;composition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;comp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;serveUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inputProps&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;        &lt;span class="c1"&gt;// ← and here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotcha 2: &lt;code&gt;renderStill({ frame })&lt;/code&gt; requires &lt;code&gt;durationInFrames &amp;gt; frame&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you register a still-only composition with &lt;code&gt;durationInFrames: 1&lt;/code&gt; and then ask for &lt;code&gt;frame: 30&lt;/code&gt; (say, to let an entrance animation settle), Remotion throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RangeError: Cannot use frame 30: Duration of composition is 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give the composition enough frames for the frame you sample. I register OG/still compositions with &lt;code&gt;durationInFrames: 60&lt;/code&gt; (2s @ 30fps) and render whatever frame I want within that.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's the whole pipeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One React component = your card, in your real styles.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;renderStill&lt;/code&gt; + a JSON props blob = one card per page, on every build.&lt;/li&gt;
&lt;li&gt;A PNG→WebP step = ~45× smaller files.&lt;/li&gt;
&lt;li&gt;Remember: same &lt;code&gt;inputProps&lt;/code&gt; to &lt;code&gt;selectComposition&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;renderStill&lt;/code&gt;, and &lt;code&gt;durationInFrames&lt;/code&gt; bigger than the frame you sample.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once it's wired, adding a new page's social card is zero manual work — it falls out of the build. Worth the afternoon.&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Shipping one Manifest V3 extension to Chrome, Edge, and Firefox from a single source</title>
      <dc:creator>AI Alleyway</dc:creator>
      <pubDate>Sun, 05 Jul 2026 10:38:26 +0000</pubDate>
      <link>https://dev.to/aialleyway/shipping-one-manifest-v3-extension-to-chrome-edge-and-firefox-from-a-single-source-cep</link>
      <guid>https://dev.to/aialleyway/shipping-one-manifest-v3-extension-to-chrome-edge-and-firefox-from-a-single-source-cep</guid>
      <description>&lt;p&gt;I recently shipped a small Manifest V3 browser extension — a toolbar popup plus an address-bar (omnibox) search, zero permissions — and wanted it on all three major stores: the Chrome Web Store, Edge Add-ons, and Firefox's AMO.&lt;/p&gt;

&lt;p&gt;The pitch for MV3 is that it's the shared standard, so "write once, ship everywhere." That's mostly true — but the small divergences are exactly the kind of thing that fails a store review at 11pm. Here's the complete list of what actually differs, plus a tiny build script that emits all three packages from one source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome and Edge are the same package
&lt;/h2&gt;

&lt;p&gt;Good news first: &lt;strong&gt;Edge Add-ons accepts the exact same MV3 zip as the Chrome Web Store.&lt;/strong&gt; Edge is Chromium, &lt;code&gt;background.service_worker&lt;/code&gt; works as-is, and the &lt;code&gt;chrome.*&lt;/code&gt; APIs are identical. You upload the same artifact to both. (I keep them as separately-named zips only for a clean per-store upload trail.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Firefox needs three manifest tweaks — and zero code changes
&lt;/h2&gt;

&lt;p&gt;Firefox is where it gets interesting. The &lt;em&gt;code&lt;/em&gt; didn't change at all — the extension uses only &lt;code&gt;chrome.omnibox&lt;/code&gt;, &lt;code&gt;chrome.runtime&lt;/code&gt;, and &lt;code&gt;chrome.tabs&lt;/code&gt;, all of which Firefox exposes via the &lt;code&gt;chrome.*&lt;/code&gt; alias. Only the manifest needs three edits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;background.service_worker&lt;/code&gt; → &lt;code&gt;background.scripts&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Firefox's MV3 support prefers an event page over a service worker for the widest compatibility. If your background script only registers listeners at the top level (no service-worker-only globals), it runs fine as an event-page script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Chrome&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Edge&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"background"&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;"service_worker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"background.js"&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Firefox&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"background"&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;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"background.js"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;browser_specific_settings.gecko.id&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AMO requires an explicit extension id. Chrome/Edge derive one for you; Firefox wants it declared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"browser_specific_settings"&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;"gecko"&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;"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;"your-ext@yourdomain.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;data_collection_permissions&lt;/code&gt; — and the version floor it drags in&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the one that surprised me. Newer Firefox requires every extension to &lt;em&gt;explicitly declare&lt;/em&gt; what user data it collects — even when the answer is "nothing." Omit it and &lt;code&gt;web-ext lint&lt;/code&gt; fails with &lt;code&gt;MISSING_DATA_COLLECTION_PERMISSIONS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"gecko"&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;"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;"your-ext@yourdomain.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"strict_min_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"142.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data_collection_permissions"&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;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gotcha is the version floor. &lt;code&gt;data_collection_permissions&lt;/code&gt; is only supported from Firefox &lt;strong&gt;140 on desktop&lt;/strong&gt; and &lt;strong&gt;142 on Android&lt;/strong&gt;. Set &lt;code&gt;strict_min_version&lt;/code&gt; below 142 and the linter throws &lt;code&gt;KEY_FIREFOX_UNSUPPORTED_BY_MIN_VERSION&lt;/code&gt; — first for desktop, then again for Android. &lt;code&gt;142.0&lt;/code&gt; is the floor that satisfies both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The omnibox trap on Firefox for Android
&lt;/h2&gt;

&lt;p&gt;If your extension has an omnibox keyword like mine, do &lt;strong&gt;not&lt;/strong&gt; tick "Firefox for Android" compatibility on AMO. The &lt;a href="https://extensionworkshop.com/documentation/develop/differences-between-desktop-and-android-extensions/" rel="noopener noreferrer"&gt;&lt;code&gt;omnibox&lt;/code&gt; API is not supported on Firefox for Android&lt;/a&gt; — the popup still works as an overlay, but the address-bar keyword silently does nothing. Ship desktop-only until you've adapted and tested for mobile, or you'll be shipping a broken core feature to Android users.&lt;/p&gt;

&lt;h2&gt;
  
  
  One source, three zips
&lt;/h2&gt;

&lt;p&gt;Rather than maintain three manifests by hand, keep one and transform it at build time. The whole script is ~40 lines; the interesting part is the Firefox transform:&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;firefoxManifest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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;fx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;structuredClone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;fx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;background.js&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;fx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browser_specific_settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;gecko&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-ext@yourdomain.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;strict_min_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;142.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data_collection_permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// pack("chrome", base);&lt;/span&gt;
&lt;span class="c1"&gt;// pack("edge",   base);              // === Chrome, kept separate for a clean upload trail&lt;/span&gt;
&lt;span class="c1"&gt;// pack("firefox", firefoxManifest(base));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;pack()&lt;/code&gt; stages the shared files (&lt;code&gt;popup.*&lt;/code&gt;, &lt;code&gt;background.js&lt;/code&gt;, icons, plus the per-store manifest) and zips them with paths at the archive root (&lt;code&gt;manifest.json&lt;/code&gt; must sit at the top level, not under a subfolder).&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate before you upload
&lt;/h2&gt;

&lt;p&gt;Run &lt;a href="https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#web-ext-lint" rel="noopener noreferrer"&gt;&lt;code&gt;web-ext lint&lt;/code&gt;&lt;/a&gt; — the same validator AMO runs — on the Firefox package &lt;em&gt;before&lt;/em&gt; submitting. It catches the version-floor and data-collection issues above locally, instead of after a rejected upload. My bar was 0 errors / 0 warnings / 0 notices before the zip went anywhere near the store.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome == Edge&lt;/strong&gt; — one MV3 zip, upload to both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox&lt;/strong&gt; — three manifest lines (&lt;code&gt;background.scripts&lt;/code&gt;, &lt;code&gt;gecko.id&lt;/code&gt;, &lt;code&gt;data_collection_permissions&lt;/code&gt;) and one version floor (&lt;code&gt;142.0&lt;/code&gt;). No code changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Omnibox on Android&lt;/strong&gt; — unsupported; don't tick the box.&lt;/li&gt;
&lt;li&gt;Lint locally with &lt;code&gt;web-ext&lt;/code&gt; so the store never says no.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're maintaining separate repos or manifests per browser, collapsing to one source + a build transform is an afternoon well spent.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>firefox</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
