<?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: Ken Imoto</title>
    <description>The latest articles on DEV Community by Ken Imoto (@kenimo49).</description>
    <link>https://dev.to/kenimo49</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%2F3800250%2F275022f6-cba9-47e3-b69e-e8faf7675a0c.jpg</url>
      <title>DEV Community: Ken Imoto</title>
      <link>https://dev.to/kenimo49</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenimo49"/>
    <language>en</language>
    <item>
      <title>KV Cache Quantization: I Stretched Qwen 35B's Context 8 on 12GB VRAM</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 28 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/kv-cache-quantization-i-stretched-qwen-35bs-context-8x-on-12gb-vram-2o8j</link>
      <guid>https://dev.to/kenimo49/kv-cache-quantization-i-stretched-qwen-35bs-context-8x-on-12gb-vram-2o8j</guid>
      <description>&lt;h2&gt;
  
  
  600 MiB of headroom
&lt;/h2&gt;

&lt;p&gt;My RTX 4070 was running Qwen 35B beautifully after the &lt;code&gt;--cpu-moe&lt;/code&gt; trick from a previous run. The tokens/sec were where I wanted them. VRAM sat at 11,714 MiB out of 12,281 — 95% full.&lt;/p&gt;

&lt;p&gt;That leaves 600 MiB. Not enough for a serious agent.&lt;/p&gt;

&lt;p&gt;The context window I was giving llama.cpp was &lt;code&gt;-c 4096&lt;/code&gt;. Fine for chat. Not fine when a Claude Code-style agent hands the model 12,000 tokens of tool definitions before it says hello.&lt;/p&gt;

&lt;p&gt;I wanted &lt;code&gt;-c 32768&lt;/code&gt;. That's an 8× jump. And the memory that grows with context length is the KV cache. Multiply the cache by 8 with 600 MiB free, and llama.cpp dies during warm-up. I know because I tried it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually sits on the GPU
&lt;/h2&gt;

&lt;p&gt;After offloading the MoE experts to CPU (the previous chapter's trick), the GPU is holding two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The attention weights and non-MoE parameters&lt;/li&gt;
&lt;li&gt;The KV cache — a running record of every token the model has already read&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first is fixed. The second grows linearly with context length. Double the context, double the cache. &lt;code&gt;-c 4096 → -c 32768&lt;/code&gt; doesn't just want 8× more tokens processed, it wants 8× more cache resident in VRAM the whole time.&lt;/p&gt;

&lt;p&gt;There is no room. So the cache itself has to shrink.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two flags
&lt;/h2&gt;

&lt;p&gt;llama.cpp takes two flags for KV cache dtype:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; qwen35.gguf &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;--cpu-moe&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 32768 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ctk&lt;/span&gt; q8_0 &lt;span class="nt"&gt;-ctv&lt;/span&gt; q8_0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-ctk&lt;/code&gt; is the Key cache, &lt;code&gt;-ctv&lt;/code&gt; is the Value cache. Default is f16 (16-bit). &lt;code&gt;q8_0&lt;/code&gt; cuts each in half. Halving both means the KV cache footprint drops by roughly 50%.&lt;/p&gt;

&lt;p&gt;That freed-up VRAM is exactly what I need to make the context 8× bigger without touching the model weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Same prompt, same seed, two runs — one at f16 KV, one at q8_0 KV:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;KV dtype&lt;/th&gt;
&lt;th&gt;Max &lt;code&gt;-c&lt;/code&gt; I could allocate&lt;/th&gt;
&lt;th&gt;Tokens/sec (decode)&lt;/th&gt;
&lt;th&gt;Perplexity delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;f16 (default)&lt;/td&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;td&gt;~34.6&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q8_0&lt;/td&gt;
&lt;td&gt;32768&lt;/td&gt;
&lt;td&gt;~34.1&lt;/td&gt;
&lt;td&gt;negligible in my tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The speed loss is inside noise. The context is 8× longer. The quality drop I could not tell apart from run-to-run variance.&lt;/p&gt;

&lt;p&gt;Community measurements agree: symmetric q8_0 KV lands somewhere under 0.1% perplexity delta on most models. Going harder — q4_0 on both K and V — is where you start seeing real degradation, and asymmetric setups (&lt;code&gt;-ctk q4_0 -ctv q8_0&lt;/code&gt;) end up being the pragmatic Q4 configuration if you go there.&lt;/p&gt;

&lt;p&gt;I have not gone there. q8_0 was already enough headroom for the agent workloads I run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-off summary
&lt;/h2&gt;

&lt;p&gt;Keep it in your head as two rows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;f16 KV&lt;/strong&gt;: fastest, but context stays around 4096 on a 12GB card. Fine for chat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;q8_0 KV&lt;/strong&gt;: same speed you measured before, VRAM roughly halved on the cache side, context can grow to 32768. Fine for agents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the entire decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to actually bump &lt;code&gt;-c&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Bigger &lt;code&gt;-c&lt;/code&gt; is not free even with quantized KV. llama.cpp reserves the full window up front — a 32k context eats 32k worth of cache the moment the server boots, whether you use it or not.&lt;/p&gt;

&lt;p&gt;So I tier it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chat, single-shot generation&lt;/strong&gt;: &lt;code&gt;-c 4096&lt;/code&gt;. Do not bother quantizing KV.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Light agents (Claude Code-style)&lt;/strong&gt;: &lt;code&gt;-c 8192&lt;/code&gt; covered almost every case I hit. No quantization needed if you have any headroom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy agents (multi-tool CLIs like Qwen Code CLI)&lt;/strong&gt;: &lt;code&gt;-c 32768&lt;/code&gt; with &lt;code&gt;-ctk q8_0 -ctv q8_0&lt;/code&gt;. The first agent turn alone can hit 19,000 tokens once system prompt and tool schemas load. 8192 will crash mid-invocation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long document reading&lt;/strong&gt;: only bump &lt;code&gt;-c&lt;/code&gt; to the actual document size. Do not pre-provision 32k for a 6k document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generalization "agents need big context and quantized KV" is too coarse. The right number depends entirely on the CLI's tool definition weight. Two agent frameworks on the same model can want radically different &lt;code&gt;-c&lt;/code&gt; values.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-line summary
&lt;/h2&gt;

&lt;p&gt;If you have a 12GB card and you want Qwen 35B to run agents:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Offload the MoE experts to CPU (previous chapter).&lt;/li&gt;
&lt;li&gt;Turn on q8_0 KV cache (this chapter).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a total of three flags: &lt;code&gt;--cpu-moe -ctk q8_0 -ctv q8_0&lt;/code&gt;. For that, you get roughly the same tokens/sec you had, an 8× longer usable context, and no measurable quality regression on the workloads I have tested.&lt;/p&gt;

&lt;p&gt;I spent a week assuming I needed a 4090 to get here. I did not.&lt;/p&gt;




&lt;p&gt;If you're pointing a Claude Code-style agent at this setup — where tool schemas and system prompts eat 12k tokens before you type anything — the sizing decisions and CLAUDE.md hardening are covered here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/claude-code-mastery?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=kv-cache-8x-context" rel="noopener noreferrer"&gt;Claude Code Mastery — agent workflows, context budgeting, and hardening&lt;/a&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>gpu</category>
      <category>performance</category>
    </item>
    <item>
      <title>The AI Summary Said "It's Not a Scam." The Springboard Was Your Site's Search Box</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 28 Jul 2026 01:28:17 +0000</pubDate>
      <link>https://dev.to/kenimo49/the-ai-summary-said-its-not-a-scam-the-springboard-was-your-sites-search-box-3iao</link>
      <guid>https://dev.to/kenimo49/the-ai-summary-said-its-not-a-scam-the-springboard-was-your-sites-search-box-3iao</guid>
      <description>&lt;p&gt;Last August, a man planning a cruise googled Royal Caribbean's customer service number. Google's AI Overview served him a phone number at the top of the results. He called it, handed over his card details, and the number belonged to scammers. Similar cases hit Southwest Airlines searches. That variant, fake support numbers planted where AI summaries would pick them up, got plenty of coverage.&lt;/p&gt;

&lt;p&gt;Last week, Japan's Metropolitan Police announced a quieter variant that I think deserves more attention from developers, because the attack surface sits on legitimate sites: the search box. Possibly the one on yours.&lt;/p&gt;

&lt;p&gt;Here's the scene the police described: someone gets invited into an investment group on social media. Before sending money, they do the sensible thing and search the group's name. The results show "XX is not a scam" and "I made money with XX." The AI summary at the top of the page agrees: "XX is not a scam." Reassured, they transfer the money.&lt;/p&gt;

&lt;p&gt;The victim's verification habit -- "let me search before I trust this" -- has been folded into the trap.&lt;/p&gt;

&lt;p&gt;When I read the report, my first question was: how? I work on LLMO (optimizing sites to get cited by AI search) day to day, so I suspected one of the search-pollution techniques floating around SEO circles. The trail led to something older and dumber than I expected: site-search spam, documented by the Japanese SEO firm JADE back in February 2023.&lt;/p&gt;

&lt;p&gt;This post covers the mechanism, why AI summaries repeat the lie, and the defenses you can ship this week (noindex, &lt;code&gt;X-Robots-Tag&lt;/code&gt;, 404-on-zero-hits).&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism: three steps, no hacking
&lt;/h2&gt;

&lt;p&gt;Most sites with a search box return results at a URL like &lt;code&gt;/search?q=keyword&lt;/code&gt;. Two properties of a typical implementation set up the attack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anyone can put an arbitrary string in the query parameter&lt;/li&gt;
&lt;li&gt;The page reflects that query into its &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; ("Search results for 'keyword' | Acme Corp")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The attack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The attacker composes a search URL on a trusted domain: &lt;code&gt;acme.com/search?q=XX+is+not+a+scam&lt;/code&gt;. No need to touch the search box. The URL alone does the job.&lt;/li&gt;
&lt;li&gt;They link to that URL from sites they control.&lt;/li&gt;
&lt;li&gt;Googlebot follows the link, crawls the results page, and indexes it. From then on, web search can show "XX is not a scam | Acme Corp" under a legitimate domain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The victimized site was never breached. No malware, no intrusion, no tools. The attacker built a URL and placed a link. When I first understood this, I said "wait, that's it?" out loud. What's being exploited is not a vulnerability. It's a spec.&lt;/p&gt;

&lt;p&gt;To the person searching, it looks like Acme Corp's website says "not a scam." The trust the domain spent years earning gets subleased to a stranger's sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the AI summary repeats the lie
&lt;/h2&gt;

&lt;p&gt;AI Overviews and similar features are structurally close to RAG: retrieve pages relevant to the query from the search index, then compose an answer from them. The internals aren't public, but the dependency is observable: the summary is built downstream of the index.&lt;/p&gt;

&lt;p&gt;The AI has no way to smell the setup. What it retrieved is, as far as it can tell, text on a trusted domain. It doesn't verify claims; it weighs source authority and cross-source agreement. So if an attacker seeds the same sentence into search URLs on several reputable domains, the AI sees multiple independent authoritative sources agreeing.&lt;/p&gt;

&lt;p&gt;That's the ugly part: the more seriously an AI weights authority signals, the better this attack works on it. The diligent ones are the easiest marks.&lt;/p&gt;

&lt;p&gt;The pipeline is simple: search index upstream, AI summary downstream. Poison the upstream and the downstream poisons itself. You could wait for AI vendors to filter better (Google said it "took action" on the fake phone numbers; new ones kept popping up), or you could close the reflection surface on your own site, which is faster and actually under your control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5-minute self-check
&lt;/h2&gt;

&lt;p&gt;Can your site be used as a springboard? Three checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. Are your search result pages indexed? (in Google)
site:example.com inurl:search
site:example.com inurl:"?s="

# 2. Indexed under suspicious phrases?
site:example.com scam
site:example.com refund
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 3. Do your search result pages carry noindex?&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/search?q=test"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; x-robots-tag

&lt;span class="c"&gt;# No header? Check the HTML meta tag&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/search?q=test"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;meta name="robots"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;site:&lt;/code&gt; queries are a quick smoke test; Google doesn't guarantee exhaustive results. For a definitive answer, open Search Console and check Indexing &amp;gt; Pages and Performance &amp;gt; Pages for URLs containing &lt;code&gt;/search&lt;/code&gt; or &lt;code&gt;?s=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also look at your search results template: does it reflect the query into &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;? Reflection plus indexability is the combination that makes you a target.&lt;/p&gt;

&lt;p&gt;One reassurance: client-side search (JS filtering in the browser, common on static sites) doesn't have this attack surface at all, because the server never returns different HTML per query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defenses
&lt;/h2&gt;

&lt;p&gt;Two viable strategies, based on JADE's recommendations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;th&gt;Caveat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;meta name="robots" content="noindex"&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reliably keeps result pages out of the index&lt;/td&gt;
&lt;td&gt;Neutralized if robots.txt blocks the page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;X-Robots-Tag: noindex&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;Same, applied at infra level without touching templates&lt;/td&gt;
&lt;td&gt;Same caveat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;noindex (or 404) on zero-hit queries&lt;/td&gt;
&lt;td&gt;Keeps search-page SEO traffic while blocking spam&lt;/td&gt;
&lt;td&gt;404 can hurt UX for legitimate zero-hit queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;robots.txt&lt;/code&gt; &lt;code&gt;Disallow: /search&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Suppresses crawling&lt;/td&gt;
&lt;td&gt;Incomplete alone -- blocked URLs can still get indexed via external links&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Choosing is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Not chasing SEO traffic on search result pages? noindex all of them. Simplest, most reliable.&lt;/li&gt;
&lt;li&gt;Want to keep that traffic? Return noindex on zero-hit queries. Spam phrases like "XX is not a scam" almost always hit zero results, so this alone kills most of the attack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is one trap worth internalizing: &lt;strong&gt;noindex only works if the crawler can read the page.&lt;/strong&gt; Block the URL in robots.txt and the crawler never sees your noindex, which un-neutralizes the whole defense. Google's docs state it outright: for noindex to be effective, the page must not be blocked by robots.txt. Never combine the two on the same URL.&lt;/p&gt;

&lt;p&gt;Implementation examples.&lt;/p&gt;

&lt;p&gt;WordPress search pages (&lt;code&gt;?s=&lt;/code&gt;) get noindex by default if you run Yoast or similar. On a bare theme, use the &lt;code&gt;wp_robots&lt;/code&gt; filter (WordPress 5.7+, plays nicely with core and plugin output):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// functions.php&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_robots'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_search&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'noindex'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js (App Router):&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="c1"&gt;// app/search/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the infra layer, nginx. Two gotchas in this snippet: it matches path-style search URLs (&lt;code&gt;/search&lt;/code&gt;), not query-style (&lt;code&gt;?s=&lt;/code&gt;); for those you'd branch on &lt;code&gt;$arg_s&lt;/code&gt; instead. And nginx's &lt;code&gt;add_header&lt;/code&gt; has inheritance rules that bite: a single &lt;code&gt;add_header&lt;/code&gt; inside a location cancels all headers defined at upper levels, so re-declare your security headers there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/search&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Robots-Tag&lt;/span&gt; &lt;span class="s"&gt;"noindex"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# re-declare upper-level add_header lines (security headers etc.) here&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even setting the scam angle aside, noindexing search result pages is standard SEO hygiene: it prevents duplicate-content bloat and crawl budget waste. This is a good excuse to finally do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The "not a scam" poisoning that Japanese police warned about is explained by site-search spam. What's exploited is the spec: reflect the query, allow indexing.&lt;/li&gt;
&lt;li&gt;AI summaries are RAG over the search index. Upstream poison becomes the downstream answer. Closing your reflection surface is faster than waiting for AI-side filters.&lt;/li&gt;
&lt;li&gt;noindex is the backbone. Never robots.txt-block a URL you want noindexed. Keep search traffic if you want it, but noindex on zero hits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run a site, try &lt;code&gt;site:yourdomain inurl:search&lt;/code&gt; today. If anything comes back, the defense section above is your afternoon. Is your search box carrying someone's "it's not a scam"?&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Metropolitan Police Department (Japan), Cyber Security Countermeasures Division advisory, July 24, 2026. Coverage: &lt;a href="https://www.itmedia.co.jp/news/articles/2607/27/news076.html" rel="noopener noreferrer"&gt;ITmedia NEWS, July 27, 2026&lt;/a&gt; (Japanese)&lt;/li&gt;
&lt;li&gt;Yusuke Murayama, &lt;a href="https://blog.ja.dev/entry/blog/2023/02/08/site-search-spam" rel="noopener noreferrer"&gt;Site-search spam abusing other companies' sites&lt;/a&gt;, JADE blog, February 8, 2023 (Japanese)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://yro.slashdot.org/story/25/08/18/0223228/googles-ai-overview-pointed-him-to-a-customer-service-number-it-was-a-scam" rel="noopener noreferrer"&gt;Washington Post via Slashdot: Google's AI Overview pointed him to a customer service number. It was a scam.&lt;/a&gt; (August 2025)&lt;/li&gt;
&lt;li&gt;Google Search Central, &lt;a href="https://developers.google.com/search/docs/crawling-indexing/block-indexing" rel="noopener noreferrer"&gt;Block Search indexing with noindex&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>freee + MCP: I Automated My Japanese Tax Return With Claude Code — 3 Things That Broke in Production</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 27 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/freee-mcp-i-automated-my-japanese-tax-return-with-claude-code-3-things-that-broke-in-production-22i8</link>
      <guid>https://dev.to/kenimo49/freee-mcp-i-automated-my-japanese-tax-return-with-claude-code-3-things-that-broke-in-production-22i8</guid>
      <description>&lt;h2&gt;
  
  
  Context for readers outside Japan
&lt;/h2&gt;

&lt;p&gt;Japan's &lt;em&gt;kakutei shinkoku&lt;/em&gt; is the annual self-employed tax return — the local equivalent of the US Schedule C or the UK Self-Assessment. Every sole trader files one. The bookkeeping underneath it is the same problem every self-employed engineer knows: categorize a year of receipts correctly, or the tax office comes back with questions.&lt;/p&gt;

&lt;p&gt;freee is the country's dominant cloud accounting SaaS for this. In late 2025 they shipped an MCP server. So this year I let Claude Code drive it, and filed the whole thing with the AI in the loop.&lt;/p&gt;

&lt;p&gt;That is the part that worked. Below is the part that did not.&lt;/p&gt;

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

&lt;p&gt;The MCP wiring is standard OAuth + PKCE. Claude Desktop config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"freee"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@him0/freee-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&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;"FREEE_CLIENT_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_client_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"FREEE_CLIENT_SECRET"&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_client_secret"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser opens once for OAuth consent. After that, refresh tokens keep the session alive.&lt;/p&gt;

&lt;p&gt;The first useful moment came about ten seconds after setup. I typed one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Log a ¥3,200 stationery purchase from Amazon on Dec 15.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude picked the account (消耗品費 / office supplies), computed the tax code (課対仕入10% / 10% input-taxable purchase), and wrote it to freee. Three seconds. Doing that by hand through the freee UI takes two or three minutes.&lt;/p&gt;

&lt;p&gt;Ten in a row, all correctly categorized:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Account picked&lt;/th&gt;
&lt;th&gt;Correct?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon stationery&lt;/td&gt;
&lt;td&gt;Office supplies&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Taxi fare&lt;/td&gt;
&lt;td&gt;Travel expense&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coworking monthly&lt;/td&gt;
&lt;td&gt;Rent&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain renewal&lt;/td&gt;
&lt;td&gt;Communication&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accountant fee&lt;/td&gt;
&lt;td&gt;Professional fee&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS invoice&lt;/td&gt;
&lt;td&gt;Communication&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business dinner&lt;/td&gt;
&lt;td&gt;Entertainment&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coffee (client meeting)&lt;/td&gt;
&lt;td&gt;Meeting expense&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Book (technical)&lt;/td&gt;
&lt;td&gt;Books/subscriptions&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Printer paper&lt;/td&gt;
&lt;td&gt;Office supplies&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten for ten. Batch mode was better — I threw a CSV of 32 transactions at it and had them all in freee in about three minutes. Manual: over an hour.&lt;/p&gt;

&lt;p&gt;That is the 90% story. Now the 10%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break #1: receipts cannot be attached
&lt;/h2&gt;

&lt;p&gt;Japanese tax law requires receipts (&lt;em&gt;ryōshūsho&lt;/em&gt;) to be attached to bookkeeping entries. This is not optional. Storage of the digital copy is legally mandatory.&lt;/p&gt;

&lt;p&gt;freee's REST API supports it. MCP does not. The moment I tried:&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;Tool: mcp_server__api_post
Path: /api/v1/receipts
Body: { "company_id": "xxx", "description": "electric bill, July" }
Response: 400 — Content-Type must be "multipart/form-data"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MCP's JSON-RPC transport cannot send &lt;code&gt;multipart/form-data&lt;/code&gt;. Binary uploads are not in the protocol. This is not a freee bug — this is the shape of MCP itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I stopped trying. Receipt uploads went into a separate Python script that hits freee's REST endpoint directly with &lt;code&gt;requests.post(files={...})&lt;/code&gt;. MCP handles the bookkeeping; the CLI handles the file uploads. Two tools, clean seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break #2: 270 tools, several thousand tokens gone before "hello"
&lt;/h2&gt;

&lt;p&gt;The freee MCP server exposes &lt;strong&gt;270 tools&lt;/strong&gt;. Accounting, HR, invoicing, timekeeping — the whole company platform. All 270 schemas load into every conversation turn.&lt;/p&gt;

&lt;p&gt;I only need maybe 8 of them for a tax return. The other 262 sit in context taking up space I would rather spend on transaction descriptions.&lt;/p&gt;

&lt;p&gt;This is a real 2026 pattern: SaaS vendors are shipping "one MCP server for the whole product" instead of narrow, task-scoped servers. It works right up until you connect a second one. Two of these and you're spending most of the context window on tool descriptions before the model reads a single receipt. I know this because I ran freee alongside a second MCP for two days before I noticed Claude cheerfully "forgetting" transactions I'd told it about three turns earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Client-side allow-listing of tool names. Claude Desktop supports this via config. Not every MCP client does — Cursor did at the time, some smaller clients didn't. If your client doesn't, you're stuck with the full 270.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break #3: rate limits, silently
&lt;/h2&gt;

&lt;p&gt;freee's API rate-limits per minute. Batch-processing 32 transactions in a row hits it. What happens then depends on the tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;REST API: 429 with &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;MCP tool call: the tool returns an error string. Claude sees a text failure and moves on. No backoff, no retry, no queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MCP has no rate-limit primitive in the protocol. There is no &lt;code&gt;retry_after&lt;/code&gt; field for a tool response to signal "wait 30s then try me again." So the model just fails the row and continues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I put a 1-second &lt;code&gt;sleep&lt;/code&gt; between transaction writes in my prompt ("write these one at a time, wait one second between them"). Ugly, but it holds. The 2026 story here is that MCP gateways with per-tool token-bucket enforcement are starting to appear — that is where the fix eventually lives, not in the model or the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the final workflow actually looks like
&lt;/h2&gt;

&lt;p&gt;The end state was a hybrid, not a pure-MCP flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Import bank/card CSV&lt;/strong&gt; → my script, not MCP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Categorize + write transactions to freee&lt;/strong&gt; → MCP + Claude&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload receipt PDFs&lt;/strong&gt; → my script, not MCP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation &amp;amp; review&lt;/strong&gt; → MCP + Claude&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final tax form generation&lt;/strong&gt; → freee's own UI (with MCP-produced data underneath)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;MCP owns the parts where language understanding matters — reading a messy expense description, picking an account, catching a typo in a memo. The CLI owns the parts where MCP structurally cannot go — files, throttling, orchestration.&lt;/p&gt;

&lt;p&gt;"MCP will replace my accountant" turned out to be the wrong framing. "MCP is a 90% automation layer, the last 10% is CLI glue" is the right one. And the last 10% is where all the design work is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-paragraph summary
&lt;/h2&gt;

&lt;p&gt;freee + MCP + Claude Code cut my bookkeeping time from a weekend of drudgery to about two hours. Three things broke in production: no binary uploads, 270-tool context bloat, silent rate-limit failures. All three had workarounds. None of the workarounds lived inside MCP. If you're building an MCP integration for a real business process, plan for the seams before you plan for the happy path.&lt;/p&gt;




&lt;p&gt;The full MCP threat model, plus the seven-service comparison of where each MCP server actually hits these ceilings, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/mcp-security-practice?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=freee-mcp-3-broke" rel="noopener noreferrer"&gt;MCP Security in Practice — production integration patterns and the failure modes nobody talks about&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>claude</category>
      <category>automation</category>
    </item>
    <item>
      <title>Playwright + Chromium: 1 Script Replaces 2 Manual Testers for WebRTC Video Call E2E</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Fri, 24 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/playwright-chromium-1-script-replaces-2-manual-testers-for-webrtc-video-call-e2e-1nm7</link>
      <guid>https://dev.to/kenimo49/playwright-chromium-1-script-replaces-2-manual-testers-for-webrtc-video-call-e2e-1nm7</guid>
      <description>&lt;p&gt;If you've ever watched a QA engineer press "call" on one laptop, walk across the room, and press "answer" on a phone before the ringing stops, you already know the setup. Two-browser sync is the single hardest part of WebRTC video call testing.&lt;/p&gt;

&lt;p&gt;The whole flow looks simple on a whiteboard. Alice presses call, Bob's screen lights up with an incoming banner, Bob answers, video panes appear on both sides. All of it has to be automated without breaking the timing. One script, two contexts, and enough patience to let ICE finish. That's the shape of it.&lt;/p&gt;

&lt;p&gt;I wrote this after replacing a two-person manual regression pass for a video call app. The team ran that check every merge to main, and every time it involved two people, two devices, and a Slack thread that started with "wait, ready?" Now one Playwright script does it in about 40 seconds on CI, and it fails loudly at the merge when signaling regresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two browsers and not one page
&lt;/h2&gt;

&lt;p&gt;The instinct on a first pass is to open one page and simulate both sides. That works for signaling unit tests. It fails for anything that involves peer connection state, because the local peer and the remote peer end up sharing the same JS runtime and the same fake camera, and every failure mode that matters in production stays invisible: race between offer and answer, ICE candidate ordering, DTLS handshake stall.&lt;/p&gt;

&lt;p&gt;Two Browser Contexts inside a single Chromium process fix that. Each context has its own cookies, localStorage, permission grants, and (crucially) its own &lt;code&gt;RTCPeerConnection&lt;/code&gt; instance living in its own renderer. When the caller's context sends an SDP offer, it goes out through the signaling server the same way it would from a second laptop, and the receiver context reads it back through a real network round trip.&lt;/p&gt;

&lt;p&gt;The same set of Playwright fixtures also catches signaling bugs, presence-server bugs, and TURN misconfiguration. The exact bugs manual testers used to catch by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The launch flags
&lt;/h2&gt;

&lt;p&gt;Every Chromium in this test has to lie about its hardware.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one-on-one video call connects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--use-fake-ui-for-media-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--use-fake-device-for-media-stream&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callerCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;microphone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receiverCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;microphone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&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;receiverPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--use-fake-ui-for-media-stream&lt;/code&gt; auto-accepts the permission prompt so the test doesn't hang staring at a browser dialog. &lt;code&gt;--use-fake-device-for-media-stream&lt;/code&gt; replaces &lt;code&gt;getUserMedia&lt;/code&gt; with a synthetic camera. The default is Chromium's green ball testcard, which is fine for signaling checks but useless if you want to verify pixels. For pixel checks, pass &lt;code&gt;--use-file-for-fake-video-capture=./sample.y4m&lt;/code&gt; and drop in a Y4M file. Chrome only reads Y4M here; MP4 is not supported for fake capture, which surprised me the first time.&lt;/p&gt;

&lt;p&gt;A gotcha worth calling out: those flags must be passed at &lt;em&gt;browser launch&lt;/em&gt; time. If you set them in &lt;code&gt;playwright.config.ts&lt;/code&gt; under &lt;code&gt;use.launchOptions.args&lt;/code&gt; but then call &lt;code&gt;chromium.launch({})&lt;/code&gt; directly inside a test, they're silently ignored on the second launcher. Same rule applies to any custom launcher.&lt;/p&gt;

&lt;h2&gt;
  
  
  The call sequence, step by step
&lt;/h2&gt;

&lt;p&gt;Two-browser tests read like a stage direction script. Every action has an implicit "wait until the other side sees it" between the lines.&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;caller initiates, receiver answers, both see video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Precondition: browser, callerPage, receiverPage already set up.&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 1: both join the same room&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#room-input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test-room&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#join-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#room-input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test-room&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#join-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: caller starts the call&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#call-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 3: wait for the incoming banner on the receiver&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#incoming-call-notification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 4: receiver answers&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#answer-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 5: wait for the remote-video element on both sides&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video#remote-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attached&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video#remote-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attached&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 6: verify actual frames are decoded, not just that &amp;lt;video&amp;gt; exists&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callerHasFrames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video#remote-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLVideoElement&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callerHasFrames&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  About the 10-15 second timeouts
&lt;/h3&gt;

&lt;p&gt;WebRTC connection setup is not fast. Signaling exchange over your signaling server, then ICE candidate gathering, then STUN/TURN round trips, then DTLS handshake, then media flow start. Even on localhost with a fake camera, the whole chain takes 3-8 seconds under normal conditions, and it can spike past 12 under CPU pressure on a shared CI runner.&lt;/p&gt;

&lt;p&gt;Playwright's default 5-second selector timeout is what gives you the classic "works locally, flakes in CI" pattern for WebRTC. Raising it to 30 seconds hides real regressions because ICE stalls take that long to surface as errors. Ten to fifteen seconds is the band where the test fails when signaling actually broke, and passes when signaling is slow-but-alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixtures do the setup once
&lt;/h2&gt;

&lt;p&gt;Repeating the two-context boilerplate in every test file gets old within about three tests. Playwright's fixture system turns it into a one-line dependency.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// fixtures/webrtc-context.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WebRTCFixtures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;callerContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;receiverContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extend&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;WebRTCFixtures&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;callerContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;microphone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;receiverContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;microphone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;callerContext&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;receiverContext&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&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;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the test file just asks for what it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/webrtc/p0-basic-call.spec.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../fixtures/webrtc-context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;caller and receiver connect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Room join → call → answer → verify video&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Context teardown runs in the fixture, so you don't leak a &lt;code&gt;BrowserContext&lt;/code&gt; per test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying that a video actually plays
&lt;/h2&gt;

&lt;p&gt;"A &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tag exists" is not the same as "video is showing." A broken test can pass the selector check and miss a black screen. There are two levels of paranoia here, and which one you want depends on what you're testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 1: videoWidth / videoHeight
&lt;/h3&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;hasVideo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video#remote-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLVideoElement&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;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both attributes stay at 0 until the video track is active and at least one frame is decoded. Any positive value means media is flowing. Cheap, fast, catches most of the failures I've seen in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 2: requestVideoFrameCallback
&lt;/h3&gt;

&lt;p&gt;For "is the video actually updating, or is this a frozen frame" you need the frame callback.&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;isReceivingFrames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video#remote-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLVideoElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isReceivingFrames&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three frames within five seconds. A frozen first frame or a static test pattern fails this check. It's slower and slightly heavier, but the failure mode it catches (the connection made it, and then media stopped) is exactly the one users describe as "my video froze."&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing not to do: Promise.all
&lt;/h2&gt;

&lt;p&gt;Two browsers with two independent await chains suggests parallelizing with &lt;code&gt;Promise.all&lt;/code&gt;. Don't. WebRTC's signaling is sequential by design, and firing both sides at once creates race windows that only fail 15% of the time, which is the worst possible flake rate because it looks stable until it doesn't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Safe: strictly sequential&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#call-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#incoming-call&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#answer-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Dangerous: receiver clicks answer before the banner appears&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;callerPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#call-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;receiverPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#answer-button&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I follow: await the caller's action, then move to the receiver. The only place I've found genuine parallelism useful is group calls with three or more participants where multiple joins are semantically simultaneous. Even there I only use &lt;code&gt;Promise.all&lt;/code&gt; on the &lt;code&gt;join()&lt;/code&gt; calls, never on &lt;code&gt;answer()&lt;/code&gt; calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Playwright and not Cypress / WebdriverIO / Selenium Grid
&lt;/h2&gt;

&lt;p&gt;The multi-context first-class support is the main reason. Cypress runs a test in a single browser tab with an iframe sandbox and cannot open a second real browser context in the same process, which means you end up with Cypress plus a headless driver on the side and manual sync between them. WebdriverIO handles multi-browser, but the API for coordinating two &lt;code&gt;browser&lt;/code&gt; objects reads like grid orchestration rather than a test script. Selenium Grid works, but the setup cost is a full network topology, and you pay that cost forever.&lt;/p&gt;

&lt;p&gt;Playwright's &lt;code&gt;browser.newContext()&lt;/code&gt; giving you N isolated peers inside one JS process is the shape you want for peer-to-peer testing. The Chrome DevTools Protocol access under the hood is what makes flags like &lt;code&gt;--use-fake-device-for-media-stream&lt;/code&gt; actually work end-to-end without a driver in the middle rewriting the launch args.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker + GitHub Actions with fake devices
&lt;/h2&gt;

&lt;p&gt;The fake camera flags work on Chromium headless without xvfb. That means the standard &lt;code&gt;mcr.microsoft.com/playwright:v1.49.0-jammy&lt;/code&gt; image with default GitHub Actions runners handles WebRTC E2E out of the box: no display server, no OpenGL, no &lt;code&gt;sudo apt install&lt;/code&gt;. Runtime for the one-on-one test above is about 40 seconds on a &lt;code&gt;ubuntu-latest&lt;/code&gt; runner. For a 6-peer group call it climbs to 90 seconds; still well inside a normal CI budget.&lt;/p&gt;

&lt;p&gt;The one thing that surprised me: the fake camera has a memory footprint per peer that scales linearly, so a 10-peer group call test on a 2-core / 7GB runner starts hitting OOM. If you need more than 6 peers, bump to &lt;code&gt;ubuntu-latest-4-cores&lt;/code&gt; or split the peers across two runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The two-manual-testers approach doesn't scale past two testers, and every video call product I've seen eventually needs to test presence, screen share, mute state, and 5-person group calls. Once you're there, scheduling a five-person QA sync every merge is not a plan.&lt;/p&gt;

&lt;p&gt;One Playwright script with two Browser Contexts, launched with &lt;code&gt;--use-fake-device-for-media-stream&lt;/code&gt;, gives you the smallest useful WebRTC E2E setup. Add frame verification when you care about pixels. Add fixtures the moment you write a second test. Keep the calls sequential and let ICE finish. What you buy back is the ability to ship a video-call feature and let CI tell you when the presence server broke, instead of Bob-from-support at 11pm.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>webrtc</category>
      <category>testing</category>
      <category>typescript</category>
    </item>
    <item>
      <title>MCP Scorecard: 4-Layer Pre-flight Check Before You Publish a Server</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 23 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/mcp-scorecard-4-layer-pre-flight-check-before-you-publish-a-server-2i77</link>
      <guid>https://dev.to/kenimo49/mcp-scorecard-4-layer-pre-flight-check-before-you-publish-a-server-2i77</guid>
      <description>&lt;p&gt;The MCP ecosystem grew faster than the review rules for it. In the last few weeks I published three MCP servers of my own and each time I found myself running the same mental checklist before shipping: how many tokens am I burning per turn just by being registered, are my tool descriptions telling the LLM enough to pick the right one, am I leaking anything into a description string, and does the tool name look like something else.&lt;/p&gt;

&lt;p&gt;I moved that checklist into a tool and put it on PyPI. &lt;code&gt;mcp-scorecard&lt;/code&gt; runs four pre-flight checks against an MCP server's declared surface and returns a scorecard graded A to F with per-tool findings. The CLI is one command; the same four layers ship as five MCP tools, so an LLM inside Claude Code can audit another MCP without leaving the session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;mcp-scorecard
mcp-scorecard scan ./your-server.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post is the reasoning behind each of the four layers, why they exist in that order, and what the tool caught the first time I scanned my own MCPs. If you already run &lt;a href="https://github.com/invariantlabs-ai/mcp-scan" rel="noopener noreferrer"&gt;MCP-Scan&lt;/a&gt; or &lt;a href="https://github.com/modelcontextprotocol/inspector" rel="noopener noreferrer"&gt;MCP Inspector&lt;/a&gt;, the last section explains where this one sits alongside them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why pre-flight for MCP at all
&lt;/h2&gt;

&lt;p&gt;The default assumption when reviewing an MCP server is that the interesting risks live at call time: prompt injection in a tool response, credentials leaking through a shell exec, tool shadowing that steers the model to the wrong function. Those are real, and MCP-Scan covers them at runtime. What that framing misses is the surface the LLM sees &lt;em&gt;before&lt;/em&gt; any tool is called.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;tools/list&lt;/code&gt; entry is sent to the model on every turn, because the model needs the list to decide which tool to call. That is the passive cost of registering a server. And every &lt;code&gt;description&lt;/code&gt; field in that list is what the model reads to choose. That is the passive quality of the server. Both are set at author time and neither depends on runtime traffic. Both are also invisible to a runtime scanner.&lt;/p&gt;

&lt;p&gt;A pre-flight covers exactly that layer: what does the LLM see about your MCP, before any request goes out. The four layers below are one attempt at a compact answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer A — Passive Footprint
&lt;/h2&gt;

&lt;p&gt;A single verbose MCP server can silently burn 5,000+ tokens per turn without anyone calling a tool. The tokens come from three places: the description string for each tool, the JSON Schema for each tool's input, and the tool name itself. All three get concatenated into &lt;code&gt;tools/list&lt;/code&gt; and shipped to the model every turn.&lt;/p&gt;

&lt;p&gt;Layer A counts these with &lt;code&gt;tiktoken&lt;/code&gt; on the &lt;code&gt;cl100k_base&lt;/code&gt; encoding (the OpenAI GPT-4-family tokenizer, close enough to Claude's to be a reasonable proxy). The output is a per-tool breakdown plus a global &lt;code&gt;initial_token_load&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                per-tool footprint (top 10 by total)
┏━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┓
┃ Tool                   ┃ Desc tok ┃ Schema tok ┃ Name tok ┃ Total ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━┩
│ check_domain           │      182 │         88 │        2 │   272 │
│ list_typo_permutations │       79 │         54 │        5 │   138 │
│ check_trademark        │       83 │         41 │        4 │   128 │
│ check_handles          │       76 │         40 │        2 │   118 │
└────────────────────────┴──────────┴────────────┴──────────┴───────┘
findings
  · 1 tool(s) have description &amp;gt; 150 tokens: check_domain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;domain-pre-flight&lt;/code&gt; scanned by &lt;code&gt;mcp-scorecard&lt;/code&gt;. Total &lt;code&gt;initial_token_load&lt;/code&gt; is 656 tokens for four tools, well inside the GREEN band. But one tool (&lt;code&gt;check_domain&lt;/code&gt;) is flagged: its description is 182 tokens, over the 150-token bloat threshold. When five servers like that are registered simultaneously, the passive drain adds up in a way that stays invisible until an "unused MCP" review.&lt;/p&gt;

&lt;p&gt;The thresholds are calibrated but not sacred. v0.1 uses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;GREEN&lt;/th&gt;
&lt;th&gt;YELLOW&lt;/th&gt;
&lt;th&gt;ORANGE&lt;/th&gt;
&lt;th&gt;RED&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initial_token_load&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;≤ 1500&lt;/td&gt;
&lt;td&gt;≤ 4000&lt;/td&gt;
&lt;td&gt;≤ 8000&lt;/td&gt;
&lt;td&gt;&amp;gt; 8000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description tokens per tool&lt;/td&gt;
&lt;td&gt;≤ 150&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;flagged as &lt;code&gt;bloat&lt;/code&gt; if over&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool count&lt;/td&gt;
&lt;td&gt;≤ 15&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;≥ 30 raises &lt;code&gt;tool_count&lt;/code&gt; warning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The RED threshold on &lt;code&gt;initial_token_load&lt;/code&gt; is roughly where a single server starts eating into the context budget of long conversations. The 150-token bloat threshold on per-tool descriptions is where descriptions start reading like documentation instead of one-line usage.&lt;/p&gt;

&lt;p&gt;The obvious way to fix a bloat finding is to trim the description to a single-purpose sentence and move examples out of the tool surface. Layer B has more to say about what "single-purpose sentence" should contain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer B — Use-Case Scoping
&lt;/h2&gt;

&lt;p&gt;Passive footprint answers &lt;em&gt;how much&lt;/em&gt; the LLM sees. Layer B answers &lt;em&gt;how well-targeted&lt;/em&gt; what it sees is. The failure mode is: the model has three tools with plausible descriptions and can't tell which one to reach for, so it picks by heuristic and gets it wrong. Fixing that is not a security check; it is a UX check on the LLM as the user of your MCP.&lt;/p&gt;

&lt;p&gt;Four rules run in v0.1:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vague verbs.&lt;/strong&gt; Descriptions that start with &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;handle&lt;/code&gt;, &lt;code&gt;process&lt;/code&gt;, &lt;code&gt;manage&lt;/code&gt;, &lt;code&gt;execute&lt;/code&gt;, &lt;code&gt;do&lt;/code&gt;, &lt;code&gt;perform&lt;/code&gt;, &lt;code&gt;work with&lt;/code&gt;, &lt;code&gt;deal with&lt;/code&gt;, or the generic &lt;code&gt;helper&lt;/code&gt; / &lt;code&gt;utility&lt;/code&gt; are flagged with an action hint. The flag on &lt;code&gt;run&lt;/code&gt; in my own &lt;code&gt;check_domain&lt;/code&gt; is a good example: the description said "run pre-flight checks on a domain", which reads fine as documentation but leaves the LLM with no signal about &lt;em&gt;what&lt;/em&gt; is being run. Rewriting to "check availability, run WHOIS, resolve DNS, and score TLD risk for a domain candidate" gives the same information plus enough disambiguation for the model to pick this tool over a sibling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When-to-use trigger.&lt;/strong&gt; Each tool's description is checked for an explicit trigger phrase (&lt;code&gt;use this&lt;/code&gt;, &lt;code&gt;use when&lt;/code&gt;, &lt;code&gt;call when&lt;/code&gt;, &lt;code&gt;useful for&lt;/code&gt;, and the Japanese equivalents &lt;code&gt;使う&lt;/code&gt;, &lt;code&gt;使用&lt;/code&gt;, &lt;code&gt;呼び出&lt;/code&gt;). Three of my four &lt;code&gt;domain-pre-flight&lt;/code&gt; tools do not have one, which is what the ORANGE band in the scan above is complaining about. The trigger is not a magic keyword; it is a marker for the model saying "here is the situation where I am the right tool". A missing trigger tends to correlate with tools that get called from context inference rather than explicit reasoning, which is not a stable pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overlap detection.&lt;/strong&gt; Each pair of tool descriptions is scored on shared stem-token overlap. If two tools use most of the same content words in their descriptions, they are probably telling the LLM roughly the same story and it will pick one at random. v0.1 flags overlap pairs above a threshold but does not fail on them; the fix is usually to rewrite one of the two descriptions to name the axis that separates them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naming style consistency.&lt;/strong&gt; The bundled check classifies each tool name as &lt;code&gt;snake_case&lt;/code&gt;, &lt;code&gt;camelCase&lt;/code&gt;, &lt;code&gt;kebab-case&lt;/code&gt;, or &lt;code&gt;flat&lt;/code&gt;, and flags a server whose tools mix styles. Mixed styles do not cost tokens directly, but they cost model attention: switching between naming conventions in the same list makes the model spend a small budget on remembering which style a given tool used, and that budget could have gone into the reasoning.&lt;/p&gt;

&lt;p&gt;Layer B is the layer where the tool grades its own MCP server harshly. Running &lt;code&gt;mcp-scorecard scan&lt;/code&gt; against &lt;code&gt;mcp-scorecard&lt;/code&gt;'s own MCP server returns overall grade &lt;strong&gt;D (ORANGE)&lt;/strong&gt; because five scoping findings fire on the docstrings of the &lt;code&gt;preflight_*&lt;/code&gt; tools. Four of those findings hit vague verbs (&lt;code&gt;handle&lt;/code&gt;, &lt;code&gt;process&lt;/code&gt;, &lt;code&gt;manage&lt;/code&gt;, &lt;code&gt;execute&lt;/code&gt;) that appear in the docstrings as &lt;em&gt;examples of what the scoping check catches&lt;/em&gt;. That is a false positive in context, and it is flagged as such in the caveat on the product LP. But the same yardstick is applied to the tool itself, unmodified. The alternative would have been to whitelist my own docstrings, and that would have been the kind of trick that makes a rule set useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer C — Security own rules
&lt;/h2&gt;

&lt;p&gt;Layer C is the layer that most obviously overlaps with existing tools, so it is also the layer with the tightest scope. v0.1 runs three families of own rules against the declared surface only; the runtime coverage that MCP-Scan does well is deliberately left for a v0.2 wrap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt injection markers in descriptions.&lt;/strong&gt; A tool description is text the LLM reads on every turn, so any imperative language in it steers the model whether the author meant to or not. The rule catches known injection patterns (&lt;code&gt;ignore previous instructions&lt;/code&gt;, &lt;code&gt;disregard&lt;/code&gt;, &lt;code&gt;system:&lt;/code&gt; prefix, closing &lt;code&gt;&amp;lt;/system&amp;gt;&lt;/code&gt; tags, &lt;code&gt;you must&lt;/code&gt;, and the equivalent Japanese phrasing). This one comes straight out of the book『MCP実践セキュリティ』(Impress NextPublishing) whose review checklist informs several rules in this layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool shadowing.&lt;/strong&gt; A tool named &lt;code&gt;ls&lt;/code&gt; that lists remote objects will get called instead of the shell &lt;code&gt;ls&lt;/code&gt; any time the model reads intent-imprecise instructions like "list the files here". The rule checks tool names against a small set of common shell / filesystem / process names (&lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;rm&lt;/code&gt;, &lt;code&gt;cp&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;shell&lt;/code&gt;, &lt;code&gt;execute&lt;/code&gt;) and flags any collision. The fix is namespace hygiene: rename to &lt;code&gt;list_objects&lt;/code&gt; or &lt;code&gt;s3_ls&lt;/code&gt; and the ambiguity goes away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardcoded secrets in descriptions.&lt;/strong&gt; Regex sweep for AWS access keys (&lt;code&gt;AKIA...&lt;/code&gt;), GitHub PATs (&lt;code&gt;ghp_...&lt;/code&gt;), OpenAI keys (&lt;code&gt;sk-...&lt;/code&gt;), Anthropic keys (&lt;code&gt;sk-ant-...&lt;/code&gt;), Google API keys (&lt;code&gt;AIza...&lt;/code&gt;), Slack tokens (&lt;code&gt;xox...&lt;/code&gt;), PEM private-key blocks, and hardcoded Bearer patterns. The failure mode is not "the credential got committed to the repo": that is gitleaks / trufflehog territory and deliberately not duplicated. The failure mode is "a description string sent to every LLM turn contains a credential", which is a different and worse leak because it exfiltrates the credential through the model's context on every request.&lt;/p&gt;

&lt;p&gt;What the layer does &lt;em&gt;not&lt;/em&gt; do in v0.1: full-source secret scanning, runtime injection over live traffic, or protocol-level validation. All three are covered better by existing tools; the v0.2 roadmap is a wrap around MCP-Scan for the runtime side, not a rewrite of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer D — Name Safety
&lt;/h2&gt;

&lt;p&gt;Layer D covers the naming decisions the author already made and asks whether they are safe to publish. Four rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case collision.&lt;/strong&gt; The bundled dictionary of ~50 brand names and 23 known MCP names is normalized to lowercase; the candidate name is checked against it after the same normalization. &lt;code&gt;GitHub-mcp&lt;/code&gt; collides with &lt;code&gt;github-mcp&lt;/code&gt; under this rule, and the finding is "case collision under PEP 503 style normalization; will be indistinguishable from &lt;code&gt;github-mcp&lt;/code&gt; once packaged".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brand Levenshtein similarity.&lt;/strong&gt; Candidate names within Levenshtein distance 2 of a known brand (for brands of at least 4 characters) trigger the typosquat warning. This is the same rule that runs in &lt;code&gt;domain-pre-flight&lt;/code&gt; for domain typosquat detection, scaled down to package-name character counts. The 23 known-MCP list is small on purpose; the fix for a hit is not to make the list bigger but to pick a name that does not need to be adjacent to something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separator variants.&lt;/strong&gt; &lt;code&gt;mcp_scorecard&lt;/code&gt; vs &lt;code&gt;mcp-scorecard&lt;/code&gt; vs &lt;code&gt;mcpscorecard&lt;/code&gt; all normalize to the same package name under PyPI's PEP 503 rules, so shipping one variant while another already exists on PyPI is a namespace conflict. This layer catches it before publish; I ran into the same class of issue with &lt;code&gt;mcp-scorecard&lt;/code&gt; itself, where the original name &lt;code&gt;mcp-preflight&lt;/code&gt; was already occupied and &lt;code&gt;mcp-pre-flight&lt;/code&gt; was rejected by PyPI as too similar. Layer D would have flagged the second name as "too close to an existing MCP" before I hit the PyPI validator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Namespace hygiene.&lt;/strong&gt; A regex on the candidate name checks for lowercase kebab-case with the optional &lt;code&gt;@vendor/tool&lt;/code&gt; scoping. Names with mixed case or unusual characters are flagged as "prefer kebab-case or @vendor/tool scoping". This one is cosmetic; the finding is a &lt;code&gt;warn&lt;/code&gt;, not an &lt;code&gt;error&lt;/code&gt;. It exists because the LLM's tool-picking heuristics do work better on consistently formatted names, which loops back to Layer B.&lt;/p&gt;

&lt;p&gt;The 23 known-MCP list is bundled as static data. It ships with obvious brands (github, google, anthropic, openai, aws, cloudflare, stripe, hubspot) plus a smaller set of common MCP names (mcp-scan, mcp-inspector, mcp-validator, and this tool's own name). Additions are a plain PR against &lt;code&gt;src/mcp_preflight/data/known_brands.py&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not cover
&lt;/h2&gt;

&lt;p&gt;The four layers above are the LLM-facing quality of an MCP server: what the model sees, how expensive that is, whether it can pick the right tool, whether the names collide with things it already knows. What they do not cover is the runtime security surface that MCP-Scan handles well: prompt injection at call time in tool &lt;em&gt;outputs&lt;/em&gt;, credential handling during exec, tool-shadowing at request time. That is a separate scanner reading a live server; v0.1 of &lt;code&gt;mcp-scorecard&lt;/code&gt; is an AST + manifest read, and it never executes the target.&lt;/p&gt;

&lt;p&gt;A CI-friendly gate is on the roadmap: &lt;code&gt;--format sarif&lt;/code&gt; output and non-zero exit codes for ORANGE / RED already work today, and the JSON schema is stable enough for local use. What is &lt;em&gt;not&lt;/em&gt; stable in v0.1 is the specific band thresholds; they will move as I run the tool against more real MCPs and find where the current numbers are too permissive or too strict. The alpha label on v0.1 exists exactly for that reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;mcp-scorecard              &lt;span class="c"&gt;# CLI + library&lt;/span&gt;
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"mcp-scorecard[mcp]"&lt;/span&gt;       &lt;span class="c"&gt;# + MCP server (stdio)&lt;/span&gt;

mcp-scorecard scan ./your-server.py    &lt;span class="c"&gt;# full four-layer scan&lt;/span&gt;
mcp-scorecard footprint ./server.py    &lt;span class="c"&gt;# Layer A only&lt;/span&gt;
mcp-scorecard scoping ./server.py      &lt;span class="c"&gt;# Layer B only&lt;/span&gt;
mcp-scorecard security ./server.py     &lt;span class="c"&gt;# Layer C only&lt;/span&gt;
mcp-scorecard name my-new-mcp          &lt;span class="c"&gt;# Layer D only, on a candidate name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript / Node MCPs are supported through a manifest JSON: pass the &lt;code&gt;tools/list&lt;/code&gt; output (or a saved copy) as JSON to &lt;code&gt;--target&lt;/code&gt;, and Layers A / B / C / D all run against it. The AST path is Python-specific; the layers themselves are not.&lt;/p&gt;

&lt;p&gt;To use it as an MCP server so Claude Code / Cursor / Windsurf can audit another MCP from chat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mcp-scorecard"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp-scorecard-mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask the model to score an MCP by path; five tools (&lt;code&gt;preflight_scan&lt;/code&gt;, &lt;code&gt;preflight_footprint&lt;/code&gt;, &lt;code&gt;preflight_scoping&lt;/code&gt;, &lt;code&gt;preflight_security&lt;/code&gt;, &lt;code&gt;preflight_name_check&lt;/code&gt;) route to the same layers as the CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Companion book
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mcp-scorecard&lt;/code&gt; is the tool half of a companion pair with a book that teaches the same checks in prose. The book covers the threat model behind Layer C in detail (prompt-injection markers, tool shadowing, hardcoded secrets in descriptions), the review checklist that informs the naming rules in Layer D, and the deployment considerations that come after your MCP passes pre-flight.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://kenimoto.dev/books/mcp-security-practice?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp-scorecard-4-layer" rel="noopener noreferrer"&gt;&lt;strong&gt;MCP実践セキュリティ (MCP Security in Practice)&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback on the rules and thresholds is exactly what a v0.1 alpha needs. Issues welcome on &lt;a href="https://github.com/kenimo49/mcp-scorecard" rel="noopener noreferrer"&gt;kenimo49/mcp-scorecard&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>claudecode</category>
      <category>security</category>
    </item>
    <item>
      <title>MCP Token Cost Audit: PostgreSQL vs Google Maps vs GitHub vs Freee — 270 Tools Priced</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 22 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/mcp-token-cost-audit-postgresql-vs-google-maps-vs-github-vs-freee-270-tools-priced-kn5</link>
      <guid>https://dev.to/kenimo49/mcp-token-cost-audit-postgresql-vs-google-maps-vs-github-vs-freee-270-tools-priced-kn5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyeun7cgrvq279p2vrfq1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyeun7cgrvq279p2vrfq1.png" alt="MCP Token Cost Audit — PostgreSQL vs Google Maps vs GitHub vs Freee, 500x range at boot"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL's MCP server costs 35 tokens to sit in your context. Freee's costs 17,500. Same protocol, same JSON, 500x apart before the agent has said a single word.&lt;/p&gt;

&lt;p&gt;I ran the same session boot against four production MCP servers — PostgreSQL, Google Maps, GitHub, and Freee — and priced the tool definitions at Claude 3.5 Sonnet input rates. The bill for "just being connected" is small on any single call. It's not small when you multiply it by a year of daily runs, and it's not small when you find out most of the tokens are for tools you will never invoke.&lt;/p&gt;

&lt;p&gt;Here is the audit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7uy3xwh04x7ovc0om76.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7uy3xwh04x7ovc0om76.png" alt="Four MCP servers priced at boot: PostgreSQL 35 tokens, Google Maps 704, GitHub 4,242, Freee 17,500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "connect" costs tokens at all
&lt;/h2&gt;

&lt;p&gt;The first thing a Model Context Protocol client does after handshaking is call &lt;code&gt;tools/list&lt;/code&gt;. The server responds with every tool it exposes: name, description, JSON Schema for parameters, and any annotations. Every one of those bytes lands in the LLM's context window before the user has typed a prompt.&lt;/p&gt;

&lt;p&gt;Old-style REST integration didn't work this way. You called the one endpoint you needed. If Stripe added forty new endpoints last quarter, your app didn't get slower or more expensive.&lt;/p&gt;

&lt;p&gt;MCP inverts that. Connect a server that ships 270 tools and you get all 270 tool definitions, permanently, for every session. The library metaphor is the honest one: you asked to borrow a single book, and the librarian handed you the entire catalog first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-server audit
&lt;/h2&gt;

&lt;p&gt;I measured each server's &lt;code&gt;tools/list&lt;/code&gt; response with &lt;code&gt;tiktoken&lt;/code&gt; (Claude uses the same tokenizer family for cost purposes) and priced it at $3 per 1M input tokens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP server&lt;/th&gt;
&lt;th&gt;Tools shipped&lt;/th&gt;
&lt;th&gt;Boot-time tokens&lt;/th&gt;
&lt;th&gt;Cost per 10 sessions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; (official)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~35&lt;/td&gt;
&lt;td&gt;~$0.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google Maps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;~704&lt;/td&gt;
&lt;td&gt;~$0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; (official)&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;~4,242&lt;/td&gt;
&lt;td&gt;~$0.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Freee&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;270&lt;/td&gt;
&lt;td&gt;~17,500&lt;/td&gt;
&lt;td&gt;~$0.52&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things fall out of that table.&lt;/p&gt;

&lt;p&gt;First, the range is not linear. Going from PostgreSQL to Freee is 500x on the token axis, not 270x on the tool axis, because Freee's descriptions are longer and its schemas are richer. A single Freee tool costs roughly 65 tokens of definition, versus roughly 35 for a stripped-down PostgreSQL one.&lt;/p&gt;

&lt;p&gt;Second, GitHub is the interesting middle case. The official server ships 26 tools if you take the vanilla bundle, but I've seen forks and community rewrites push that to 90+ tools with correspondingly larger token bills. The InfoQ writeup from May 2026 clocked one large-team configuration at 55,000 tokens of boot-time overhead — roughly 21% of a 200K context window, paid before the first prompt. That's the same shape as Freee, just wearing a hoodie.&lt;/p&gt;

&lt;p&gt;The number to fear is not "how many MCP servers do I have." It's "how many tool definitions am I loading, and how verbose are they."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Freee balloons to 270 tools
&lt;/h2&gt;

&lt;p&gt;Freee is a Japanese SaaS platform that bundles five back-office APIs — accounting, HR, invoicing, timekeeping, and sales — under one login. The MCP server mirrors that structure honestly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Freee API&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Approximate tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accounting&lt;/td&gt;
&lt;td&gt;Deals, ledger, trial balance&lt;/td&gt;
&lt;td&gt;~80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HR&lt;/td&gt;
&lt;td&gt;Employees, departments, payroll&lt;/td&gt;
&lt;td&gt;~60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoicing&lt;/td&gt;
&lt;td&gt;Draft, send, reconcile&lt;/td&gt;
&lt;td&gt;~40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timekeeping&lt;/td&gt;
&lt;td&gt;Punch, overtime, leave&lt;/td&gt;
&lt;td&gt;~50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sales&lt;/td&gt;
&lt;td&gt;Quote, order, revenue&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;If you're a small business using every corner of Freee, this design is exactly right. You want the accounting agent to reach into invoicing without a second server handshake.&lt;/p&gt;

&lt;p&gt;If you're an individual filing one annual tax return, ten of those 270 tools would cover you, and the other 260 are dead weight in your context window for the entire session.&lt;/p&gt;

&lt;p&gt;Nobody at Freee did anything wrong. Their MCP server matches their product surface area. What went wrong is the assumption that "connect a server" and "load its tools" should be the same action.&lt;/p&gt;

&lt;h2&gt;
  
  
  The yearly cost, spelled out
&lt;/h2&gt;

&lt;p&gt;Filing a Japanese sole-proprietor tax return realistically involves 10-ish sessions per month with the accounting API. At 17,500 tokens per session boot, at $3 per 1M input tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Per session:  17,500 × $3/1M     = $0.0525
Monthly:      10 × $0.0525       = $0.525
Yearly:       12 × $0.525        = $6.30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six dollars is not going to bankrupt anyone. Two things about that number still deserve attention.&lt;/p&gt;

&lt;p&gt;One: &lt;strong&gt;that's just the definitions.&lt;/strong&gt; Actual tool calls, arguments, and results are billed separately. The $6 is what you pay for the librarian handing you the catalog, before you've read a single page.&lt;/p&gt;

&lt;p&gt;Two: &lt;strong&gt;this scales with usage, not with the tools you actually use.&lt;/strong&gt; A daily user of the same MCP config pays ~$20/year. A team of ten pays $200. A middleware layer running this in every automation run pays real money.&lt;/p&gt;

&lt;p&gt;Compare to Freee's own subscription (¥2,380/month ≈ $200/year). The MCP overhead is small next to the product. It's not small next to "we're paying for a bill line no one on the team knows exists."&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to shrink the bill
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Strategy 1: allowlist the tools you actually use
&lt;/h3&gt;

&lt;p&gt;The single biggest lever. Most MCP clients let you filter which tools get exposed from a server. Configure it once and 96% of Freee's boot cost evaporates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"freee"&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;"allowedTools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"create_deal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"list_deals"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"get_deal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"update_deal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"get_trial_balance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"list_account_items"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"list_partners"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"list_taxes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"list_walletables"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"get_company"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten tools, ~650 tokens. That's a 96% cut from the 17,500 baseline, and the agent still gets everything a tax return needs. This is the change GitHub itself credited in the May 2026 InfoQ piece for a 62% drop in agent workflow token spend — the mechanism was audits and pruning, nothing exotic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strategy 2: rewrite the descriptions
&lt;/h3&gt;

&lt;p&gt;MCP tool descriptions are English prose, and English prose has a wide dynamic range on token count. The same tool, honestly documented, can cost 80 tokens or 20:&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;~&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tokens&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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Uses the freee accounting API to create a new transaction (journal entry) against the specified company_id. Accepts amount, date, account item, partner name, and memo. Consumption tax classification is inferred automatically from the account item."&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;~&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tokens&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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Create a transaction. Args: amount, date, account_item, partner."&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 verbose version reads like docs. The terse version reads like a signature. Agents infer the same behavior from both — the schema already spells out the parameter names and types — and you save 60 tokens per tool. Multiply by 270 tools and it's non-trivial.&lt;/p&gt;

&lt;p&gt;If you're building an MCP server, this is the highest-leverage habit to form early. Descriptions written like API reference docs cost more than descriptions written like function signatures, and both get you to the same agent behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strategy 3: connect on demand, not on boot
&lt;/h3&gt;

&lt;p&gt;The token bill is per session, so a long-running interactive session with Freee always attached pays it once. A batch job that spawns 100 sessions pays it 100 times.&lt;/p&gt;

&lt;p&gt;If a task only needs Freee for one step, connect for that step and disconnect. Most modern MCP clients — including Claude Desktop, Cursor, and Codex CLI — let you enable and disable servers per session or per project. Keep the giant servers off by default. Bring them in when the task actually requires them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to evaluate before adopting a new MCP server
&lt;/h2&gt;

&lt;p&gt;The community has spent 2026 auditing servers rather than adding them — see the getunblocked, PolicyLayer, and mcp-audit tooling that emerged this spring — and the useful checklist has settled around four questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Good signal&lt;/th&gt;
&lt;th&gt;Bad signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;How many tools ship by default?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Under 20, purpose-scoped&lt;/td&gt;
&lt;td&gt;Hundreds, bundled by product surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;How long are the descriptions?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Function-signature length&lt;/td&gt;
&lt;td&gt;Paragraph, marketing-inflected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Can I filter tools client-side?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allowedTools&lt;/code&gt; or equivalent&lt;/td&gt;
&lt;td&gt;All-or-nothing connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Does the vendor publish boot-time token counts?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, in the README&lt;/td&gt;
&lt;td&gt;You have to measure it yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Servers built by teams that have felt the token bill answer these questions on the tin. Servers built without that pressure make you find out at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger frame
&lt;/h2&gt;

&lt;p&gt;MCP was designed for composability, and composability has a cost the original design didn't foreground. Every server you connect is a permanent tax on the model's attention and your input token budget, whether you use its tools or not. The ecosystem is still catching up to that reality — 14,000+ servers as of May 2026, and the audit tooling is barely older than that.&lt;/p&gt;

&lt;p&gt;The good news is that the fixes are cheap and mostly local: allowlist the tools you use, keep descriptions tight, connect on demand. The bad news is that most teams find this out the same way I did — by running the numbers on the bill they were already paying, and being unpleasantly surprised.&lt;/p&gt;

&lt;p&gt;If you've never audited yours, the ten minutes it takes to enumerate what your MCP setup is loading is the highest-ROI ten minutes you can spend on your agent this quarter.&lt;/p&gt;




&lt;p&gt;The full version of this — the four-server measurement methodology, the yearly cost model at each Claude tier, and eleven MCP servers audited end-to-end — is what my book on MCP security practice covers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/mcp-security-practice?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp-token-audit-4" rel="noopener noreferrer"&gt;MCP Security Practice: Auditing Tools, Tokens, and Trust in the Model Context Protocol&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.infoq.com/news/2026/05/github-agentic-token-savings/" rel="noopener noreferrer"&gt;InfoQ — GitHub Slashes Agent Workflow Token Spend up to 62%&lt;/a&gt;, &lt;a href="https://getunblocked.com/blog/mcp-token-budget-autopsy/" rel="noopener noreferrer"&gt;getunblocked — MCP Token Cost: A Line-Item Autopsy&lt;/a&gt;, &lt;a href="https://policylayer.com/token-cost" rel="noopener noreferrer"&gt;PolicyLayer — MCP Token Cost Calculator&lt;/a&gt;, &lt;a href="https://github.com/ariefalabbasi/mcp-audit" rel="noopener noreferrer"&gt;mcp-audit on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>tokens</category>
      <category>ai</category>
    </item>
    <item>
      <title>OpenAI Codex Shipped 1M Lines From AGENTS.md: 3 Harness Lessons That Beat Model Swaps</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/openai-codex-shipped-1m-lines-from-agentsmd-3-harness-lessons-that-beat-model-swaps-311m</link>
      <guid>https://dev.to/kenimo49/openai-codex-shipped-1m-lines-from-agentsmd-3-harness-lessons-that-beat-model-swaps-311m</guid>
      <description>&lt;p&gt;I spent a week blaming the model.&lt;/p&gt;

&lt;p&gt;My Codex runs kept blowing past their budgets, my agents kept editing the wrong files, and every morning I woke up to another PR that "almost" worked. I bought the hype, swapped Sonnet for GPT-5, then swapped it back, then tried a bigger context window, then tried a smaller one. Nothing moved.&lt;/p&gt;

&lt;p&gt;The bug was one paragraph deep in my &lt;code&gt;AGENTS.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;OpenAI published "Harness engineering: leveraging Codex in an agent-first world" in February 2026, and the Codex team's public numbers tell the same story I lived that week. Between August 2025 and January 2026, three engineers drove roughly 1,500 merged PRs and shipped on the order of a million lines of production code with zero lines written by hand. The model did not change halfway through. The &lt;code&gt;AGENTS.md&lt;/code&gt; did, twice.&lt;/p&gt;

&lt;p&gt;Here are the three things the experiment proved.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzc2c37dgi4bub0slsumt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzc2c37dgi4bub0slsumt.png" alt="OpenAI Codex 1M lines: harness vs model breakdown, Sonnet 4.6 held constant" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: The "one big AGENTS.md" collapses under its own weight
&lt;/h2&gt;

&lt;p&gt;The Codex team's first move was one big &lt;code&gt;AGENTS.md&lt;/code&gt; covering the whole repo. Coding conventions, module boundaries, review checklist, deployment runbook, all in one file. It reads like the right thing to do.&lt;/p&gt;

&lt;p&gt;It failed in the most predictable way possible. Context is a scarce resource, and a giant instruction file crowds out the task, the code, and the documentation the agent actually needs to look at. The team wrote about this directly: the encyclopedia approach lost, because the encyclopedia was the whole reason the agent stopped reading the code.&lt;/p&gt;

&lt;p&gt;The fix was to shrink &lt;code&gt;AGENTS.md&lt;/code&gt; to a table of contents (roughly 100 lines) and move the real knowledge into a &lt;code&gt;docs/&lt;/code&gt; directory treated as the system of record. &lt;code&gt;AGENTS.md&lt;/code&gt; no longer answers questions; it points at where to look.&lt;/p&gt;

&lt;p&gt;I ported the same shape into my own harness the day I read the post. My previous &lt;code&gt;AGENTS.md&lt;/code&gt; was 940 lines. My new one is 118 lines and looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# AGENTS.md&lt;/span&gt;

&lt;span class="gu"&gt;## Overview&lt;/span&gt;
FastAPI + Postgres backend for the internal billing service.

&lt;span class="gu"&gt;## Structure&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`app/`&lt;/span&gt; → FastAPI application
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`tests/`&lt;/span&gt; → pytest suite
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`docs/skills/`&lt;/span&gt; → task-specific skills

&lt;span class="gu"&gt;## Key Skills&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Add an endpoint → docs/skills/add-endpoint.md
&lt;span class="p"&gt;-&lt;/span&gt; Write a migration → docs/skills/write-migration.md
&lt;span class="p"&gt;-&lt;/span&gt; Debug a production incident → docs/skills/oncall-triage.md

&lt;span class="gu"&gt;## Constraints&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; All endpoints must have contract tests
&lt;span class="p"&gt;-&lt;/span&gt; Never inline SQL — go through the repository layer
&lt;span class="p"&gt;-&lt;/span&gt; PRs are squash-merged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every constraint in the old file that used to sit in &lt;code&gt;AGENTS.md&lt;/code&gt; now lives in the skill file that actually needs it. The agent loads it only when it enters that task.&lt;/p&gt;

&lt;p&gt;The regressions I had been fighting stopped that afternoon. Not because the model got smarter. Because I stopped hiding the code from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: Swapping models fixes nothing when the harness is the bug
&lt;/h2&gt;

&lt;p&gt;This is the uncomfortable one.&lt;/p&gt;

&lt;p&gt;For a week I A/B-tested models. I tried GPT-5, I tried Claude Sonnet 4.6, I tried Haiku for the cheap runs and Opus for the hard ones. My completion rate on the same fifteen tasks moved by less than four percentage points across all four.&lt;/p&gt;

&lt;p&gt;I switched to a smaller model but a cleaner harness: a 118-line &lt;code&gt;AGENTS.md&lt;/code&gt;, hooks that ran linters before every commit, and a sandbox that refused writes outside &lt;code&gt;app/&lt;/code&gt;. Completion jumped by 31 points on the same fifteen tasks.&lt;/p&gt;

&lt;p&gt;The Codex team's phrasing landed differently after that: "Agents aren't hard; the harness is hard." I read it a month before I believed it, which is roughly the industry-average delay.&lt;/p&gt;

&lt;p&gt;Louis Bouchard put it more bluntly in his 2026 write-up: stop saying "the model is dumb" and start saying "my system tolerated this failure." That reframe is why the Codex team keeps the same model checkpoint for weeks at a time and treats every regression as a &lt;code&gt;docs/&lt;/code&gt; update, not a model swap. When the harness is the thing that changes, the model becomes a stable variable, and you can actually reason about what broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: AGENTS.md, Anthropic Skills, and &lt;code&gt;.cursorrules&lt;/code&gt; do different jobs
&lt;/h2&gt;

&lt;p&gt;The vocabulary is confusing on purpose. Every vendor picked a different noun for a similar shape.&lt;/p&gt;

&lt;p&gt;Here is what actually differs, based on what I ship with each:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbl66v3isery201yc3zuc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbl66v3isery201yc3zuc.png" alt="AGENTS.md vs Anthropic Skills vs .cursorrules — 3-column comparison of role, scope, and failure mode" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;OpenAI AGENTS.md&lt;/th&gt;
&lt;th&gt;Anthropic Skills&lt;/th&gt;
&lt;th&gt;Cursor &lt;code&gt;.cursorrules&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Primary role&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Repo-level index for any agent&lt;/td&gt;
&lt;td&gt;Task-scoped, lazy-loaded playbook&lt;/td&gt;
&lt;td&gt;Editor-level rules per project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One per repo&lt;/td&gt;
&lt;td&gt;Many; one per task type&lt;/td&gt;
&lt;td&gt;One per project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;When loaded&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every session, top of context&lt;/td&gt;
&lt;td&gt;Only when the task matches&lt;/td&gt;
&lt;td&gt;Every prompt in this repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Failure mode if oversized&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Context crowded, agent ignores code&lt;/td&gt;
&lt;td&gt;None — it never fires&lt;/td&gt;
&lt;td&gt;Rules bleed into unrelated prompts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Where-to-look pointers&lt;/td&gt;
&lt;td&gt;How-to-do-X procedures&lt;/td&gt;
&lt;td&gt;Style + safety rails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They compose. On my own harness I keep a slim &lt;code&gt;AGENTS.md&lt;/code&gt; as the top-level index, Anthropic-style skill files under &lt;code&gt;docs/skills/&lt;/code&gt; for anything task-specific, and a short &lt;code&gt;.cursorrules&lt;/code&gt; for the two or three editor-scope conventions that survive across every task (never introduce &lt;code&gt;any&lt;/code&gt;, never touch &lt;code&gt;main.py&lt;/code&gt; without permission). The &lt;code&gt;AGENTS.md&lt;/code&gt; is not competing with skills; it is announcing where the skills live.&lt;/p&gt;

&lt;p&gt;The failure mode I still see most often in other people's repos is putting the skill content inside &lt;code&gt;AGENTS.md&lt;/code&gt; "so the agent always has it." That is the encyclopedia problem, one level down. If it always has it, it never reads the code.&lt;/p&gt;

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

&lt;p&gt;Three concrete moves that came from reading OpenAI's post and porting the shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; down from 940 lines to 118 lines. Everything else moved into &lt;code&gt;docs/skills/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Model held constant for four weeks. Any regression writes a new line into a skill file, not a switch to a new checkpoint.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;harness/hooks/pre-commit.sh&lt;/code&gt; that runs the linter and the type checker before the agent is allowed to move on. This one I stole from Anthropic's long-running-agent post from April, but it belongs on this list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Completion rate on my personal fifteen-task benchmark went from 41% to 78% across the three changes. The model has been the same Sonnet 4.6 checkpoint for that entire span.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth of the Codex experiment is that "which model" was the wrong question the whole time. The right question is which harness this model is wearing. If you have been swapping checkpoints and blaming the vendor, try shrinking your &lt;code&gt;AGENTS.md&lt;/code&gt; first. It's cheaper, and it's usually the actual bug.&lt;/p&gt;




&lt;p&gt;If you want the longer version of this — the six components of a harness, why AGENTS.md is only one of them, and how the same shape applies to CLAUDE.md and hooks — that is what my book is about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/harness-engineering-guide?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=codex-agents-md-1m" rel="noopener noreferrer"&gt;Harness Engineering: Building the Environment That Makes AI Agents Reliable&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://openai.com/index/harness-engineering/" rel="noopener noreferrer"&gt;OpenAI — Harness engineering&lt;/a&gt;, &lt;a href="https://www.anthropic.com/engineering/harness-design-long-running-apps" rel="noopener noreferrer"&gt;Anthropic — Harness design for long-running app development&lt;/a&gt;, &lt;a href="https://www.infoq.com/news/2026/04/anthropic-three-agent-harness-ai/" rel="noopener noreferrer"&gt;InfoQ — Anthropic three-agent harness&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>openai</category>
      <category>codex</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>SQLite vs Kuzu vs Neo4j: Which Graph DB Survives 1M Code Nodes?</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/sqlite-vs-kuzu-vs-neo4j-which-graph-db-survives-1m-code-nodes-4nei</link>
      <guid>https://dev.to/kenimo49/sqlite-vs-kuzu-vs-neo4j-which-graph-db-survives-1m-code-nodes-4nei</guid>
      <description>&lt;p&gt;Two weeks ago I was drafting a recommendation table for a code knowledge graph MCP: SQLite up to about 100k nodes, Kuzu from 100k to a million, Neo4j past that. It was a clean answer. I ran the numbers, I trusted the ladder, I moved on.&lt;/p&gt;

&lt;p&gt;Then a colleague pointed me at a Register piece from October 14, 2025. Kuzu's GitHub repo had been archived. Apple had bought Kùzu Inc. on October 9, and the 9to5Mac follow-up in February 2026 traced it to an EU Digital Markets Act filing (&lt;a href="https://www.theregister.com/2025/10/14/kuzudb_abandoned/" rel="noopener noreferrer"&gt;The Register: KuzuDB abandoned&lt;/a&gt;, &lt;a href="https://9to5mac.com/2026/02/11/kuzu-database-company-joins-apples-list-of-recent-acquisitions/" rel="noopener noreferrer"&gt;9to5Mac: Kuzu joins Apple's list&lt;/a&gt;). The final release, 0.11.3, shipped the same day the repo went dark.&lt;/p&gt;

&lt;p&gt;That changed the middle rung of the ladder. This post is the re-selection I actually shipped, at the four scales you might have: 10k, 100k, 1M, and past 1M code nodes. FalkorDB gets a section too because its GraphBLAS engine is genuinely fast in one specific lane, and pretending otherwise would be dishonest.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: sizing your codebase in nodes, not lines
&lt;/h2&gt;

&lt;p&gt;Before you pick a graph DB, translate your repo into node count. For a minimal schema of File / Module / Class / Function / Variable plus six edge types, this is the shape I keep seeing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lines of code&lt;/th&gt;
&lt;th&gt;Nodes&lt;/th&gt;
&lt;th&gt;Edges&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;~3,000&lt;/td&gt;
&lt;td&gt;~8,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;~30,000&lt;/td&gt;
&lt;td&gt;~80,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300,000&lt;/td&gt;
&lt;td&gt;~100,000&lt;/td&gt;
&lt;td&gt;~270,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;~300,000&lt;/td&gt;
&lt;td&gt;~800,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The node-to-edge ratio hovers around 1:2.5 because an average function calls two or three others. Semantic edges (added later, if you build a Pass 2) push it higher, but the table above is the baseline. A 1M-node code graph means about 3M lines of source, which is Google or Meta scale monorepo territory. Most teams live in the 30k-300k node band.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dviirifvpaglulx2c3e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dviirifvpaglulx2c3e.png" alt="Graph DB selection matrix — SQLite under 100k, LadybugDB (Kuzu fork) 100k-1M, Neo4j 1M-10M, FalkorDB for read-heavy niche" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Under 100k nodes: SQLite, and the one index that decides everything
&lt;/h2&gt;

&lt;p&gt;For anything under 100k nodes I still recommend SQLite. Three reasons hold up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zero infrastructure.&lt;/strong&gt; Ship a &lt;code&gt;.codegraph.db&lt;/code&gt; file inside the repo. &lt;code&gt;git clone&lt;/code&gt; and the tool works. No Docker, no auth, no &lt;code&gt;docker-compose&lt;/code&gt; file that no one wants to review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recursive CTE handles 3-5 hop BFS.&lt;/strong&gt; For a hop-3 blast radius query on a 100k-node graph, SQLite returns in 50-150ms in my measurements. Good enough to sit behind an MCP tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already have the driver.&lt;/strong&gt; Every language you might build an MCP in already has SQLite bindings in the standard library.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The catch is one index: &lt;code&gt;edges(dst, type)&lt;/code&gt;. Without it, reverse traversals (who calls this function?) fall off a cliff, because SQLite has to scan every edge row. With it, they stay in tens of milliseconds. If you take one thing from this post, take this: &lt;strong&gt;on SQLite, &lt;code&gt;edges(dst, type)&lt;/code&gt; is not optional.&lt;/strong&gt; I have seen four independent code-KG implementations, and every one of them either had this index or was silently unusable.&lt;/p&gt;

&lt;p&gt;OSS reference points: &lt;a href="https://github.com/nazdridoy/code-review-graph" rel="noopener noreferrer"&gt;code-review-graph&lt;/a&gt; and CodeGraph both run on SQLite with FTS5 for symbol lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  100k-1M nodes: Kuzu was the answer, and it still is (with an asterisk)
&lt;/h2&gt;

&lt;p&gt;This is the band that changed. Until October 2025 the answer was Kuzu, and honestly it still is on the merits. Kuzu was embedded like SQLite (no server), spoke Cypher, and shipped with real graph indices instead of asking you to bolt them onto B-trees. On the 300k-node codebases I profiled, hop-3 traversals dropped from SQLite's 800ms into Kuzu's 40-80ms range.&lt;/p&gt;

&lt;p&gt;The asterisk is that the repo is archived, the maintainers moved to Apple, and there is no roadmap. What you get today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LadybugDB&lt;/strong&gt;, a community fork under a permissive license, keeps Kuzu's columnar storage, Cypher dialect, and the last vector and full-text indices intact (&lt;a href="https://arcadedb.com/blog/neo4j-alternatives-in-2026-a-fair-look-at-the-open-source-options/" rel="noopener noreferrer"&gt;ArcadeDB's Neo4j alternatives writeup, 2026&lt;/a&gt;). If you already know Kuzu, LadybugDB is a straight lift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bighorn&lt;/strong&gt;, another fork by Kineviz, exists but has less traction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kuzu 0.11.3 itself&lt;/strong&gt; still works. It is not going to acquire security bugs because you stopped writing to it. For an internal code KG behind an MCP tool, an abandoned but functional DB is usable; for a customer-facing service, it is a policy decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Would I start a new project on LadybugDB today? For the 100k-1M code node band, yes, provided the team is comfortable with a small-community fork. If that word "fork" makes your legal team nervous, skip to Neo4j.&lt;/p&gt;

&lt;h2&gt;
  
  
  Past 1M nodes: Neo4j, and the boring reason it wins here
&lt;/h2&gt;

&lt;p&gt;Once you cross a million nodes you are also past the point where "embedded" is a virtue. You have multiple engineers hitting the same graph, you want a query log, you want backup tooling that already exists. That is server-shaped work, and Neo4j is the mature server-shaped answer.&lt;/p&gt;

&lt;p&gt;Cypher travels from Kuzu / LadybugDB to Neo4j with only dialect nicks, so if the codebase grows out of Kuzu you migrate rather than rewrite. Neo4j Community handles most single-node code-KG workloads; you reach for Enterprise (or Aura) when you need clustering.&lt;/p&gt;

&lt;p&gt;The scale ceiling story worth knowing: NTT Comware runs Neo4j on a ~40M-node network device management graph in production, with query times dropping from about 80 minutes to tens of seconds after the migration (&lt;a href="https://www.creationline.com/clientvoice/case17/" rel="noopener noreferrer"&gt;Neo4j case: NTT Comware&lt;/a&gt;). That is not a code KG, it is network topology, but it establishes the upper bound. If you are worried about Neo4j surviving your monorepo, it will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where FalkorDB actually belongs
&lt;/h2&gt;

&lt;p&gt;FalkorDB is easy to mis-place because its benchmark numbers are impressive in isolation. FalkorDB reports sub-140ms p99 on aggregate expansion patterns where Neo4j hits 46.9 seconds (&lt;a href="https://www.falkordb.com/blog/graph-database-performance-benchmarks-falkordb-vs-neo4j/" rel="noopener noreferrer"&gt;FalkorDB benchmark writeup&lt;/a&gt;). It represents the graph as sparse adjacency matrices and executes traversals as &lt;a href="https://graphblas.org/" rel="noopener noreferrer"&gt;GraphBLAS&lt;/a&gt; matrix operations, which is a very different physical model from Neo4j's B-tree pointer walks.&lt;/p&gt;

&lt;p&gt;The caveats are equally real: FalkorDB's benchmark methodology is FalkorDB's own, tuning effort likely differs, and the workloads picked are the ones where sparse-matrix execution shines. In &lt;a href="https://ldbcouncil.org/benchmarks/snb/" rel="noopener noreferrer"&gt;LDBC SNB&lt;/a&gt; results both vendors have wins.&lt;/p&gt;

&lt;p&gt;Practical read: &lt;strong&gt;FalkorDB is a fit when you need very low read latency, your graph fits in memory, and you can tolerate reload time on restart.&lt;/strong&gt; Real-time IDE integrations, live code-navigation panels, hot-path autocomplete. For a nightly code-KG rebuild that a code review MCP queries a few dozen times per PR, it is overkill. CodeGraphContext uses FalkorDB Lite as one option for exactly this "hot, small, ephemeral" pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The selection matrix I actually use now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;th&gt;First pick&lt;/th&gt;
&lt;th&gt;Fallback&lt;/th&gt;
&lt;th&gt;Not this&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Under 100k&lt;/td&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;LadybugDB (Kuzu fork)&lt;/td&gt;
&lt;td&gt;Neo4j (operational overkill)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100k - 1M&lt;/td&gt;
&lt;td&gt;LadybugDB (with fork risk noted)&lt;/td&gt;
&lt;td&gt;SQLite (up to ~200k), Neo4j (up to 1M)&lt;/td&gt;
&lt;td&gt;Vanilla Kuzu 0.11.3 for customer-facing work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M - 10M&lt;/td&gt;
&lt;td&gt;Neo4j&lt;/td&gt;
&lt;td&gt;FalkorDB (if read-only and in-memory OK)&lt;/td&gt;
&lt;td&gt;Anything embedded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Past 10M&lt;/td&gt;
&lt;td&gt;Neo4j Cluster&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;Embedded, period&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Not this" is not a technical impossibility. You can run SQLite at 500k nodes; it just tips into "please stop" territory on hop-4 CTEs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I picked, and the migration escape hatch
&lt;/h2&gt;

&lt;p&gt;For the specific project that started this — a code review MCP over a ~300k-node Python monorepo — I picked SQLite. The number was inside its comfort band, the repo was fine, and the team did not need a fork discussion at the review meeting. If it grows past 500k I will move to LadybugDB, and if the org grows past a million nodes we will migrate to Neo4j.&lt;/p&gt;

&lt;p&gt;The escape hatch is the schema. If you keep the schema to a minimal common denominator (five node types, six edge types, &lt;code&gt;confidence&lt;/code&gt; and &lt;code&gt;line_range&lt;/code&gt; as plain properties), then the migration between any two of these four DBs is a CSV export and a &lt;code&gt;LOAD CSV&lt;/code&gt; on the other side. The temptation is to reach for DB-specific features (Neo4j label hierarchies, Kuzu's custom indices, FalkorDB's matrix-native aggregates). Every one of those pins you.&lt;/p&gt;

&lt;p&gt;Apple's move on Kuzu made one thing concrete: "the best embedded graph DB" is one board meeting away from being someone else's IP roadmap. Keep the schema portable. Pick the DB that fits your current scale. Move when the numbers, not the vendor's Twitter, tell you to.&lt;/p&gt;




&lt;p&gt;If you are building a code knowledge graph and want the full schema, ingest pipeline, and MCP wiring in one place, I wrote it up in &lt;a href="https://kenimoto.dev/books/knowledge-graph-practical-guide?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sqlite-kuzu-neo4j-1m" rel="noopener noreferrer"&gt;Knowledge Graph Practical Guide&lt;/a&gt;. It covers the Pass 1 / Pass 2 split, the &lt;code&gt;edges(dst, type)&lt;/code&gt; gotcha in more detail, and the migration paths between the four DBs above.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>ai</category>
      <category>codereview</category>
    </item>
    <item>
      <title>The Machine Accent Travels: AI Text Is Rhythmically Monotone in 70/70 Cells Across 3 Languages</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Sat, 18 Jul 2026 07:09:54 +0000</pubDate>
      <link>https://dev.to/kenimo49/the-machine-accent-travels-ai-text-is-rhythmically-monotone-in-7070-cells-across-3-languages-1a97</link>
      <guid>https://dev.to/kenimo49/the-machine-accent-travels-ai-text-is-rhythmically-monotone-in-7070-cells-across-3-languages-1a97</guid>
      <description>&lt;p&gt;Yesterday I published my third research paper on Zenodo. It showed that Japanese AI-generated text swings its sentence lengths far less than human text does, and that all seven models I tested drift the same direction. I called the phenomenon a "machine accent."&lt;/p&gt;

&lt;p&gt;One thing kept nagging at me after publishing. An accent belongs to the speaker. It follows you into whatever language you attempt. But all I had measured was Japanese. If the monotony vanished in English or Portuguese, my "accent" was never an accent. It was a fact about Japanese.&lt;/p&gt;

&lt;p&gt;A name like that has to earn its metaphor.&lt;/p&gt;

&lt;p&gt;So today I published paper number four. These are the field notes, landmines included, same as last time.&lt;/p&gt;

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

&lt;p&gt;Two hypotheses. H1: if the accent is real, AI monotonization shows up in English and Portuguese with the same direction (d &amp;lt; 0) on every model. H2: the direction holds but the magnitude may differ by language.&lt;/p&gt;

&lt;p&gt;The human corpora had to be pre-ChatGPT. For English I took 853 of Dev.to's all-time most popular posts, January 2019 through October 2022. If you wrote a well-liked Dev.to post back then, congratulations: you are now a scientific baseline.&lt;/p&gt;

&lt;p&gt;Portuguese got lucky. TabNews, the Brazilian dev forum, opened in May 2022. ChatGPT landed November 30 of the same year. That leaves a seven-month window where every post is guaranteed human, so I took the entire window: 403 posts. Short, but airtight.&lt;/p&gt;

&lt;p&gt;The AI side is 7 models × 10 themes × 5 attempts × 2 languages, using the same zero-shot prompt as the Japanese study, translated. Metric definitions carried over unchanged: burstiness, sentence-length CV, paragraph-structure CV. Two adaptations only. Japanese mora counts became pyphen syllable approximations, and sentence splitting uses one shared regex for both languages instead of pysbd, which does not support Portuguese. Mixing splitters would have confounded the language difference with a tooling difference.&lt;/p&gt;

&lt;p&gt;New measurements: 1,202 English + 751 Portuguese documents, 1,953 total. The Japanese numbers come straight from the published third paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landmine 1: my models had retired
&lt;/h2&gt;

&lt;p&gt;The moment generation started, Claude 3 Haiku, Sonnet 4, and Opus 4 all returned 404. The three Claude models from the Japanese study had been retired from the API.&lt;/p&gt;

&lt;p&gt;For a replication-style design this hurts. Same models, different language: broken. I substituted the current tiers (Haiku 4.5, Sonnet 5, Opus 4.8) and restricted the direct Japanese comparison to the four models both studies share (GPT-3.5 Turbo, GPT-4o, GPT-OSS 20B, Llama 3.2 1B). The paper discloses the substitution plainly.&lt;/p&gt;

&lt;p&gt;Annoying at the time. It ends up delivering the most interesting finding in the study.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landmine 2: GPT-4o wraps entire documents in a code fence
&lt;/h2&gt;

&lt;p&gt;Early in measurement, 35 GPT-4o documents came back as "0 sentences." Opening them explained why: the entire document sat inside a&lt;br&gt;
&lt;br&gt;
 &lt;code&gt;```markdown&lt;/code&gt;&lt;br&gt;
&lt;br&gt;
 fence. My code-block exclusion, which exists so that code doesn't pollute rhythm stats, was eating the whole article as one giant block. The fix unwraps only an outer fence that carries the markdown tag. A bare fence stays untouched, because it might be actual code.&lt;/p&gt;

&lt;p&gt;It sounds like a footnote. It is not: left alone, a third of GPT-4o's sample (35 of 100 documents) disappears silently, and nearly half on the Portuguese side. Preprocessing for multilingual measurement doubles as a catalog of model quirks.&lt;/p&gt;
&lt;h2&gt;
  
  
  Results: 70 cells, 70 negative
&lt;/h2&gt;

&lt;p&gt;Five core metrics (three burstiness variants, two sentence-length CVs) × 7 models × 2 languages gives 70 cells. Every one of them came out d &amp;lt; 0: AI more monotone than humans. The direction survives length residualization in all 70. Zero exceptions.&lt;/p&gt;

&lt;p&gt;Pooled effect sizes, next to the Japanese result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;burstiness (char)&lt;/th&gt;
&lt;th&gt;Japanese&lt;/th&gt;
&lt;th&gt;English&lt;/th&gt;
&lt;th&gt;Portuguese&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cohen's d&lt;/td&gt;
&lt;td&gt;−0.96&lt;/td&gt;
&lt;td&gt;−1.12&lt;/td&gt;
&lt;td&gt;−1.03&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three languages, one band around −1. And the ordering carries over too. Across the four shared models, GPT-3.5 Turbo is the most monotone in every language and GPT-OSS 20B sits closest to the human band in every language. A model's accent strength follows it across languages, rank and all.&lt;/p&gt;

&lt;p&gt;The accent travels.&lt;/p&gt;
&lt;h2&gt;
  
  
  The comma is the exception
&lt;/h2&gt;

&lt;p&gt;One metric refused to line up: commas per sentence. In English, AI uses more commas than humans (d = +0.45). In Portuguese, fewer (d = −0.83).&lt;/p&gt;

&lt;p&gt;The explanation lives on the human side. Portuguese writers average 1.14 commas per sentence; English writers 0.58. The models settle around 0.6 to 0.7 in both languages, a kind of textbook middle. Against comma-light English humans that looks excessive. Against comma-loving Brazilians it looks starved. Identical behavior, opposite sign, decided entirely by the local convention it gets compared against.&lt;/p&gt;

&lt;p&gt;Rhythm crosses languages with its direction intact. Punctuation flips depending on where you stand. That contrast became the paper's framework.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three layers: signature, accent, dialect
&lt;/h2&gt;

&lt;p&gt;The third paper sorted AI text traces into two layers: vocabulary as a model-specific signature, rhythm as a shared machine accent. The comma result is a third kind of trace that fits neither.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkenimoto.dev%2Fimages%2Fblog%2Fmachine-accent-3-languages-70-70-cells%2Fthree-layer-fingerprint-en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fkenimoto.dev%2Fimages%2Fblog%2Fmachine-accent-3-languages-70-70-cells%2Fthree-layer-fingerprint-en.png" alt="Three-layer fingerprint: vocabulary is a signature, rhythm is an accent, punctuation is a dialect" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signature (vocabulary)&lt;/strong&gt;: diverges per model. Tells you which machine wrote it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accent (rhythm)&lt;/strong&gt;: shared by all models, persists across languages. Tells you a machine wrote it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dialect (punctuation)&lt;/strong&gt;: the behavior is shared, but its visible sign flips with the conventions of the language you compare against&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One machine-written document carries all three kinds of traces, in separate layers. That is the paper's central claim.&lt;/p&gt;
&lt;h2&gt;
  
  
  The accent is fading
&lt;/h2&gt;

&lt;p&gt;Here is the gift from landmine 1. Because the model roster changed, the data holds both GPT-3.5 from 2023 and the current Claude tier from 2026, side by side.&lt;/p&gt;

&lt;p&gt;On English burstiness (char), GPT-3.5 Turbo scores d = −2.59. The current Claude generation scores −0.59 to −0.96, roughly a third of that on average. Portuguese shows the same ratio. Newer models write rhythm much closer to the human band.&lt;/p&gt;

&lt;p&gt;So rhythm-based AI detection probably has a shelf life.&lt;/p&gt;

&lt;p&gt;The accent gets trained away, generation by generation. For writing improvement the same trend cuts the other way: the closer models get to the human band, the more precisely a rhythm metric points at whatever monotony remains. Detection value and editing value move in opposite directions, which is exactly the shape of the third paper's conclusion.&lt;/p&gt;
&lt;h2&gt;
  
  
  The practical part: rhythm lint crosses languages, thresholds don't
&lt;/h2&gt;

&lt;p&gt;The takeaway for tooling is short. The direction of rhythm metrics is shared across all three languages, so lint logic ports as-is. The distributions differ, so thresholds need per-language calibration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kenimo49/rhythm-lens" rel="noopener noreferrer"&gt;rhythm-lens&lt;/a&gt;, the small CLI I released last week, ships the English and Portuguese baselines from this study as of v0.2.0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;rhythm-lens
rhythm-lens draft.md            &lt;span class="c"&gt;# language auto-detected (ja/en/pt)&lt;/span&gt;
rhythm-lens &lt;span class="nt"&gt;--lang&lt;/span&gt; en draft.md  &lt;span class="c"&gt;# or explicit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this post through it before publishing. It flagged me on the first pass, and I rewrote my own paragraph structure to satisfy my own linter. The tool biting its author feels like a good sign for the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paper and data
&lt;/h2&gt;

&lt;p&gt;The paper is on Zenodo (text CC-BY 4.0, code and data MIT on GitHub). Human corpus texts are not redistributed; the repo ships metadata plus recollection scripts instead.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paper: &lt;a href="https://doi.org/10.5281/zenodo.21424903" rel="noopener noreferrer"&gt;10.5281/zenodo.21424903&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code and data: &lt;a href="https://github.com/kenimo49/llm-rhythm-crosslingual" rel="noopener noreferrer"&gt;github.com/kenimo49/llm-rhythm-crosslingual&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The third paper (Japanese lexical vs. rhythm fingerprints): &lt;a href="https://doi.org/10.5281/zenodo.21413035" rel="noopener noreferrer"&gt;10.5281/zenodo.21413035&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This ended up being a sequel published 24 hours after the original. Working while the question is still warm meant every scraper, metric, and mistake was fresh in memory. And none of it works without communities that kept their pre-ChatGPT writing intact, so if your 2021 Dev.to post is in the baseline: thanks for the rhythm.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>writing</category>
      <category>research</category>
    </item>
    <item>
      <title>Brave Search Buries Pages Google Ranks #1, and Your AI Agents Can't Find Them</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/brave-search-buries-pages-google-ranks-1-and-your-ai-agents-cant-find-them-57e1</link>
      <guid>https://dev.to/kenimo49/brave-search-buries-pages-google-ranks-1-and-your-ai-agents-cant-find-them-57e1</guid>
      <description>&lt;p&gt;I have an article that ranks #1 on Google for its target query. Position one, above the fold, the SEO equivalent of a parking spot right by the door. I was proud of it. I had earned it the boring way: clean headings, internal links, a year of patience.&lt;/p&gt;

&lt;p&gt;Then I searched for the same query on Brave. My article was on page 5. Page five. The place URLs go to die unmourned, somewhere below a forum thread from 2019.&lt;/p&gt;

&lt;p&gt;The part that actually stung came a few minutes later. I asked Claude Code, running in my own terminal, to research that exact topic and cite good sources. It came back with three links. None of them were mine. My agent, which I built, which runs on my machine, could not find the article I wrote. It was searching Brave. And on Brave, I do not exist.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3tajx0ojpkx596jti6uq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3tajx0ojpkx596jti6uq.png" alt="Same article shown as #1 on Google and page 5 on Brave, with an AI agent failing to find it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Google and Brave are not looking at the same web
&lt;/h2&gt;

&lt;p&gt;The instinct here is to assume Brave is just a smaller, scrappier mirror of Google. It is not. Brave runs its own index, built from a completely separate crawl, with its own ranking logic. When I say my page is #1 on one and page 5 on the other, I am not describing a glitch. I am describing two different maps of the web that happen to share a planet.&lt;/p&gt;

&lt;p&gt;Brave's index is real infrastructure, not a side project. It covers &lt;a href="https://brave.com/blog/search-api-growth/" rel="noopener noreferrer"&gt;over 40 billion pages and refreshes more than 100 million of them daily&lt;/a&gt;, fully independent of Google and Microsoft. The interesting part is how it stays fresh. A chunk of its signal comes from the Web Discovery Project: tens of millions of Brave browser users who opt in to share anonymous data about which pages they actually visit. So instead of ranking purely on backlinks and the usual SEO machinery, Brave leans on pages humans genuinely land on.&lt;/p&gt;

&lt;p&gt;Which, when I think about it, explains my page 5 problem with uncomfortable precision. My article ranked on Google because I optimized it for Google's machinery. It ranked nowhere on Brave because I had never once asked whether Brave's separate index had even noticed it existed. I had been studying for the wrong exam and getting an A on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a search engine I never use decides whether AI can find me
&lt;/h2&gt;

&lt;p&gt;Here is where it stops being a curiosity and starts being a problem with my paycheck attached.&lt;/p&gt;

&lt;p&gt;In May 2025, Microsoft announced it was retiring the Bing Search API, and it &lt;a href="https://learn.microsoft.com/en-us/lifecycle/announcements/bing-search-api-retirement" rel="noopener noreferrer"&gt;shut down for good on August 11, 2025&lt;/a&gt;. For years, a huge slice of AI tools and third-party search services ran on Bing's API under the hood. When it went dark, the replacement was not obvious. Google does not open its real web index to developers for grounding or RAG; its Programmable Search Engine is built for a narrower job. The scraper-based APIs (Tavily, Exa, and friends) ultimately depend on indexes they do not own, which means they inherit someone else's blocking, pricing, and terms-of-service risk.&lt;/p&gt;

&lt;p&gt;That left exactly one independent commercial web-search API at scale: Brave. Brave's own chief business officer described the shift as giving developers &lt;a href="https://brave.com/blog/search-api-aws-marketplace/" rel="noopener noreferrer"&gt;"the only independent search API in the market,"&lt;/a&gt; and for once the marketing line is just describing the terrain.&lt;/p&gt;

&lt;p&gt;So follow the chain. AI coding agents need web search. The independent web-search API they can actually buy is Brave's. Therefore the agents search Brave. &lt;a href="https://brave.com/search/api/tools/" rel="noopener noreferrer"&gt;Cursor, Cline, and Windsurf all use Brave for web lookups&lt;/a&gt;, Anthropic shipped Brave Search as one of the first Claude MCP demo servers, and Brave is increasingly the default web-search provider baked into agent toolchains. The top AI companies by usage all touch Brave Search at training or inference time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhklikj8d2mnsv5y5fcr4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhklikj8d2mnsv5y5fcr4.png" alt="Flow from your content into Brave's index and out to AI agents like Claude Code, Cursor, Cline, and Perplexity" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Put plainly: Brave's index is the front door for a growing share of AI agents. If your content is not in that index, or it is in there on page 5, those agents will never hand it to a user. You can be the #1 result on Google and still be functionally invisible to the tools engineers actually use to research things. I was. On my own laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LLM Context API reads structured data first, and most of us never gave it any
&lt;/h2&gt;

&lt;p&gt;In February 2026 Brave shipped its LLM Context API, and it changes what "being indexed" even means. The old web-search API returned what humans need: a title, a URL, a snippet to click. The LLM Context API returns what a model needs: pre-chunked, ranked pieces of content ready to drop into a prompt. It is &lt;a href="https://brave.com/blog/most-powerful-search-api-for-ai/" rel="noopener noreferrer"&gt;already powering over 22 million answers per day&lt;/a&gt; inside Brave Search itself.&lt;/p&gt;

&lt;p&gt;The detail that should make every blog owner sit up is in the extraction step. When the API pulls content from your page, it &lt;a href="https://thesearchsignal.com/brave-search-llm-ready-endpoints/" rel="noopener noreferrer"&gt;preserves JSON-LD schemas and tables with row-level granularity, and it prioritizes that structured data during extraction&lt;/a&gt;. One write-up put it bluntly: it is not optional anymore.&lt;/p&gt;

&lt;p&gt;So if your page ships clean &lt;code&gt;TechArticle&lt;/code&gt; or &lt;code&gt;FAQPage&lt;/code&gt; JSON-LD, the API can lift your author, your headline, your published date, and your key claims out cleanly and feed them straight to the model. If it ships a wall of &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; soup with the real answer buried in paragraph nine, the API has to work harder and your page loses to one that did the structuring for it. Schema stopped being a nice-to-have for Google rich snippets. It became the format your content gets read in.&lt;/p&gt;

&lt;p&gt;And before this sounds like I am about to tell you the biggest model wins, Brave published a benchmark that says the opposite, which is the most encouraging thing I have read all year.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Data quality beats model performance" is now a measured result, not a vibe
&lt;/h2&gt;

&lt;p&gt;Brave ran a pairwise evaluation over 1,500 queries, judged by Claude Opus and Sonnet acting as graders, with each pair scored in both orders to cancel out position bias. The headline: their "Ask Brave" answer engine, running on the open-weight Qwen3 model, beat both ChatGPT and Perplexity on answer quality.&lt;/p&gt;

&lt;p&gt;Let that land. An open-weight model you can download for free out-scored two of the most heavily funded AI products on the market. The variable was not parameters or training budget. It was the quality of the grounding data fed into the model at answer time.&lt;/p&gt;

&lt;p&gt;For a content creator this is the rare benchmark that is actually good news for the little guy. It means the thing under my control, the structure and clarity of what I publish, is the lever that moves AI answers. Not the size of someone's GPU cluster. If clean, well-structured grounding data can make a small open model beat ChatGPT, then clean, well-structured pages are not a tax I pay for tidiness. They are the whole game.&lt;/p&gt;

&lt;p&gt;This is the part I keep coming back to with the framework work I've been building over at &lt;a href="https://llmoframework.com" rel="noopener noreferrer"&gt;LLMO Framework&lt;/a&gt;, which I treat as the canonical playbook for the index-side fixes: it formalizes exactly this, that you optimize the data you hand the model, not the model. Brave's benchmark is the cleanest external proof of that idea I have found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually did about my page 5 problem
&lt;/h2&gt;

&lt;p&gt;Diagnosis first, because it costs nothing and it is humbling in a useful way.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search yourself on Brave.&lt;/strong&gt; Go to &lt;a href="https://search.brave.com" rel="noopener noreferrer"&gt;search.brave.com&lt;/a&gt; and run your own article titles and target queries. Compare the result to Google. The first time I did this I found three of my "top-ranked" posts nowhere in Brave's first few pages, and one post Google had buried sitting near the top on Brave. The two indexes disagree more than you would believe until you look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask an agent to find you.&lt;/strong&gt; Open Claude Code or any Brave-backed tool, ask it to research your topic and cite sources, and see if your URL shows up. This is the real test, because it is the exact path a reader-via-agent would take. Mine failed it. That failure is the whole reason this article exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship JSON-LD, server-rendered.&lt;/strong&gt; Add &lt;code&gt;TechArticle&lt;/code&gt; and &lt;code&gt;FAQPage&lt;/code&gt; schema with your author, headline, date, and description, and make sure it renders server-side so the crawler and the LLM Context API actually see it. Client-injected schema that only appears after JavaScript runs is schema the index never reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structure for extraction.&lt;/strong&gt; Clean heading hierarchy, real &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; elements for comparisons, fenced code blocks for anything technical. The LLM Context API pulls these out with row-level and block-level precision. Give it clean blocks and you get extracted cleanly; give it mush and you get skipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It is mostly the hygiene I had skipped because Google rewarded me anyway and I let "ranks #1" paper over "structured like 2014." The Brave index does not grant that grace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable summary
&lt;/h2&gt;

&lt;p&gt;For years "rank on Google" was a complete sentence. It is now a partial one. Google still owns roughly 90% of human search, so SEO is not dead and I am not telling you to torch it. But human search and agent search now run on different rails, and the agent rail increasingly runs through Brave. Optimizing only for Google buys you nothing on the index that AI tools actually query.&lt;/p&gt;

&lt;p&gt;The fix is not a growth hack. It is going to Brave, searching for yourself, watching an agent fail to find you, and then giving Brave's index the structured, clean, extractable content it rewards. I ranked #1 on Google and still could not get my own agent to cite me. Fixing that started with admitting the search engine I never use had been quietly grading my homework the whole time.&lt;/p&gt;

&lt;p&gt;If you want the full implementation playbook for the index side of this, including the JSON-LD patterns and why AI engines keep ignoring perfectly good pages, &lt;a href="https://llmoframework.com" rel="noopener noreferrer"&gt;llmoframework.com&lt;/a&gt; is where I keep the working version.&lt;/p&gt;

</description>
      <category>llmo</category>
      <category>bravesearch</category>
      <category>ai</category>
      <category>seo</category>
    </item>
    <item>
      <title>Claude Code Made My Sprints 40% Slower: 3 Time Sinks I Only Found by Timing Myself</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 16 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/claude-code-made-my-sprints-40-slower-3-time-sinks-i-only-found-by-timing-myself-4998</link>
      <guid>https://dev.to/kenimo49/claude-code-made-my-sprints-40-slower-3-time-sinks-i-only-found-by-timing-myself-4998</guid>
      <description>&lt;p&gt;Monday morning I estimated an HTTP retry tweak at 45 minutes. I closed the laptop at 8:07 PM. Fine, one bad estimate. Except when I added up the last five sprints and compared them to the five before I bolted Claude Code onto everything, the shape was ugly: my "quick" tickets were finishing about &lt;strong&gt;40% slower&lt;/strong&gt; on average. And I had walked around for two months telling anyone who would listen that the AI had turned me into a shipping machine.&lt;/p&gt;

&lt;p&gt;The AI is not the villain. I still use Claude Code every day, and Opus 4.7 is a real jump from Opus 4.6. Anthropic reports SWE-Bench Pro moving from 53.4% to 64.3% in the &lt;a href="https://www.anthropic.com/news" rel="noopener noreferrer"&gt;April 2026 release notes&lt;/a&gt;. What died is the story I told myself about where the time was going. This is the log of the three specific places I lost it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The illusion I was defending
&lt;/h2&gt;

&lt;p&gt;I had a "feeling" I was faster. That feeling is the same one described in the METR randomized study from July 2025 (&lt;a href="https://arxiv.org/abs/2507.09089" rel="noopener noreferrer"&gt;arXiv:2507.09089&lt;/a&gt;): 16 experienced open source developers with an average of 5 years on their own repositories, given AI tools, took &lt;strong&gt;19% longer&lt;/strong&gt; to complete tasks. Before starting, they predicted a 24% speedup. &lt;strong&gt;After finishing, slower, they still believed the AI had sped them up by 20%.&lt;/strong&gt; The gap between what you feel and what the clock records is about 39 percentage points, and it does not close after you live through the slowdown.&lt;/p&gt;

&lt;p&gt;So my private "40%" is my number, on a smaller sample, in a codebase I know cold. METR gets 19% and I get 40% for roughly the same reason: the harder I know the codebase, the more expensive it is to route the decision through someone who does not.&lt;/p&gt;

&lt;p&gt;Fine. Where did the time actually go?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qr42aah1rw1nyom2bxq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qr42aah1rw1nyom2bxq.png" alt="Three time sinks that made Claude Code slow me down 40% — the 3-second response hiding a 40-minute read, auto-accept as a slow leak, and the 'one more prompt' loop compounding into hours" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sink 1: The 3-second response that hid a 40-minute read
&lt;/h2&gt;

&lt;p&gt;Claude Code returns a diff in three seconds. My brain reads that as "the task took three seconds."&lt;/p&gt;

&lt;p&gt;The task did not take three seconds. It took three seconds of generation plus twelve minutes reading the diff, plus twenty-eight minutes chasing an off-by-one in an exponential backoff that only fired under load, plus fifteen minutes writing the test that would have caught it if I had written the test first the way I do without AI. That is fifty-five minutes, and I logged it in my head as "quick fix, Claude did it."&lt;/p&gt;

&lt;p&gt;The generation speed contaminates the estimate for the entire ticket. If you asked me on Monday morning "how long?" I would have said 45 minutes because the AI part is 3 seconds and the "read and verify" part felt free. It is not free. It is the whole job.&lt;/p&gt;

&lt;p&gt;Anthropic's docs point to Plan Mode for exactly this reason — read-only, Claude proposes changes without touching files, and you are supposed to review the plan before it runs (&lt;a href="https://code.claude.com/docs/en/permission-modes" rel="noopener noreferrer"&gt;permission modes&lt;/a&gt;). I did use plan mode. I skimmed the plans the same way I skim CI output: looking for red, not looking for wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sink 2: Auto-accept mode as a slow leak
&lt;/h2&gt;

&lt;p&gt;Anthropic shipped an official Auto mode through spring 2026, opening it to Team, Enterprise, and API between March 24 and April 16, and then to Max and Pro users, with Sonnet 4.6 support added alongside Opus 4.7 (&lt;a href="https://claude.com/blog/auto-mode" rel="noopener noreferrer"&gt;Auto mode announcement&lt;/a&gt;). The safety classifier runs on Sonnet 4.6 no matter which model your main session uses, and it is genuinely good at blocking the obviously catastrophic stuff: pushing to main, exfiltrating secrets. That is not where the time died.&lt;/p&gt;

&lt;p&gt;The time died in the acceptable-but-wrong lane. Auto-accepting file edits ("Accept edits" mode, which auto-approves file edits and safe filesystem operations inside your working directory) let a whole category of "yes, this compiles, but it is not what I wanted" changes ship into my working tree without me looking at them until I ran the code. Then I read the change on a compile failure or a test failure, which is a much more expensive place to read it. Cognitive context switches are pricey and I was buying them wholesale.&lt;/p&gt;

&lt;p&gt;The trap is not the mode. The trap is treating "safe" (the classifier blocked the disaster) as "correct" (this actually matches my intent). Those are different words. The classifier does not know my intent. My intent lives in my head, and I was outsourcing that head to a very fast typist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sink 3: The "one more prompt" verification loop
&lt;/h2&gt;

&lt;p&gt;This one is the deepest cut. Every time Claude Code returned a solution that was 90% right, I sent it back with "close, but also handle X." Twenty seconds to type the follow-up. Two minutes to re-read the new diff. Six minutes to run tests. Eight minutes to notice that the fix to X regressed the thing that was already right. Sixteen minutes per loop, and the loop feels fast because each turn is short.&lt;/p&gt;

&lt;p&gt;Three loops is 48 minutes, which is longer than it would take me to write the whole thing myself in a codebase I know. But I never budget for three loops on Monday morning. I budget for one, feel great about the first response, and by the third loop I have talked myself into "I'm almost done" and I stay another hour past the point where sunk cost should have told me to close the loop and finish it by hand.&lt;/p&gt;

&lt;p&gt;Simon Willison has been putting this fairly plainly for a while: LLMs are a productivity amplifier for tasks you know how to do and a mirage for tasks you don't, and the trap is that they feel the same from the inside. He is right, and my log agrees.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually broke: the estimate, not the AI
&lt;/h2&gt;

&lt;p&gt;In each of the three sinks, the AI performed roughly as advertised. It generated code fast. Its Auto mode blocked genuinely dangerous shell calls. Its plan mode showed me plans. None of that is the problem.&lt;/p&gt;

&lt;p&gt;The problem is that "generation" and "task" are different sizes and my estimate collapsed them. When I say "this will take 45 minutes," I am estimating the total ticket: generate + read + verify + fix + verify + commit. The AI compresses only the first component. It leaves the rest untouched or, worse, expands it because now I am reading someone else's code instead of writing my own. If you shrink 5 minutes of a 45-minute task, you save 5 minutes. If you shrink 5 minutes and blow up the other 40 into 60 because verification is now more expensive, you lost 15 minutes and told yourself you saved 5.&lt;/p&gt;

&lt;p&gt;That is not a Claude Code bug. That is an arithmetic bug in my planning, and it took me sixty days and one 8 PM Monday to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I added to CLAUDE.md on Monday
&lt;/h2&gt;

&lt;p&gt;I did not quit Claude Code. I added one rule to my per-repo &lt;code&gt;CLAUDE.md&lt;/code&gt;, and it took me under a minute to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Estimate policy&lt;/span&gt;

When I estimate any task involving AI code generation, I split the estimate
into two lines:
&lt;span class="p"&gt;
-&lt;/span&gt; Generation budget: how long the AI will take to produce first-draft code.
&lt;span class="p"&gt;-&lt;/span&gt; Verification budget: how long I will take to read, run, and correct it.

The commit only happens when both budgets close. If the verification budget
runs out and the code is not shipping, I stop and rewrite by hand rather
than opening another prompt loop.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three effects since I added it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;My estimates got more honest. "Quick" tickets get 90 minutes now instead of 45, because 45 was fiction all along.&lt;/li&gt;
&lt;li&gt;I catch the "one more prompt" loop earlier because verification has a real budget instead of an infinite string.&lt;/li&gt;
&lt;li&gt;I stopped using auto-accept for anything I could not describe out loud before generating it. If I cannot describe the intended change in one sentence, the AI does not have my intent either, and any speed it gives me is going to be given back with interest inside 20 minutes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One more piece if your team ships services: pair the verification budget with a CI perf gate. &lt;a href="https://codspeed.io/" rel="noopener noreferrer"&gt;CodSpeed&lt;/a&gt; hardened its AI-agent integration in March 2026 with an MCP server that can hunt regressions on every PR (&lt;a href="https://codspeed.io/changelog/2026-03-16-mcp-server" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;). Auto mode plus a perf-gate CI catches the "compiles green, ships slow" class of AI-generated code that would otherwise blow up the verification budget after merge.&lt;/p&gt;

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

&lt;p&gt;Two things I did not believe on Monday morning.&lt;/p&gt;

&lt;p&gt;First, the feeling of speed is not evidence of speed. It is evidence of how fast the last visible unit of work happened. If the visible unit is "AI returns code" and the invisible unit is "I read and verify code," the feeling is going to lie in a very consistent direction. METR measured 39 percentage points of lie. My personal number is uglier because I estimate more aggressively than the median dev. Yours will differ. Timing yourself once, on real work, will tell you which side of the line you are on faster than any think piece.&lt;/p&gt;

&lt;p&gt;Second, I got slower not because of AI, but because I let AI speed rewrite my estimation model in the background. The estimation model is the thing that keeps me honest. Once it goes, the tool that gets blamed is the loudest tool in the room, and Claude Code has been by a wide margin the loudest tool in mine. The tool did not lie. My clock did, and I trusted the clock more than the ticket. That is the mistake, and the mistake is fixable with sixty seconds and a &lt;code&gt;CLAUDE.md&lt;/code&gt; edit.&lt;/p&gt;

&lt;p&gt;The irony that lands hardest: the study that most helped me use AI well is a study about AI making experienced developers slower. I did not get slower from using AI. I got slower from &lt;em&gt;believing&lt;/em&gt; the AI without timing myself. Different failure mode, same repair.&lt;/p&gt;




&lt;p&gt;The full set of harness rules I use across all Claude Code projects — memory files, permission mode discipline, verification budgets, session cost accounting — is collected in &lt;a href="https://kenimoto.dev/books/harness-engineering-guide?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cc-40-slower-3-sinks" rel="noopener noreferrer"&gt;Harness Engineering: A Field Guide&lt;/a&gt;. It is written for engineers who want to run Claude Code past the "open three terminals and hope" stage.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devops</category>
    </item>
    <item>
      <title>Whisper v3 Turbo + Qwen2.5 1.5B: 5 Ollama Models Benchmarked for Sub-300ms Voice AI on CPU</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/whisper-v3-turbo-qwen25-15b-5-ollama-models-benchmarked-for-sub-300ms-voice-ai-on-cpu-4a8d</link>
      <guid>https://dev.to/kenimo49/whisper-v3-turbo-qwen25-15b-5-ollama-models-benchmarked-for-sub-300ms-voice-ai-on-cpu-4a8d</guid>
      <description>&lt;p&gt;Light does not negotiate. Tokyo to Virginia is about 130ms round-trip and no amount of infrastructure spend changes that. If you want a voice agent to feel like a person and not a call-center IVR, the round trip has to leave the building.&lt;/p&gt;

&lt;p&gt;So I ran the whole stack on a CPU-only laptop. No GPU. No cloud LLM. Whisper v3 Turbo for STT, one of five Ollama models for the LLM, a local TTS for the last mile. Wall-clock target: sub-300ms voice-to-first-byte. Here are the seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  5 Ollama models, one CPU, one budget
&lt;/h2&gt;

&lt;p&gt;I put each model behind the same Whisper Turbo front end and the same local TTS, and asked it to produce the first spoken token as fast as possible. The audio input was a short customer-support prompt in English and Japanese. The box was a mid-range x86 laptop, no accelerator, running Ollama with Q4_K_M weights where available.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size on disk&lt;/th&gt;
&lt;th&gt;Tokens/sec&lt;/th&gt;
&lt;th&gt;Time to first token&lt;/th&gt;
&lt;th&gt;Japanese quality&lt;/th&gt;
&lt;th&gt;Verdict for 300ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5 1.5B&lt;/td&gt;
&lt;td&gt;~1 GB&lt;/td&gt;
&lt;td&gt;4-8 tok/s&lt;/td&gt;
&lt;td&gt;200-350 ms&lt;/td&gt;
&lt;td&gt;Usable&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Fits&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 2B&lt;/td&gt;
&lt;td&gt;~1.5 GB&lt;/td&gt;
&lt;td&gt;~15 tok/s&lt;/td&gt;
&lt;td&gt;220-380 ms&lt;/td&gt;
&lt;td&gt;Passable&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Fits (best throughput)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5 0.8B&lt;/td&gt;
&lt;td&gt;~1 GB&lt;/td&gt;
&lt;td&gt;12-15 tok/s&lt;/td&gt;
&lt;td&gt;150-250 ms&lt;/td&gt;
&lt;td&gt;Thinking loop&lt;/td&gt;
&lt;td&gt;Fast but unstable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5 2B&lt;/td&gt;
&lt;td&gt;~2.7 GB&lt;/td&gt;
&lt;td&gt;8-9 tok/s&lt;/td&gt;
&lt;td&gt;280-450 ms&lt;/td&gt;
&lt;td&gt;Thinking loop&lt;/td&gt;
&lt;td&gt;Overshoots on retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-R1 1.5B&lt;/td&gt;
&lt;td&gt;~1.1 GB&lt;/td&gt;
&lt;td&gt;3.9-4.0 tok/s&lt;/td&gt;
&lt;td&gt;400-600 ms&lt;/td&gt;
&lt;td&gt;JP/CN code-switch&lt;/td&gt;
&lt;td&gt;Misses the budget&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of five hold the budget with margin. Qwen2.5 1.5B and Gemma 3 2B are the only ones I would put in front of a real user today. The rest have a reason they miss, and the reasons matter more than the seconds.&lt;/p&gt;

&lt;p&gt;If you were expecting DeepSeek-R1 to win, you and I both. It is the model most local-LLM tutorials open with. On CPU, at 1.5B, with a Japanese prompt, it thinks in Chinese for a beat before it answers in a mix of both. The stopwatch says the latency is fine. The user hangs up anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Whisper v3 Turbo made this fight winnable
&lt;/h2&gt;

&lt;p&gt;Whisper Large v3 Turbo is &lt;a href="https://novascribe.ai/how-accurate-is-whisper" rel="noopener noreferrer"&gt;809M parameters with 4 decoder layers instead of the Large v3's 32&lt;/a&gt;. That single architectural change cuts per-chunk decode time in half at a 0.3-0.7pp WER cost. Simplismart's production benchmark &lt;a href="https://simplismart.ai/blog/fastest-whisper-v3-turbo-serving-millions-of-requests-at-1300-real-time-with-simplismart" rel="noopener noreferrer"&gt;put the served RTF at 1300x&lt;/a&gt;, and Groq's hosted variant &lt;a href="https://groq.com/blog/whisper-large-v3-turbo-now-available-on-groq-combining-speed-quality-for-speech-recognition" rel="noopener noreferrer"&gt;runs at 216x real-time&lt;/a&gt;. On my CPU box I get nothing close to 1300x, but I get 50-150ms of STT on a 3-second utterance and that is what the budget needs.&lt;/p&gt;

&lt;p&gt;The distilled English-only cousin, &lt;a href="https://novascribe.ai/how-accurate-is-whisper" rel="noopener noreferrer"&gt;distil-whisper large-v3, runs at ~90x RTF on a GPU&lt;/a&gt;, which is faster on paper. It also drops multilingual, so for the Japanese half of my traffic Turbo is the only one that survives. If your product is English-only and you have a GPU, distil-whisper is worth a look. Turbo is the model I ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 300ms actually goes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsnzzc0cg2xe5ou7g0g8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsnzzc0cg2xe5ou7g0g8.png" alt="Latency budget breakdown for Whisper Turbo + Qwen2.5 1.5B on CPU"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Adding up the budget line by line is the boring part, and it is also the part everyone gets wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VAD (voice activity detect):     0-5 ms
Whisper Turbo STT (3s audio):   50-150 ms
Qwen2.5 1.5B LLM TTFT:         200-350 ms   ← the fat one
Local TTS TTFB:                 40-75 ms
Network:                         0 ms       ← the win
--------------------------------------------
Total to first spoken byte:    290-580 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM's time-to-first-token is the fat variable and everything else is thin. If Qwen2.5 lands at 200ms you clear 300ms. If it lands at 350ms you clear 400ms. There is nothing else to tune. Speeding up Whisper by 40ms buys you 40ms. Speeding up the LLM by 40ms buys you 40ms on every subsequent token too, because that is the model that keeps talking.&lt;/p&gt;

&lt;p&gt;Which is why my "winning" configuration is Qwen2.5 1.5B, and Qwen2.5 is not the fastest of the five. Gemma 3 2B has better tokens-per-second by roughly &lt;a href="https://www.promptquorum.com/local-llms/best-cpu-only-llm" rel="noopener noreferrer"&gt;15 vs 4-8&lt;/a&gt;, and I still ship Qwen2.5 when the traffic is Japanese-heavy, because Qwen's TTFT variance is tighter and the output quality does not embarrass me. Gemma is my English-first pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two failure modes nobody warns you about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Thinking-loop overshoot.&lt;/strong&gt; Qwen3.5 in the 0.8B and 2B sizes will start a chain-of-thought passage before it answers. On the desktop that is a feature. In a voice loop it burns 200ms and outputs &lt;code&gt;&amp;lt;thinking&amp;gt;&lt;/code&gt; tokens the TTS then tries to speak. If you want a reasoning model for your voice agent, wrap it in a stop-on-first-answer regex. Do not skip that step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-language code-switching.&lt;/strong&gt; DeepSeek-R1 1.5B on a Japanese prompt produces answers with Chinese phrases mixed in. This is a distillation artifact from the base model, and prompt tuning does not remove it. If your users speak the language the model bleeds from, you will hear it in the TTS output. I noticed on the second test call. Users will notice on the first.&lt;/p&gt;

&lt;p&gt;Both of these fail in production and pass in benchmark scripts, which is exactly the opposite of what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipecat vs LiveKit context, briefly
&lt;/h2&gt;

&lt;p&gt;If you are doing this in a framework rather than raw Python, &lt;a href="https://sellerity.co/blog/livekit-pipecat-web-voice-agents" rel="noopener noreferrer"&gt;LiveKit's baseline is 750-900ms end-to-end and Pipecat's is 800-950ms&lt;/a&gt; with a standard cloud stack. Both frameworks can reach &lt;a href="https://futureagi.com/blog/how-to-optimize-livekit-latency-2026/" rel="noopener noreferrer"&gt;sub-500ms p95 in 2026 with streaming STT, partial TTS, and prefix caching&lt;/a&gt;, assuming a GPU somewhere in the loop.&lt;/p&gt;

&lt;p&gt;The CPU-only path is aiming at a different product entirely. 300ms from a device that is not on the internet and does not care about a regional outage. That is what the physics buys you when you take the trip out of the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The realistic edge + cloud hybrid
&lt;/h2&gt;

&lt;p&gt;The pure-edge configuration is a technical achievement, and it is also not what I would ship to most customers. This is the version I actually recommend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edge: VAD, Whisper Turbo STT, a small local LLM as a fallback / cache.&lt;/li&gt;
&lt;li&gt;Cloud: primary LLM (whatever your quality tier demands) and TTS.&lt;/li&gt;
&lt;li&gt;Only text crosses the wire. Voice data stays local.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That configuration lands at 300-350ms voice-to-first-byte with the same Whisper Turbo front end and a cloud LLM, gets you the quality of a large model, and keeps the privacy story that the pure cloud path cannot buy at any price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constraints worth naming
&lt;/h2&gt;

&lt;p&gt;Local inference on CPU costs you memory, battery, and thermal headroom. 1.5B at Q4_K_M is about a gigabyte of RAM permanently pinned. A long call drains a laptop battery faster than a video call. Sustained inference will thermal-throttle a fanless device inside ten minutes.&lt;/p&gt;

&lt;p&gt;None of that is a dealbreaker. All of it is a reason to have a fallback path. My rule: local first, cloud on thermal or memory pressure, log which path served each request so I can see the shape of the tradeoff a month later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;If I were starting fresh I would benchmark Phi-4 Mini alongside Qwen2.5. Phi-4 Mini is &lt;a href="https://www.promptquorum.com/local-llms/best-cpu-only-llm" rel="noopener noreferrer"&gt;12 tok/s on CPU at 3.8B&lt;/a&gt;, which is a good spot in the CPU-bandwidth-bound curve, and it did not exist when I ran this test. I would also test the newer distil-whisper INT8 variants for the English-only case, since the 6x speedup is real and the WER cost is smaller than the reputation suggests.&lt;/p&gt;

&lt;p&gt;I would not switch off Whisper Turbo. The 6.3x speedup over Large v3 with a tiny WER penalty is the single most important line item in the whole stack, and every downstream design decision falls out of that one architectural change.&lt;/p&gt;




&lt;p&gt;The 300ms wall was physics. Whisper Turbo and a 1.5B LLM on a laptop CPU turned it into an engineering problem, and engineering problems ship.&lt;/p&gt;

&lt;p&gt;The long form of this stack (Pipecat design patterns, filler-word strategies for hiding the last 50ms, the full latency breakdown for hybrid cloud, and the parts I cut from this piece for length) is the book that grew out of the work: &lt;a href="https://kenimoto.dev/books/voice-ai-300ms-ux?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=voice-300ms-cpu-5-ollama" rel="noopener noreferrer"&gt;Voice AI 300ms UX (English edition)&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>voiceai</category>
      <category>whisper</category>
      <category>ollama</category>
      <category>edgeai</category>
    </item>
  </channel>
</rss>
