<?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>Claude, Codex, Kimi K3, Qwen: I graded their web search 120 times</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/kenimo49/claude-codex-kimi-k3-qwen-i-graded-their-web-search-120-times-48lk</link>
      <guid>https://dev.to/kenimo49/claude-codex-kimi-k3-qwen-i-graded-their-web-search-120-times-48lk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The Kimi API usage in this post was paid for with credits Moonshot AI provided for evaluation. Moonshot AI had no part in designing, running, or concluding the test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four months ago I wrote that &lt;a href="https://kenimoto.dev/blog/brave-invisible-to-ai-agents/" rel="noopener noreferrer"&gt;my own AI agents search Brave, not Google&lt;/a&gt;. Like most people, I thought about AI search in terms of which search engine sits underneath.&lt;/p&gt;

&lt;p&gt;This time I reread the official docs to compare web search in Claude, Codex, Kimi K3 and Qwen. That framing no longer fits. None of the four vendors names its search engine. OpenAI makes its own maintained index the default in Codex. Kimi now sells search itself as a standalone API. The question has moved from "which engine?" to "where does search run, and who calls it?" I did not expect that much change in four months.&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%2Funlgqv5ct4502sw5xhvw.jpg" 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%2Funlgqv5ct4502sw5xhvw.jpg" alt="Illustration of four different-looking robots, each looking things up in its own way. From left: one pulls index cards from a wooden drawer, one looks through a telescope from a balcony at a cloud of floating web pages, one hands an envelope to a delivery drone, and one sits on the floor while a human hand reaches through a hatch in the wall to plug a cable into it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the mechanics changed, the results should too. Before measuring, I assumed live search would beat a cache whenever the question needs fresh information.&lt;/p&gt;

&lt;p&gt;Codex CLI's web search is not live by default. It runs in "cached" mode against an index OpenAI maintains. The docs give the reason: it lowers prompt injection risk (&lt;a href="https://learn.chatgpt.com/docs/web-search.md" rel="noopener noreferrer"&gt;Codex: Web search&lt;/a&gt;). I read that as trading some freshness for safety.&lt;/p&gt;

&lt;p&gt;The measurements said the opposite. I asked the same 8 questions 3 times under 5 setups: Claude, Codex live, Codex cached, Kimi K3 and a local Qwen. &lt;strong&gt;Only cached search got every answer right.&lt;/strong&gt; It even found the title of an article that had been online for about three hours.&lt;/p&gt;

&lt;p&gt;The other surprise: &lt;strong&gt;the same search API produced 21 correct answers with one model and 9 with another.&lt;/strong&gt; I gave Kimi K3 and a local Qwen identical access to Kimi's search API. The results were the same. How each model read them was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where web search runs differs by vendor
&lt;/h2&gt;

&lt;p&gt;Before comparing anything, I checked the mechanics in public docs. The biggest difference is where the search actually runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;th&gt;Codex / OpenAI&lt;/th&gt;
&lt;th&gt;Kimi (Moonshot AI)&lt;/th&gt;
&lt;th&gt;Qwen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where search runs&lt;/td&gt;
&lt;td&gt;Anthropic's side (server tool)&lt;/td&gt;
&lt;td&gt;OpenAI's side (hosted tool)&lt;/td&gt;
&lt;td&gt;New API: &lt;strong&gt;your code calls it and passes results to the model&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Alibaba's side (DashScope). &lt;strong&gt;A local Qwen has no search&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freshness control&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Codex defaults to cached; &lt;code&gt;--search&lt;/code&gt; for live&lt;/td&gt;
&lt;td&gt;Time window on Pro search&lt;/td&gt;
&lt;td&gt;7-365 day window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price per search&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;$0.002-0.003&lt;/td&gt;
&lt;td&gt;Varies by region&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search engine&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;td&gt;Not disclosed ("OpenAI-maintained index")&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fu3ie7v4fh1xo4ppg5j8c.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%2Fu3ie7v4fh1xo4ppg5j8c.png" alt="Table comparing where web search runs in three designs. Columns: receives the question, runs the search, reads results and answers. For Claude and OpenAI, the vendor's server and model handle all three. For Kimi's new API, your code receives the question and calls Kimi's search API, and Kimi K3 reads the results. A local Qwen has no built-in search, so your code wires in an external search API (Kimi's here) and the local Qwen reads the results. Bottom line: same Kimi search API, different readers, K3 scored 21/24 and local Qwen 9/24" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of the four names its search engine officially. For Claude, a March 2025 report said Brave Search had been added to Anthropic's list of subprocessors (&lt;a href="https://simonwillison.net/2025/Mar/21/anthropic-used-brave/" rel="noopener noreferrer"&gt;Simon Willison&lt;/a&gt;). Today's docs only say "Anthropic's web search backend."&lt;/p&gt;

&lt;p&gt;Claude and OpenAI run the search on their own servers and hand the results to the model. With Claude, results come back encrypted, and you must send them back unchanged to continue the conversation (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool" rel="noopener noreferrer"&gt;Claude: Web search tool&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Kimi is mid-transition. The built-in &lt;code&gt;$web_search&lt;/code&gt; tool retires on 2026-10-20. Its replacements are standalone REST APIs for search and page fetch, such as &lt;code&gt;/v1/tools/search&lt;/code&gt; (&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;). With the new APIs, you decide when and how often to search. They cost a third to a fifth of the others, billed only when a call succeeds and returns results (&lt;a href="https://platform.kimi.ai/docs/pricing/websearch" rel="noopener noreferrer"&gt;Kimi: pricing&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Qwen the model has no search of its own. Alibaba Cloud's API offers search, but a Qwen running locally can only look things up if you wire in an external search tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two details in the docs you will want to know
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code's WebSearch returns only titles and URLs.&lt;/strong&gt; It does not read the pages. Reading happens in WebFetch, which summarizes the page with a separate model call first. The docs call this "lossy by design" (&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Claude Code: Tools reference&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qwen's search on Alibaba Cloud silently skips searching above 15 requests per second.&lt;/strong&gt; There is no error; the search just does not happen. The FAQ also says English questions often fail to trigger search, and recommends asking in Chinese or forcing search. Both notes appear only in the Chinese docs (&lt;a href="https://help.aliyun.com/zh/model-studio/web-search" rel="noopener noreferrer"&gt;阿里云: 联网搜索&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hit the "lossy" part myself while researching this post. I had Claude Code's WebFetch read the Qwen docs, and it reported finding neither the 15 RPS note nor the English FAQ. Running grep over the raw HTML (about 460 KB) found both. Once a summary sits in the middle, "it doesn't say that" may only mean "the summary dropped it."&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;p&gt;Five setups, the same 8 questions, 3 runs each: 120 graded answers in total, collected on the evening of 2026-10-14.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;Claude Code (&lt;code&gt;claude -p&lt;/code&gt;) with WebSearch / WebFetch, Opus 5.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;Responses API &lt;code&gt;web_search&lt;/code&gt;, gpt-5.6-terra (my Codex CLI's default model)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI cached&lt;/td&gt;
&lt;td&gt;Same, plus &lt;code&gt;external_web_access: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;kimi-k3 with my own search function backed by Kimi's &lt;code&gt;/v1/tools/search&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;qwen3.5:35b-a3b on Ollama with the same search function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For Codex, I called the API the CLI uses under the hood. In Codex's source, cached mode maps to &lt;code&gt;external_web_access=false&lt;/code&gt; and live to &lt;code&gt;true&lt;/code&gt; (&lt;a href="https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/hosted_spec.rs" rel="noopener noreferrer"&gt;codex hosted_spec.rs&lt;/a&gt;). So the API calls reproduce the CLI's two modes.&lt;/p&gt;

&lt;p&gt;These were the 8 questions. I only used questions whose answers I could verify from primary sources or my own site.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;What it tests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;q1&lt;/td&gt;
&lt;td&gt;Kimi K3 API release date&lt;/td&gt;
&lt;td&gt;2026-07-16&lt;/td&gt;
&lt;td&gt;News after the training cutoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q2&lt;/td&gt;
&lt;td&gt;Kimi K3 open-weights release date (asked in English with "release date")&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;td&gt;The "date" trap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q3&lt;/td&gt;
&lt;td&gt;Release date of a Japanese book, &lt;em&gt;MCP実践セキュリティ&lt;/em&gt; (Impress)&lt;/td&gt;
&lt;td&gt;2026-10-16&lt;/td&gt;
&lt;td&gt;Japanese-language sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q4&lt;/td&gt;
&lt;td&gt;Japanese title of a Kimi K3 article posted on kenimoto.dev that day&lt;/td&gt;
&lt;td&gt;Kimi K3の動画レビュー、Claudeと互角&lt;/td&gt;
&lt;td&gt;Freshness: a page a few hours old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q5&lt;/td&gt;
&lt;td&gt;Date the MCP 2026-07-28 spec release candidate came out&lt;/td&gt;
&lt;td&gt;2026-05-21&lt;/td&gt;
&lt;td&gt;A dated fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q6&lt;/td&gt;
&lt;td&gt;Retirement date of Kimi's &lt;code&gt;$web_search&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2026-10-20&lt;/td&gt;
&lt;td&gt;A detail buried in docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q7&lt;/td&gt;
&lt;td&gt;Kimi K3 total and activated parameters&lt;/td&gt;
&lt;td&gt;2.8T / 104B&lt;/td&gt;
&lt;td&gt;Numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q8&lt;/td&gt;
&lt;td&gt;Kimi K4 release date&lt;/td&gt;
&lt;td&gt;Not announced&lt;/td&gt;
&lt;td&gt;Does it invent something that doesn't exist?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;q3 is my own book and q4 is my own article, chosen because I know the answers for certain. That choice carries my own bias, and I'd rather say so. Both are in Japanese, so they also test how well each setup reaches Japanese-language sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Correct (of 24)&lt;/th&gt;
&lt;th&gt;Partial&lt;/th&gt;
&lt;th&gt;Wrong or no answer&lt;/th&gt;
&lt;th&gt;Median time&lt;/th&gt;
&lt;th&gt;Searches (avg)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;22.0 s&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5.7 s&lt;/td&gt;
&lt;td&gt;2.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenAI cached&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6.2 s&lt;/td&gt;
&lt;td&gt;2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;25.9 s&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;31.1 s&lt;/td&gt;
&lt;td&gt;1.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Correct answers per question, out of 3 runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;q1&lt;/th&gt;
&lt;th&gt;q2&lt;/th&gt;
&lt;th&gt;q3&lt;/th&gt;
&lt;th&gt;q4&lt;/th&gt;
&lt;th&gt;q5&lt;/th&gt;
&lt;th&gt;q6&lt;/th&gt;
&lt;th&gt;q7&lt;/th&gt;
&lt;th&gt;q8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI cached&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the nonexistent Kimi K4 (q8), every setup said it hasn't been announced. Qwen once added, without a source, that K4 had been "mentioned as part of plans for 2027 or later."&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the cache wasn't stale
&lt;/h2&gt;

&lt;p&gt;Cached search answered all 24 correctly. The q4 article went live a little after 5 p.m. that day, and I measured about three hours later. Cached search returned its title in all three runs. Honestly, that surprised me. At least for my site, "an index OpenAI maintains" was refreshed within hours.&lt;/p&gt;

&lt;p&gt;Source quality also favored the cache at times. On q6 (when &lt;code&gt;$web_search&lt;/code&gt; retires), cached search cited Kimi's official docs. Live search got the same answer but, in one run, cited an unofficial roundup site. A wider reach also means picking up more unofficial pages.&lt;/p&gt;

&lt;p&gt;Live search missed q3, the Japanese book, once, saying it couldn't confirm a book by that title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: same results, different answers
&lt;/h2&gt;

&lt;p&gt;Kimi K3 and the local Qwen got exactly the same search API in exactly the same way. They still scored 21 and 9. That's more than double.&lt;/p&gt;

&lt;p&gt;Qwen's mistakes followed a few patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Its belief that it's 2024 beat the search results.&lt;/strong&gt; On q4 it said: "As of now (May 2024), an article dated October 14, 2026 does not exist. That is a future date." On q1 it found the July 16 date, then concluded it was "not released as of 2025"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its sources and its answers disagreed.&lt;/strong&gt; On q5, all three runs cited the release-candidate announcement dated May 21, then answered with the final release date, July 28&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It invented a source.&lt;/strong&gt; On q3 it cited an Impress press release URL that doesn't exist (404) and gave a wrong release date. Each run gave a different wrong date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It barely searched.&lt;/strong&gt; 1.7 searches on average, less than half of K3's 4.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model size differs, so this isn't a verdict on Qwen as a whole. What it does show is that &lt;strong&gt;web search accuracy is not decided by the search API alone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: Kimi's search API has two gaps
&lt;/h2&gt;

&lt;p&gt;All three of K3's misses were q4. It rephrased the query 12 times and never found the same-day article. The page simply wasn't in Kimi's search index yet. The cause wasn't the model. It was index freshness.&lt;/p&gt;

&lt;p&gt;The second gap: &lt;strong&gt;queries containing the &lt;code&gt;site:&lt;/code&gt; operator return HTTP 502.&lt;/strong&gt; Both K3 and Qwen tried &lt;code&gt;site:kenimoto.dev&lt;/code&gt; when hunting for a specific page. Each time, the search API returned a non-JSON error page, and on the first run it crashed my script. My tool now returns the failure to the model so it can rephrase and try again.&lt;/p&gt;

&lt;p&gt;Hitting the search API directly also showed how much wording matters. Searching "Kimi K3 release date Moonshot AI" let "date" take over: I got a time-zone site and JavaScript Date docs. Searching just "Kimi K3" put the official site, the paper and GitHub on top. None of the models fell into this trap during the actual runs, though. Both K3 and Qwen rephrased their own queries around it.&lt;/p&gt;

&lt;p&gt;By the way, the soon-to-retire &lt;code&gt;$web_search&lt;/code&gt; tool didn't work with K3. The second call, the one that returns search results, failed every time with &lt;code&gt;400 tokenization failed&lt;/code&gt;. Passing search through regular function calling, as in this test, works fine with K3 (more in &lt;a href="https://kenimoto.dev/blog/kimi-k3-video-review-vs-claude/" rel="noopener noreferrer"&gt;Kimi K3 vs Claude: Video Review, Same Findings&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude struggled with a Japanese book
&lt;/h2&gt;

&lt;p&gt;On q3, Claude said in 2 of 3 runs that it couldn't find the book and that it may not exist. The book is listed, with its release date, on the publisher's NextPublishing page and on Rakuten Books.&lt;/p&gt;

&lt;p&gt;It was persistent elsewhere, though. In one q4 run, WebSearch didn't surface the article, so Claude found it through the sitemap and opened the page to confirm the title and date. Because WebSearch returns only titles and URLs, fetching pages when needed is part of the design, and here it paid off. Claude also often noted that it had only checked secondary sources, and avoided firm claims.&lt;/p&gt;

&lt;p&gt;The 22-second median includes Claude Code's agent loop. Calling the search tool directly through the API should be faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'll use them
&lt;/h2&gt;

&lt;p&gt;Here's what I'm taking from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leave cached search as the default for research.&lt;/strong&gt; If you use Codex, there's no need to pass &lt;code&gt;--search&lt;/code&gt; every time. The safer setting didn't lose on accuracy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust "not found" for non-English sources.&lt;/strong&gt; Not found is not the same as not existing. When Claude says so, I check another way&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you wire in search yourself, the model matters more.&lt;/strong&gt; With the same search API, K3 and Qwen differed by more than 2x. Pick a model that can read search results before picking the cheapest search API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make your own search tool return failures.&lt;/strong&gt; Kimi's search API returns 502 on &lt;code&gt;site:&lt;/code&gt;. Pass the failure back instead of crashing, and the model will rephrase and search again&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;This is a small test: 8 questions, 3 runs. Topics lean toward Kimi K3 and MCP, and q3 and q4 are my own book and article&lt;/li&gt;
&lt;li&gt;Claude ran through Claude Code; Codex results come from calling the API the CLI uses&lt;/li&gt;
&lt;li&gt;The Qwen setup is a local 35B model wired to Kimi's search, not Qwen's official search&lt;/li&gt;
&lt;li&gt;Each setup works differently, so times aren't directly comparable. I didn't total the costs in this test&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next, I want to call Claude's search tool directly through the API and try Qwen's official search on Alibaba Cloud, to close the gaps between setups.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool" rel="noopener noreferrer"&gt;Claude: Web search tool&lt;/a&gt;: pricing, citations, encrypted results, versions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Claude Code: Tools reference&lt;/a&gt;: how WebSearch and WebFetch behave&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.chatgpt.com/docs/web-search.md" rel="noopener noreferrer"&gt;Codex: Web search&lt;/a&gt;: cached / live modes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/tools-web-search" rel="noopener noreferrer"&gt;OpenAI: Web search tool&lt;/a&gt;: &lt;code&gt;external_web_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/hosted_spec.rs" rel="noopener noreferrer"&gt;openai/codex hosted_spec.rs&lt;/a&gt;: how the modes map to &lt;code&gt;external_web_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt; / &lt;a href="https://platform.kimi.ai/docs/guide/best-practices-for-web-search" rel="noopener noreferrer"&gt;best practices&lt;/a&gt; / &lt;a href="https://platform.kimi.ai/docs/pricing/websearch" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://help.aliyun.com/zh/model-studio/web-search" rel="noopener noreferrer"&gt;阿里云 百炼: 联网搜索&lt;/a&gt; (Chinese; the 15 RPS note and the English-question FAQ appear only here)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>openai</category>
    </item>
    <item>
      <title>Kimi K3 reads video natively. Claude still caught the same issues</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/kenimo49/kimi-k3-reads-video-natively-claude-still-caught-the-same-issues-j4o</link>
      <guid>https://dev.to/kenimo49/kimi-k3-reads-video-natively-claude-still-caught-the-same-issues-j4o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Moonshot AI provided the Kimi API credits used here ($50) for evaluation. They had no part in designing, running, or concluding the test. Everything K3 couldn't do, and the features that didn't work, are in this post too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Moonshot AI's Kimi K3 takes &lt;strong&gt;video files straight through the API&lt;/strong&gt;. Claude has no video input, so when I want Claude to look at a video, I cut it into frames first. Codex CLI doesn't have a video channel either, even when you point it at K3 (&lt;a href="https://platform.kimi.ai/docs/guide/codex-kimi" rel="noopener noreferrer"&gt;Kimi API: Use K3 in Codex&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I wanted to know how much that difference matters in real work. My test material was a pair of 40-50 second hands-on videos I made for the &lt;a href="https://kenimoto.dev/ja/learn/remote-machine-access/" rel="noopener noreferrer"&gt;SSH series on kenimoto.dev /learn/&lt;/a&gt; (Japanese).&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;K3 and Claude agreed on what was wrong.&lt;/strong&gt; Where viewers would drop off, the six fixes, where to insert diagrams, even which video to post first: all the same&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The differences were timestamp precision and effort.&lt;/strong&gt; K3 pinpointed frozen-screen intervals to fractions of a second, and all I did was hand it the file. Claude estimated from frames sampled every 2 seconds, so its timestamps drift by about ±1 second&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K3 doesn't understand audio.&lt;/strong&gt; It can't hear the narration or catch audio sync problems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude was faster.&lt;/strong&gt; The same request took K3 363 seconds and Claude 185&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The test material: auto-generated hands-on videos
&lt;/h2&gt;

&lt;p&gt;The videos come from handson-cast, a CLI I'm building. You give it a JSON file of steps. It runs each command for real inside Docker containers while &lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;vhs&lt;/a&gt; records the terminal, then adds narration from OpenAI's text-to-speech and burns in subtitles. It's Python and ffmpeg underneath. The narration and subtitles are in Japanese.&lt;/p&gt;

&lt;p&gt;I made two videos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key authentication&lt;/strong&gt; (55 s): create a key, install the public key, confirm key-only login works, then disable password auth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bastion host&lt;/strong&gt; (47 s): the same key gets rejected by the bastion with &lt;code&gt;ssh -J&lt;/code&gt;, but works through &lt;code&gt;~/.ssh/config&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every line of terminal output was produced by OpenSSH 10.0p2 running in a container. Nothing is staged. When the rejection message changes from &lt;code&gt;(publickey,password)&lt;/code&gt; to &lt;code&gt;(publickey)&lt;/code&gt;, that really happened on camera.&lt;/p&gt;

&lt;p&gt;The first cut (v1) was not watchable, though. The first second shows me typing an internal wrapper script, &lt;code&gt;bash /lab/ssh-config.step-01.sh&lt;/code&gt;. You can't tell what the video is about. (I made it, and I couldn't tell either.)&lt;/p&gt;

&lt;p&gt;Here's v1 (47 s, with audio):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/videos/blog/kimi-k3-video-review-vs-claude/ssh-config-v1.mp4" rel="noopener noreferrer"&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%2Ftao36goxaxl2y94gz7qi.jpg" alt="Bastion host hands-on video, v1 (47 seconds). Click to open the video." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image to open the mp4 (with Japanese narration).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I showed v1 to K3 and Claude and asked what was wrong with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending video to K3, and what it costs
&lt;/h2&gt;

&lt;p&gt;The K3 API is OpenAI-compatible. You upload the video as a file first, then reference the returned ID with an &lt;code&gt;ms://&lt;/code&gt; prefix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.moonshot.ai/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;learn-ssh-key-auth.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kimi-k3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Why would viewers drop off in the first 3 seconds?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]}],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public URLs aren't accepted. The official docs describe video as a set of key frames, and higher resolution means more tokens (&lt;a href="https://platform.kimi.ai/docs/guide/use-kimi-vision-model" rel="noopener noreferrer"&gt;Kimi API: Vision&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My first measurement used one 55-second, 1920×1080 video:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input tokens&lt;/td&gt;
&lt;td&gt;40,659 (almost all video)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output tokens&lt;/td&gt;
&lt;td&gt;2,500 (1,912 of them reasoning)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;about $0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;83.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Costs use $3 per million input tokens and $15 per million output tokens.&lt;/p&gt;

&lt;p&gt;Two practical tips from actually running it:&lt;/p&gt;

&lt;h3&gt;
  
  
  Assume it can't hear anything
&lt;/h3&gt;

&lt;p&gt;For this run I made a version without burned-in subtitles and asked K3 to transcribe the last sentence of the narration. It answered that it couldn't recognize any audio. Not pretending to hear something it can't is exactly what you want from a reviewer. But it means &lt;strong&gt;K3 can't see narration content or subtitle-audio drift.&lt;/strong&gt; It sees the screen and any subtitles burned into it, nothing else.&lt;/p&gt;

&lt;p&gt;It reads the screen well, and accurately. It listed the commands and output for all four steps in order, including the &lt;code&gt;600&lt;/code&gt; / &lt;code&gt;644&lt;/code&gt; permissions, the change in the rejection message, and an on-screen comment. The only misread was the wrapper script's filename.&lt;/p&gt;

&lt;h3&gt;
  
  
  Give it a generous output budget
&lt;/h3&gt;

&lt;p&gt;K3 always reasons before it answers. With &lt;code&gt;max_tokens&lt;/code&gt; at 300, the reasoning used everything up and the answer came back empty. It thought hard and then left without saying a word. At 8,000, a full video review got cut off in the middle of the first question. For long answers like reviews, I use 32,000.&lt;/p&gt;

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

&lt;p&gt;K3 and Claude each reviewed the same two v1 videos, under matched conditions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How the video goes in&lt;/td&gt;
&lt;td&gt;The file, once&lt;/td&gt;
&lt;td&gt;Frames extracted and read as images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;Not understood&lt;/td&gt;
&lt;td&gt;Not used (transcription turned off)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narration script&lt;/td&gt;
&lt;td&gt;Step JSON attached&lt;/td&gt;
&lt;td&gt;Same JSON attached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Questions&lt;/td&gt;
&lt;td&gt;Same wording (5 questions)&lt;/td&gt;
&lt;td&gt;Same wording (5 questions)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Claude side ran as a subagent that never saw K3's answers. I had already read them, so if I'd done the review myself, I would have been anchored. That's not a comparison.&lt;/p&gt;

&lt;p&gt;For frame extraction I used &lt;code&gt;/watch&lt;/code&gt; from &lt;a href="https://github.com/bradautomates/claude-video" rel="noopener noreferrer"&gt;claude-video&lt;/a&gt;, and left the frame count and resolution up to Claude. It ended up re-extracting 4 times. For the opening it pulled frames at 0 / 0.5 / 1 / 2 / 3 seconds, then one every 2 seconds for the rest: 34 frames in total.&lt;/p&gt;

&lt;p&gt;The five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where would viewers drop off in the first 3 seconds?&lt;/li&gt;
&lt;li&gt;Up to six improvements, biggest impact first (what to change / why it works / which pipeline stage implements it)&lt;/li&gt;
&lt;li&gt;If you combined the article's diagrams with the terminal recording, which diagram goes where, for how long?&lt;/li&gt;
&lt;li&gt;Which video should go to social media first?&lt;/li&gt;
&lt;li&gt;What's already good?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Result: nearly identical findings
&lt;/h2&gt;

&lt;p&gt;They mostly matched. Lined up side by side, the answers overlap almost completely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seconds 0-2 show the wrapper script being typed; the topic is unclear&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The first narration line introduces the site, not a reason to watch&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Could not resolve hostname&lt;/code&gt; in the bastion video is a strong hook, buried&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open with the conclusion or a question (cold open)&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cut the typing and the waits between steps&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bigger font, highlight the key output lines&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make a vertical version&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert the article's diagrams: permissions at &lt;code&gt;ls -l&lt;/code&gt;, the before/after at the climax&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End on the article's OG image&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post the bastion video first&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show a progress bar along the top&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewritten narration lines, as actual sentences&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The four &lt;code&gt;ssh-copy-id&lt;/code&gt; INFO lines are noise&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fixes themselves are standard short-video advice: open with the conclusion, cut silence, keep subtitles short, put the call to action at the end. Either model will give you that. &lt;strong&gt;This comparison didn't surface anything only K3 could find.&lt;/strong&gt; I expected a model that reads video directly to catch something Claude couldn't see. It didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where they differed: timestamps
&lt;/h3&gt;

&lt;p&gt;The biggest gap was in how precisely each one placed things in time.&lt;/p&gt;

&lt;p&gt;K3 wrote things like "the first meaningful output appears at 3.7 seconds" and "the error at 00:03.2 in the bastion video gets buried," sub-second throughout. When I later had it review an improved version, it came back with intervals: "from 30.5 to 36.1 seconds the screen is frozen after the output appears."&lt;/p&gt;

&lt;p&gt;Claude's timestamps were estimates from frames 2 seconds apart, and Claude said so at the end of its review: interval boundaries could be off by about ±1 second. You can sample more densely, but then there are more images to read. It's a trade-off.&lt;/p&gt;

&lt;p&gt;"Where exactly is the screen frozen?" is hard to answer from a handful of stills. That's where reading the video directly paid off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Effort and speed
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My effort&lt;/td&gt;
&lt;td&gt;Hand over the file&lt;/td&gt;
&lt;td&gt;Decide how to sample frames (Claude redid it 4 times)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;363 s&lt;/td&gt;
&lt;td&gt;185 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;84,461 input / 10,863 output tokens, about $0.20 (part of the input billed at the cache rate)&lt;/td&gt;
&lt;td&gt;111,313 tokens, 40 tool calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;K3 took less effort; Claude was faster. Each has its edge. Because K3 spends time reasoning, each review took 6-8 minutes. Enough time to make a coffee and come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The videos really did get better
&lt;/h2&gt;

&lt;p&gt;I made the fixes. Following the reviews, I added six changes to handson-cast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open with a card: "Same key, but &lt;code&gt;ssh -J&lt;/code&gt; gets rejected. Why?"&lt;/li&gt;
&lt;li&gt;Cut the wrapper-script typing and the waits between steps&lt;/li&gt;
&lt;li&gt;Type commands out character by character, with a larger font&lt;/li&gt;
&lt;li&gt;Right after the rejection, show the article's diagram full-screen for 4.5 seconds&lt;/li&gt;
&lt;li&gt;Once the output is complete, slowly zoom into the top-left of the terminal&lt;/li&gt;
&lt;li&gt;End with the article's OG image and URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the final cut (v4, 41 s, with audio):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/videos/blog/kimi-k3-video-review-vs-claude/ssh-config-v4.mp4" rel="noopener noreferrer"&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%2Fjpfe4l14qkxuygg0dk46.jpg" alt="Bastion host hands-on video, v4 (41 seconds). Click to open the video." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image to open the mp4 (with Japanese narration).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Frames from the same timestamps, side by side:&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%2F5g8svlj27ufa8cskqh3j.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%2F5g8svlj27ufa8cskqh3j.png" alt="v1 vs v4. Top left: v1 at 1 second, a dark screen with a single half-typed line, bash /lab/ssh-config.step, in the corner. Top right: v4 at 1.8 seconds, a large Japanese headline asking why the same key is rejected with ssh -J, above a client-bastion-target reachability diagram. Bottom left: v1 at 34.5 seconds, small ssh -G output in the top-left corner. Bottom right: v4 at the same time, the same output shown much larger" width="800" height="497"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bastion video went from 46.8 to 41.0 seconds, about 6 seconds shorter. I had K3 grade the improved cut against a 10-item checklist for technical short videos. It covers things like a hook in the first 3 seconds, something changing on screen every 1-3 seconds, and a short call to action only at the end. It scored 7 pass, 2 borderline, 1 pass (estimated). The two borderline items were a frozen stretch after the output appears and an overlong subtitle. Both are fixed now.&lt;/p&gt;

&lt;p&gt;I'd meant to have K3 research that checklist too, but that didn't work. The Kimi API has a built-in web search tool (&lt;code&gt;$web_search&lt;/code&gt;). With K3, though, the second call, the one that returns the search results, failed every time with &lt;code&gt;400 tokenization failed&lt;/code&gt;. With kimi-k2.6 the first search went through, but after that it stopped searching and wrote text that only looked like search calls into its answer. The docs say this feature is scheduled for retirement on 2026-10-20 (&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;). I gave up. Claude's web search did the research, and I passed the results to K3.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'll split the work
&lt;/h2&gt;

&lt;p&gt;Based on this, here's how I plan to use them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;K3 when I need to know where in a video something happens.&lt;/strong&gt; Frozen intervals, the moment the screen gets cut off, how long a caption stays up: questions whose answer is a span of time. I don't have to think about how to sample frames&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Either one for deciding how to fix it.&lt;/strong&gt; The improvement ideas didn't differ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not K3 for anything involving audio.&lt;/strong&gt; For narration content or sync, I prepare a transcript separately and compare against it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No confidential screens.&lt;/strong&gt; The video goes to Moonshot AI's servers. These videos were going public anyway, so that was fine; internal screen recordings need different handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a small comparison: two videos, one set of questions, nothing statistical. Still, it gave me a working answer. Reading video directly didn't change the judgment; it changed timestamp precision and effort. Next I want to plant deliberate defects in a video and count how many each model finds.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/MoonshotAI/Kimi-K3" rel="noopener noreferrer"&gt;MoonshotAI/Kimi-K3 (GitHub)&lt;/a&gt;: model card. 2.8T total parameters, MoE with 104B active per token, 1M context&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-kimi-vision-model" rel="noopener noreferrer"&gt;Kimi API: Vision models&lt;/a&gt;: how video input works&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;: &lt;code&gt;$web_search&lt;/code&gt; and its retirement date&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/bradautomates/claude-video" rel="noopener noreferrer"&gt;bradautomates/claude-video&lt;/a&gt;: the &lt;code&gt;/watch&lt;/code&gt; tool used for Claude's frame extraction&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;charmbracelet/vhs&lt;/a&gt;: terminal recording&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kenimoto.dev/ja/learn/remote-machine-access/" rel="noopener noreferrer"&gt;SSH: five ways to reach a remote machine, compared on seven axes&lt;/a&gt; (Japanese): the /learn/ series these videos were made for&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>video</category>
    </item>
    <item>
      <title>Shai-Hulud: 796 Packages, 1 Yes-Mash, and the Claude Code Auto-Approve Risk</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/shai-hulud-796-packages-1-yes-mash-and-the-claude-code-auto-approve-risk-5f7b</link>
      <guid>https://dev.to/kenimo49/shai-hulud-796-packages-1-yes-mash-and-the-claude-code-auto-approve-risk-5f7b</guid>
      <description>&lt;p&gt;On November 24, 2025, JFrog Security Research disclosed that a self-replicating npm worm had quietly backdoored 796 unique packages. The industry called the second wave &lt;strong&gt;Shai-Hulud 2.0&lt;/strong&gt;, or "The Second Coming." It followed the September 16, 2025 original wave, which had already poisoned over 1,150 packages (&lt;a href="https://jfrog.com/blog/shai-hulud-npm-supply-chain-attack-new-compromised-packages-detected/" rel="noopener noreferrer"&gt;JFrog&lt;/a&gt;, &lt;a href="https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/" rel="noopener noreferrer"&gt;Datadog Security Labs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Over 25,000 GitHub repositories had credentials leaked by the campaign (&lt;a href="https://www.sysdig.com/blog/return-of-the-shai-hulud-worm-affects-over-25-000-github-repositories" rel="noopener noreferrer"&gt;Sysdig&lt;/a&gt;). The 796 is the count of poisoned npm packages. The 25,000 is the count of victim repositories whose tokens and secrets the worm harvested; the attacker-side drop points were separate public repos (random 18-character names, description &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;) created on victim accounts.&lt;/p&gt;

&lt;p&gt;The infection vector was the same thing every developer does fifty times a day. Run a package install. Hit Enter.&lt;/p&gt;

&lt;p&gt;Now add Claude Code, or any other coding agent, with "always allow" turned on for &lt;code&gt;npm install&lt;/code&gt;. The fifty-times-a-day moves to 500-times-a-day. The attacker now needs one of those 500 to land on a compromised version.&lt;/p&gt;

&lt;p&gt;That is the article.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Shai-Hulud 2.0 actually did
&lt;/h2&gt;

&lt;p&gt;The worm's loop was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Infect.&lt;/strong&gt; A victim ran &lt;code&gt;npm install&lt;/code&gt; on a poisoned version of a popular package. The payload fired as a &lt;code&gt;pre-install&lt;/code&gt; hook. (V1 used &lt;code&gt;post-install&lt;/code&gt;; V2 moved it earlier, so it ran even on failed installs.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steal.&lt;/strong&gt; The payload scanned the victim's machine for &lt;code&gt;~/.npmrc&lt;/code&gt; tokens, GitHub tokens from &lt;code&gt;~/.config/gh/&lt;/code&gt;, SSH keys, AWS and GCP credentials, and loose &lt;code&gt;.env&lt;/code&gt; files. Credentials were exfiltrated by creating a public GitHub repository on the victim's account (description stamped &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;) that acted as the drop point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-replicate.&lt;/strong&gt; Using the stolen npm token, the worm published poisoned versions of every package the victim had publish rights to. The victim became the next publisher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist.&lt;/strong&gt; In some cases it enrolled the victim's host as a self-hosted GitHub Actions runner named &lt;code&gt;SHA1HULUD&lt;/code&gt;, so the attacker could execute code on the machine later (&lt;a href="https://www.netskope.com/blog/shai-hulud-2-0-aggressive-automated-one-of-fastest-spreading-npm-supply-chain-attacks-ever-observed" rel="noopener noreferrer"&gt;Netskope&lt;/a&gt;, &lt;a href="https://www.zscaler.com/blogs/security-research/shai-hulud-v2-poses-risk-npm-supply-chain" rel="noopener noreferrer"&gt;Zscaler&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three details matter for the agent-usage angle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The payload ran on install, not on first use. There was no runtime signal.&lt;/li&gt;
&lt;li&gt;The exfiltration channel was public GitHub, not an exotic webhook. Egress filters that block weird DNS wouldn't help.&lt;/li&gt;
&lt;li&gt;The runtime was Bun, not Node. Signature-based scanners trained on Node payloads lagged (&lt;a href="https://www.endorlabs.com/learn/shai-hulud-2-malware-campaign-targets-github-and-cloud-credentials-using-bun-runtime" rel="noopener noreferrer"&gt;Endor Labs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the time npm removed the compromised versions, the token mesh was already seeded. The affected packages had roughly 132 million monthly downloads between them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "always allow" is the exploited behavior
&lt;/h2&gt;

&lt;p&gt;Claude Code's permission prompt is the exact friction Shai-Hulud needs you to remove.&lt;/p&gt;

&lt;p&gt;Default behavior: Claude Code asks before running &lt;code&gt;npm install &amp;lt;pkg&amp;gt;&lt;/code&gt;. You read the package name, you decide. One prompt per install. Annoying when you're installing twelve dependencies for a Next.js app, which is why &lt;code&gt;/permissions&lt;/code&gt; exists and why most Claude Code users have &lt;code&gt;Bash(npm install:*)&lt;/code&gt; on "always allow" by their third week. Mine was on by week two. I know this because I checked after reading the JFrog post.&lt;/p&gt;

&lt;p&gt;The attacker's threat model relies on this. The payload does not need to defeat your judgment. It needs you to never be asked.&lt;/p&gt;

&lt;p&gt;The equivalent risk on other agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers&lt;/strong&gt; with &lt;code&gt;auto-approve: true&lt;/code&gt; for all tools, so a server you installed last month can shell out and you would never see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codex, Aider, or Cursor&lt;/strong&gt; running under &lt;code&gt;--yes&lt;/code&gt; or an auto-run shell wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI&lt;/strong&gt;: a bot account running &lt;code&gt;npm install&lt;/code&gt; without lockfile integrity, auto-publishing on merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every case the pattern is the same. A human decided once, early, then stopped looking. The worm does not need to be smarter than Claude Code. It needs the human in the loop to have left the loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  A defensive checklist that isn't "write a CLAUDE.md"
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md helps against prompt-injection and model behavior. It does not stop a signed, published, poisoned package from running &lt;code&gt;exec&lt;/code&gt; in a pre-install hook. By the time install scripts fire, the model is no longer in the loop.&lt;/p&gt;

&lt;p&gt;The checklist below targets the install surface itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-session, before you say "always allow":&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;/permissions&lt;/code&gt; and read what is on always-allow right now. If &lt;code&gt;Bash(npm:*)&lt;/code&gt; or &lt;code&gt;Bash(*)&lt;/code&gt; is on the list, that is your exposure.&lt;/li&gt;
&lt;li&gt;Scope allow rules to specific commands, not globs. &lt;code&gt;Bash(npm ci)&lt;/code&gt; or &lt;code&gt;Bash(npm install --package-lock-only)&lt;/code&gt; is survivable; &lt;code&gt;Bash(npm install:*)&lt;/code&gt; is not.&lt;/li&gt;
&lt;li&gt;For MCP: review &lt;code&gt;auto-approve&lt;/code&gt; arrays per server. New MCP servers get a sandbox project first, not production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the install layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin everything. &lt;code&gt;package-lock.json&lt;/code&gt; + &lt;code&gt;npm ci&lt;/code&gt; + &lt;code&gt;--ignore-scripts&lt;/code&gt; on CI where you can. Install scripts are the exact Shai-Hulud entrypoint.&lt;/li&gt;
&lt;li&gt;Flip &lt;code&gt;--ignore-scripts&lt;/code&gt; on globally in local dev (&lt;code&gt;npm config set ignore-scripts true&lt;/code&gt;), then whitelist the handful of packages that legitimately need scripts.&lt;/li&gt;
&lt;li&gt;Verify the registry you resolve from (&lt;code&gt;npm config get registry&lt;/code&gt;) and the integrity hashes in your lockfile. The worm published to the real npm registry, so this does not save you from the attack itself, but it does catch tampering between your lockfile and your &lt;code&gt;node_modules&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the detection layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Socket, Snyk, or GitHub Dependabot wired into every repo. Socket in particular flags behavioral risks like install scripts and unusual network calls, and was fast on both Shai-Hulud waves.&lt;/li&gt;
&lt;li&gt;Token hygiene. &lt;code&gt;~/.npmrc&lt;/code&gt; and &lt;code&gt;~/.config/gh/&lt;/code&gt; are what the worm wants. Rotate npm tokens quarterly, use &lt;code&gt;npm token create --read-only&lt;/code&gt; for CI, scope GitHub PATs down to the minimum.&lt;/li&gt;
&lt;li&gt;Watch your account for the Nov 2025 exfiltration markers: public repos with randomly-generated 18-character names and description &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;, workflow files at &lt;code&gt;.github/workflows/discussion.yaml&lt;/code&gt; or &lt;code&gt;formatter_*.yml&lt;/code&gt;, and self-hosted runners registered as &lt;code&gt;SHA1HULUD&lt;/code&gt;. (The V1 wave in Sept 2025 used repos literally named &lt;code&gt;Shai-Hulud&lt;/code&gt; and a workflow at &lt;code&gt;.github/workflows/shai-hulud-workflow.yml&lt;/code&gt;.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the agent layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not leave &lt;code&gt;always allow&lt;/code&gt; on across restarts for &lt;code&gt;Bash&lt;/code&gt;, &lt;code&gt;WebFetch&lt;/code&gt;, or anything that writes files outside the project. Per-session is survivable, forever is not.&lt;/li&gt;
&lt;li&gt;Have the agent list the package name and the latest publish date before any install. A fresh version of a popular package deserves a human look, even when you are tired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are novel. All of them are standard. What Shai-Hulud 2.0 showed is that the "I'll just hit Enter" discount was always being subsidized by someone else's attention budget. The second wave took the subsidy away.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trust, but verify
&lt;/h2&gt;

&lt;p&gt;Reagan used it on arms control. It fits here. Claude Code is in the loop. Your other agents are in the loop. Both are useful. Neither replaces the one-second pause before you widen a trust boundary.&lt;/p&gt;

&lt;p&gt;The industry has, so far, caught Shai-Hulud waves inside 48 hours of publish (&lt;a href="https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem" rel="noopener noreferrer"&gt;CISA alert&lt;/a&gt;, &lt;a href="https://www.trellix.com/blogs/research/npm-account-hijacking-and-the-rise-of-supply-chain-attacks/" rel="noopener noreferrer"&gt;Trellix&lt;/a&gt;). The vulnerability window is small, but it is the exact window your "always allow" paves over.&lt;/p&gt;

&lt;p&gt;For a longer walk-through of Claude Code security patterns, including CLAUDE.md templates and data-retention policy per plan, I pulled these together in &lt;a href="https://kenimoto.dev/books/claude-code-mastery?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=shai-hulud-796" rel="noopener noreferrer"&gt;Practical Claude Code on Kindle&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Deepgram Nova-3 + Cartesia Sonic 3 + GPT-4o: Where 750ms of Voice AI TTFB Actually Hides</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/deepgram-nova-3-cartesia-sonic-3-gpt-4o-where-750ms-of-voice-ai-ttfb-actually-hides-46d3</link>
      <guid>https://dev.to/kenimo49/deepgram-nova-3-cartesia-sonic-3-gpt-4o-where-750ms-of-voice-ai-ttfb-actually-hides-46d3</guid>
      <description>&lt;p&gt;Voice-AI conversations die the moment the agent takes too long to say the first word. Not the full answer. The first byte of audio.&lt;/p&gt;

&lt;p&gt;The industry calls this TTFB (time to first byte), or TTFA (time to first audio). I measure it from the moment the user stops speaking to the moment the first audio frame plays. Everything else, including total answer length and follow-up quality, happens after the user has already decided whether the agent feels alive.&lt;/p&gt;

&lt;p&gt;Below is a ~750ms streaming budget on a Deepgram Nova-3 + GPT-4o + Cartesia Sonic 3 stack, broken into what each hop actually costs. And where almost every vendor-quoted number stops matching the number you will measure in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  1,200ms to 750ms is the easy part
&lt;/h2&gt;

&lt;p&gt;Batch pipelines wait for the full response before TTS starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User stops speaking] → STT → LLM (full answer) → TTS (full) → [play]
                                                                 ^ &amp;gt;1,200ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaming pipelines hand sentence 1 to TTS the moment the LLM emits it, while sentences 2 and 3 are still generating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User stops speaking] → STT → LLM (sentence 1) → TTS (sentence 1) → [play]
                               LLM (sentence 2) → TTS (sentence 2) → [continue]
                               LLM (sentence 3) → TTS (sentence 3) → [continue]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the stack above, the streaming budget breaks down roughly like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;STT (Deepgram Nova-3 or Flux)&lt;/td&gt;
&lt;td&gt;350ms&lt;/td&gt;
&lt;td&gt;includes end-of-turn detection on Flux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM first sentence (GPT-4o)&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;first-token latency, best-case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS first chunk (Cartesia Sonic 3)&lt;/td&gt;
&lt;td&gt;170ms&lt;/td&gt;
&lt;td&gt;first audio frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network round-trips&lt;/td&gt;
&lt;td&gt;80ms&lt;/td&gt;
&lt;td&gt;user ↔ server + server ↔ vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (streaming TTFB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~750ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 1,200ms to 750ms move pays for itself: no code change beyond "do not wait for the full response." The hard part is everything below 750ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  STT: Nova-3 is 300ms, Flux adds end-of-turn for about 260ms
&lt;/h2&gt;

&lt;p&gt;Deepgram's public numbers, cross-referenced with &lt;a href="https://softcery.com/lab/how-to-choose-stt-tts-for-ai-voice-agents-in-2025-a-comprehensive-guide" rel="noopener noreferrer"&gt;Softcery's 2026 comparison&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Nova-3&lt;/strong&gt;: streaming latency under 300ms, 36 languages. General ASR; does not detect end-of-turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Flux&lt;/strong&gt;: around 260ms end-of-turn, English only. Flux folds ASR together with turn detection into a single model and emits &lt;code&gt;StartOfTurn&lt;/code&gt;, &lt;code&gt;EagerEndOfTurn&lt;/code&gt;, &lt;code&gt;EndOfTurn&lt;/code&gt; events (&lt;a href="https://deepgram.com/learn/introducing-flux-conversational-speech-recognition" rel="noopener noreferrer"&gt;Deepgram launch post&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The combination matters. Nova-3 transcribes, but you still need a separate endpointing step to know when the user is done. Flux folds endpointing in, which cuts a 400-500ms round-trip, but costs you multi-language support.&lt;/p&gt;

&lt;p&gt;For reference across other vendors: OpenAI &lt;code&gt;gpt-4o-transcribe&lt;/code&gt; is around 320ms with 100+ languages. AssemblyAI Universal-3 Pro Streaming is under 600ms. ElevenLabs Scribe v2 Realtime is around 150ms with 90 languages.&lt;/p&gt;

&lt;p&gt;Each of these is a vendor-quoted number. None of them is what you will measure from Tokyo over LTE to a US-East endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  LLM: GPT-4o claims 200-400ms, measured 719-1,608ms
&lt;/h2&gt;

&lt;p&gt;This is the hop where the vendor-vs-real gap gets ugly.&lt;/p&gt;

&lt;p&gt;Deepgram's own blog post listing &lt;a href="https://deepgram.com/learn/designing-voice-ai-workflows-using-stt-nlp-tts" rel="noopener noreferrer"&gt;reasons they chose GPT-4o for voice agents&lt;/a&gt; cites a first-token latency of 200-400ms. The same post's measured table, on a MacBook M1 Pro over Wi-Fi, reports GPT-4o first-token times of &lt;strong&gt;719ms to 1,608ms&lt;/strong&gt;. That is 2x to 5x the vendor's number, on a laptop, on consumer Wi-Fi.&lt;/p&gt;

&lt;p&gt;A separate &lt;a href="https://dev.to/cloudx/cracking-the-1-second-voice-loop-what-we-learned-after-30-stack-benchmarks-427"&gt;Dev.to benchmark from CloudX&lt;/a&gt; tested 30+ stacks and found GPT-4.1-nano landing at 0.30-0.40 seconds on 2nd-turn-onward, where the KV cache is warm. First turn is always worse.&lt;/p&gt;

&lt;p&gt;What the numbers teach: first-token latency is dominated by TTFT variance, which is dominated by model size, GPU type, quantization, input length, KV cache hit rate, inference backend load, and cold starts. A table of "model X equals N ms" is strictly less useful than one measurement from your own region to your own endpoint.&lt;/p&gt;

&lt;p&gt;Running a small LLM locally removes the LLM-side network round-trip entirely. On a tuned setup, that is where sub-500ms TTFB actually lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  TTS: Cartesia Sonic 3 says sub-90ms, measured median is 166-190ms
&lt;/h2&gt;

&lt;p&gt;Cartesia's two models get swapped in half the voice-AI blog posts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cartesia Sonic 3&lt;/strong&gt;: sub-90ms (vendor claim, measurement definition undisclosed), 40+ languages, emotion support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cartesia Sonic Turbo&lt;/strong&gt;: 40ms (vendor claim, speed-specialized model), 9 languages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The widely-circulated 40ms number is &lt;strong&gt;Sonic Turbo, not Sonic 3&lt;/strong&gt;. Real Sonic 3 measurements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cartesia's own launch disclosure: median around 190ms.&lt;/li&gt;
&lt;li&gt;Vapi's &lt;a href="https://humannessindex.vapi.ai/models/cartesia-sonic-3" rel="noopener noreferrer"&gt;Humanness Index&lt;/a&gt; (50-run test, round-trip included): median 166ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both numbers are roughly 2x the vendor-quoted sub-90ms. Neither 166 nor 190 is "wrong." They measure different things: vendor model-inference vs client-side round-trip.&lt;/p&gt;

&lt;p&gt;ElevenLabs Flash v2.5 quotes around 75ms, with their own docs noting that 75ms refers to model inference only, and that end-to-end latency will vary with your location and endpoint type (&lt;a href="https://elevenlabs.io/docs/best-practices/latency-optimization" rel="noopener noreferrer"&gt;ElevenLabs latency docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Research on streaming TTS reaches sub-50ms TTFA (&lt;a href="https://arxiv.org/abs/2508.04721" rel="noopener noreferrer"&gt;arXiv 2508.04721&lt;/a&gt;). But the same paper's own end-to-end telecommunications voice agent, with the LLM in the loop, measured 678ms average TTFA. The TTS was fast. The stack still was not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3-level optimization ladder
&lt;/h2&gt;

&lt;p&gt;Treating these numbers as a budget, the ceiling moves in three steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 1, 500ms target:&lt;/strong&gt; fast TTS + sentence-level streaming + streaming STT. This is the "do not wait for the full response" move above; gets you from 1,200ms+ to roughly 500-750ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 2, 300ms target:&lt;/strong&gt; Level 1 plus Flux-style end-of-turn detection, speculative LLM invocation on partial ASR, a smaller LLM, and a TTS whose &lt;em&gt;measured&lt;/em&gt; (not quoted) first byte is under 100ms. The emphasis is on measured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 3, 200ms target:&lt;/strong&gt; edge inference, a unified STT+LLM+TTS model, hardware tuning. At this point you stop composing vendor APIs and start building.&lt;/p&gt;

&lt;p&gt;The 300ms figure is not arbitrary. It is the UX cliff below which a voice agent starts feeling like a human conversation partner, as opposed to a very fast IVR. Everything above 500ms feels like an echo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three rules before you trust any TTFB number in a blog post
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Was the network included?&lt;/strong&gt; ElevenLabs says the quiet part out loud (75ms is model inference only). Most vendor numbers do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which model?&lt;/strong&gt; Cartesia's 40ms is Sonic Turbo; the Sonic 3 you probably want is 2x that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who measured it?&lt;/strong&gt; Vendor, third-party, or user-run on their own hardware? Each of the three routinely disagrees by a factor of 2.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Build your own measurement. Pick the stack above, or three alternatives on each row of the table, point at your actual region, record a 50-run distribution. The p95 is the number you should budget against. The vendor's median is marketing.&lt;/p&gt;




&lt;p&gt;The full chapter this article compresses, including the TTS vendor-vs-measurement table and the 3-level ladder in more detail, is in &lt;a href="https://kenimoto.dev/books/voice-ai-300ms-ux?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=voice-ttfb-750ms" rel="noopener noreferrer"&gt;The 300ms Threshold on Kindle&lt;/a&gt;. The book is built around voice-AI UX cliffs at 300, 500, and 800ms, and how to budget inside them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kernighan, Fowler, Torvalds: 7 Debugging Quotes Every Senior Still Repeats</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/kernighan-fowler-torvalds-7-debugging-quotes-every-senior-still-repeats-4jog</link>
      <guid>https://dev.to/kenimo49/kernighan-fowler-torvalds-7-debugging-quotes-every-senior-still-repeats-4jog</guid>
      <description>&lt;p&gt;Some engineering lines refuse to fade.&lt;/p&gt;

&lt;p&gt;They survive because every senior ends up repeating them in code review, year after year, usually on a Thursday afternoon when a junior ships something that works and shouldn't.&lt;/p&gt;

&lt;p&gt;I spent the last few months decoding a hundred of these for a short Kindle book. Below are seven, from three names that keep surfacing when the topic is debugging: Brian Kernighan, Martin Fowler, Linus Torvalds. Each quote has its original public source. Each one has a modern code-review move I use when I quote it.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. "Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan &amp;amp; P. J. Plauger&lt;/strong&gt;, &lt;em&gt;The Elements of Programming Style&lt;/em&gt;, 2nd ed., ch. 2, 1978.&lt;/p&gt;

&lt;p&gt;Half a century later, this is the single question I ask when a PR shows up with a one-line regex, a nested ternary, or a &lt;code&gt;reduce&lt;/code&gt; that collapses six operations into one line. Not "is it correct." &lt;em&gt;How will you ever debug it?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI-pair-programming era rediscovers this one every quarter. Give Claude Code or Copilot a prompt like "make this shorter" and it will happily compress four lines into one with a currying trick. The code works. Six months later, nobody (human or model) can rewrite the same function from scratch, because the compressed form hides the steps.&lt;/p&gt;

&lt;p&gt;My review rule: if the author needs more than one sentence to explain what the line does, split it. I know, because I've been that author more than once.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. "The most effective debugging tool is still careful thought, coupled with judiciously placed print statements."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan&lt;/strong&gt;, &lt;em&gt;Unix for Beginners&lt;/em&gt;, 1979.&lt;/p&gt;

&lt;p&gt;I used to be embarrassed by &lt;code&gt;println&lt;/code&gt; debugging. Then I watched enough senior engineers reach for it before a debugger that I stopped apologizing.&lt;/p&gt;

&lt;p&gt;The quote still holds, with one update. The tool today is &lt;code&gt;console.log&lt;/code&gt;, &lt;code&gt;fmt.Println&lt;/code&gt;, &lt;code&gt;print()&lt;/code&gt;, or whatever your runtime offers, plus a tail of structured logs. Debuggers help when you can reproduce the bug and the state is static. Print statements help when the bug lives in a flow, in production, or at a boundary the debugger can't easily cross: gRPC, serverless cold start, worker queues, flaky async tests.&lt;/p&gt;

&lt;p&gt;The actual skill the quote names is &lt;strong&gt;careful thought&lt;/strong&gt;. The prints are only useful once you've guessed where to put them.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. "Controlling complexity is the essence of computer programming."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan &amp;amp; P. J. Plauger&lt;/strong&gt;, &lt;em&gt;Software Tools&lt;/em&gt;, p. 319, 1976.&lt;/p&gt;

&lt;p&gt;A junior and a senior will both call code "complex." The junior usually means "I don't understand this yet." The senior usually means "nobody will ever understand this again, including the person who wrote it."&lt;/p&gt;

&lt;p&gt;I keep this quote pinned when reviewing refactors. The question isn't "is this clever." It's "does the next on-call engineer, at 2 a.m., with no context, have a chance?" Most production bugs I've chased started as a cleverness-driven refactor that added coupling the diff didn't name.&lt;/p&gt;

&lt;p&gt;Complexity accrues. Most PRs make it worse. The ones that make it better are rare and should be celebrated.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. "Any fool can write code that a computer can understand. Good programmers write code that humans can understand."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Martin Fowler&lt;/strong&gt;, &lt;em&gt;Refactoring: Improving the Design of Existing Code&lt;/em&gt;, p. 15, Addison-Wesley, 1999.&lt;/p&gt;

&lt;p&gt;This is the one line of Fowler's that engineers who've never read &lt;em&gt;Refactoring&lt;/em&gt; can still recite. Fair, because it compresses the whole book: tests exist so humans can change code; names exist so humans can read it; small methods exist so a human can hold one in their head.&lt;/p&gt;

&lt;p&gt;The habit I lifted from this: whenever I'm tempted to leave a comment explaining &lt;em&gt;what&lt;/em&gt; a block does, I rename or split instead. If the code reads without the comment, the comment was a smell.&lt;/p&gt;

&lt;p&gt;LLM code generators love long comments. Rename the function.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. "If it hurts, do it more often."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Martin Fowler&lt;/strong&gt;, &lt;a href="https://martinfowler.com/bliki/FrequencyReducesDifficulty.html" rel="noopener noreferrer"&gt;martinfowler.com, FrequencyReducesDifficulty&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fowler surfaces this as one of his favorite soundbites. The surface reading sounds like masochism. The real reading: if deploys are painful, deploy daily; if merges are painful, merge every hour; if integration tests are painful, run them on every push.&lt;/p&gt;

&lt;p&gt;The pattern survives the current stack. Painful AI-code reviews? Review each PR as soon as it opens, not once a day in a batch. Painful dependency bumps? Automate them nightly instead of quarterly. Painful incident post-mortems? Hold them within 48 hours while people still remember.&lt;/p&gt;

&lt;p&gt;Every time I've watched a team avoid a painful activity, the pain compounded. Fowler's rule flips the compounding.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. "Talk is cheap. Show me the code."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Linus Torvalds&lt;/strong&gt;, &lt;a href="https://lkml.org/lkml/2000/8/25/132" rel="noopener noreferrer"&gt;Linux Kernel Mailing List, 25 Aug 2000&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Linus wrote this in the middle of a kernel discussion where someone was proposing changes without an implementation. The adage has outlived its original thread.&lt;/p&gt;

&lt;p&gt;Modern application: architecture debates on Slack where no participant has opened the file in question. Design reviews on a tool nobody has prototyped. "We should migrate to X" threads with no benchmark attached. The quote doesn't ban design. It bans debating before anyone has sampled the code. Open a branch. Commit a 30-line proof of concept. Attach the diff to the thread.&lt;/p&gt;

&lt;p&gt;I keep a text expander snippet with this quote. I pull it out maybe once a month, and every time it ends the thread in under an hour.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. "Bad programmers worry about the code. Good programmers worry about data structures and their relationships."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Linus Torvalds&lt;/strong&gt;, Git mailing list, 2006. (&lt;a href="https://lwn.net/Articles/193245/" rel="noopener noreferrer"&gt;git mailing list via LWN, 2006-07-27&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The underrated debugging quote on this list. Most bugs that reach production aren't in the control flow. They're in the shape of the data: a nullable field someone assumed was present, a one-to-many relationship someone modeled one-to-one, a cache that lies about freshness, a queue that quietly drops ordering guarantees.&lt;/p&gt;

&lt;p&gt;When a bug surfaces, the first question I ask is what the data actually looks like right now, and what the code assumed it would look like. More than half the time, the fix is a schema change or a type refinement, and the logic was never wrong. I lost a weekend last year "fixing" logic that was fine — the schema was lying about a nullable column.&lt;/p&gt;

&lt;p&gt;Modern echo: in LLM pipelines, the hardest bugs don't live in the prompt. They live in the context layout the prompt silently depends on: what the retriever returned, in what order, with what metadata.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why these seven, out of a hundred
&lt;/h2&gt;

&lt;p&gt;The engineering world has more quotes than it has reviewed PRs. These seven survive translation across stacks: a 1978 line is still live in a 2026 Claude-Code-generated diff.&lt;/p&gt;

&lt;p&gt;If this slice landed, the full set (100 quotes across 10 chapters — code aesthetics, bugs and failure, design, teams, innovation, learning, craftsmanship, open source, the AI era, and an engineer's life) is in &lt;a href="https://kenimoto.dev/books/engineer-it-quotes?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=debug-7-quotes" rel="noopener noreferrer"&gt;100 Engineering Quotes Decoded&lt;/a&gt;. One decoded quote at a time, so you can read a decoding on a coffee break and bring it into code review the same day.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>career</category>
      <category>productivity</category>
    </item>
    <item>
      <title>5 Code Review Biases: Why Senior PRs Pass Faster Even When They Shouldn't</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/5-code-review-biases-why-senior-prs-pass-faster-even-when-they-shouldnt-4h1j</link>
      <guid>https://dev.to/kenimo49/5-code-review-biases-why-senior-prs-pass-faster-even-when-they-shouldnt-4h1j</guid>
      <description>&lt;p&gt;Meta researchers suspected the bystander effect was slowing their code reviews when a group — rather than one person — got asked to look at a change. They tested it, confirmed it, and shipped BystanderRecRnd, a recommender that assigns a single reviewer so the "someone else will take it" delay stops happening. The paper is worth reading on its own. It is also one of five biases that quietly decide which PRs pass fast and which ones get picked apart, independent of code quality.&lt;/p&gt;

&lt;p&gt;The other four — halo, authority, framing, ownership — show up every day in reviews that look technical from the outside. This piece walks through each one with where it fires, and the mechanism that stops it. Not vigilance. A mechanism. Reminders to "be careful" survive about a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Halo effect: the senior PR passes faster
&lt;/h2&gt;

&lt;p&gt;The halo effect is the oldest finding in applied social psychology, replicated so many times it stopped being news. In code review it shows up as this: the same diff, submitted by a senior and by a new hire, gets different scrutiny. The senior's code is "they probably have a reason." The new hire's is "they haven't learned yet."&lt;/p&gt;

&lt;p&gt;The inverse also fires. An engineer who shipped a famous bug last year gets careful reviews on code that is objectively fine. The halo rots in both directions.&lt;/p&gt;

&lt;p&gt;I have been on both sides of this. The senior PR with a design flaw that got waved through, and the junior-era PR with a reasonable choice that got picked apart line by line. Neither reaction was really about the code.&lt;/p&gt;

&lt;p&gt;The amplifiers in a modern codebase are visible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub commit count, PR frequency, OSS contributions on the sidebar&lt;/li&gt;
&lt;li&gt;Prior employer (ex-FAANG, maintainer of a known OSS project)&lt;/li&gt;
&lt;li&gt;How carefully written the PR description is — people assume the writing style predicts the code&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;p&gt;Hide the author from yourself for the first pass. Open the diff tab, not the PR summary. Form a technical read of the code before you look at who wrote it. The vigilance version — "I will try to be fair" — has been measured and does not work. The structural version — "I read the diff before I see the name" — does.&lt;/p&gt;

&lt;p&gt;Pair it with a fixed checklist (security, performance, test coverage, readability). A checklist does not stop you from being charitable to the senior; it stops you from skipping items for the senior and inventing items for the new hire.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Authority bias: tech leads get LGTM on auto-pilot
&lt;/h2&gt;

&lt;p&gt;Authority bias is subtle because it does not look like a bias. The tech lead's PR gets approved because, in most cases, the tech lead's PR is actually correct. The bias is the margin. It is the fraction of approvals where the reviewer thought "this looks off" and did not comment, because the reviewer assumed the lead had a reason.&lt;/p&gt;

&lt;p&gt;Over a year that margin compounds. The architectural choices that quietly went unchallenged are the ones that survive into legacy debt.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to use it as a reviewee
&lt;/h3&gt;

&lt;p&gt;The flip side is a tool. When you want a proposal to land, borrow authority deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cite the official docs or RFC for your design choice in the PR description&lt;/li&gt;
&lt;li&gt;Reference industry patterns or a specific paper for an architectural change&lt;/li&gt;
&lt;li&gt;Ask a trusted senior for a pre-review, then mention the feedback was already applied&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ceiling of this technique is low. Borrowed authority does not improve the code; it only lowers the friction of the review. Use it where you are confident the proposal is right and the friction is costing you time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mechanism against it
&lt;/h3&gt;

&lt;p&gt;For reviewers: when the author is a senior, switch to question-form comments. "Is this intentional?" lowers the social cost of a reply from the author, so you get the clarification instead of eating the uncertainty. "This is wrong" from a junior to a staff engineer rarely gets sent. "Why this instead of X?" gets sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Bystander effect: reviews that never start
&lt;/h2&gt;

&lt;p&gt;This is the Meta finding. When a review request fans out to a team instead of a person, the review starts later. Everyone assumes someone else will pick it up. Nobody does. The PR sits.&lt;/p&gt;

&lt;p&gt;Meta's team at Facebook rebuilt reviewer recommendation specifically to kill this. One reviewer, named at request time, with the group as fallback. Review start time dropped.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Default to single-reviewer requests. Group requests are a fallback, not the primary mode.&lt;/li&gt;
&lt;li&gt;When you must request a group, name a point person in the first comment. "&lt;a class="mentioned-user" href="https://dev.to/alice"&gt;@alice&lt;/a&gt; primary, &lt;a class="mentioned-user" href="https://dev.to/team"&gt;@team&lt;/a&gt; as backup" removes the ambiguity even if the UI does not.&lt;/li&gt;
&lt;li&gt;A review older than 24 hours gets a direct ping, not a reshare into the group. Resharing distributes the "someone else" button to more people.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Framing effect: same bug, different reaction
&lt;/h2&gt;

&lt;p&gt;Dr. Michaela Greiler, who studied code review feedback at Microsoft and now consults on code review culture, notes that aggressive feedback tends to slow down fixes and drag down the quality of the author's next PR, even when the technical content of the comment is identical to a constructive version.&lt;/p&gt;

&lt;p&gt;The same N+1 bug, two framings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Negative frame&lt;/strong&gt;: "This N+1 query will cause performance problems. Fix it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Positive frame&lt;/strong&gt;: "If you switch this to eager loading, the query count drops from N+1 to 2. On production traffic the difference should be noticeable."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The technical content is identical. The receiver's reaction is not. One sounds like a problem report. The other sounds like a tip. Michael Lynch, formerly of Google, has a longer set of guidelines for code review comments; three of them do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Talk about the code, not the author. "This line can produce a NullPointerException" is reviewable. "You messed up here" is a feelings reply.&lt;/li&gt;
&lt;li&gt;Attach a why. "Change this" without a reason triggers a defensive reply. "Change this because X returns null in the pagination edge case" triggers a fix.&lt;/li&gt;
&lt;li&gt;Use questions where possible. "Could &lt;code&gt;foo&lt;/code&gt; return null here?" opens the conversation. "This is a bug" closes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;p&gt;A review comment template with three fields — "what", "why", "suggestion" — forces the framing. The field with "suggestion" at the end makes the comment hard to leave hostile without actively choosing to.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ownership bias: taking the review as criticism
&lt;/h2&gt;

&lt;p&gt;The last one lives on the receiving side. If you wrote the code five minutes ago, your sense of ownership is at its peak, and every comment reads as a judgment of you, not of the diff. The defensive-reply reflex fires. You start arguing with suggestions that, read a day later, you would just accept.&lt;/p&gt;

&lt;p&gt;I have the Slack history to prove it: suggestions I defended at 11pm, merged at 9am the next morning without changing a single argument against them. The code did not improve overnight. I did.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mechanism
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Submit the PR a few hours after you finish, or the next morning. Ownership fades with distance. Comments read differently after one night of sleep.&lt;/li&gt;
&lt;li&gt;Reframe the artifact. The codebase is a team's shared product, not a portfolio of personal creations. The reframe sounds abstract and feels silly, until the first time you accept a suggestion that an hour ago you would have fought.&lt;/li&gt;
&lt;li&gt;Reviewers: name one thing you liked in the PR before any comments. "The test naming is clean" is not politeness; it signals that the review is about the diff, not an attack on the author. Reviews that are 100% suggestions trip the ownership reflex.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why mechanisms, not reminders
&lt;/h2&gt;

&lt;p&gt;Across all five, the pattern is the same. Reminders to "be aware" of a bias do not survive the next sprint. Mechanisms do. Hiding the author on the first pass is a mechanism. A single-reviewer default is a mechanism. A comment template with a "suggestion" field is a mechanism. Submitting a PR the next morning is a mechanism.&lt;/p&gt;

&lt;p&gt;Code review is a technical filter and a social situation at the same time, and the social half is the one that decides whether the technical half ever lands. If you want the full set — 20 biases that distort estimates, meetings, and hiring, each with a mechanism rather than a reminder — the book collects them in one place:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/engineer-psychology-tricks?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=5-code-review-biases" rel="noopener noreferrer"&gt;Cognitive Bias for Software Engineers&lt;/a&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>codenewbie</category>
      <category>webdev</category>
      <category>codequality</category>
    </item>
    <item>
      <title>7 Steps to Ship a Knowledge Graph on Neo4j: Step 3 Ate Both My Weekends</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/7-steps-to-ship-a-knowledge-graph-on-neo4j-step-3-ate-both-my-weekends-3pih</link>
      <guid>https://dev.to/kenimo49/7-steps-to-ship-a-knowledge-graph-on-neo4j-step-3-ate-both-my-weekends-3pih</guid>
      <description>&lt;p&gt;Neo4j's official framework for building a knowledge graph is seven steps long. It reads like a well-behaved playbook: define the use case, pick your sources, design the ontology, model the data, ingest, query, operate. If you were reviewing it on a slide it would look completely reasonable.&lt;/p&gt;

&lt;p&gt;Then you sit down on a Friday night to actually build one, and Step 3 quietly reaches out and eats the whole weekend.&lt;/p&gt;

&lt;p&gt;Twice.&lt;/p&gt;

&lt;p&gt;The first time I built a knowledge graph on Neo4j, the ontology-design step ate two full weekends before I could even start writing the first Cypher &lt;code&gt;CREATE&lt;/code&gt; statement. Steps 1, 2, 4, 5, 6, and 7 combined took less time than Step 3 by itself. That is not because the other six steps are trivial. It is because Step 3 is where every unresolved assumption from Steps 1 and 2 comes home to be reconciled, and you cannot fake your way past it.&lt;/p&gt;

&lt;p&gt;Below is the 7-step arc as Neo4j teaches it, with what actually happens between the diagrams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 steps, at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What Neo4j calls it&lt;/th&gt;
&lt;th&gt;What actually happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Define the use case&lt;/td&gt;
&lt;td&gt;You pick one query you want fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Identify data sources&lt;/td&gt;
&lt;td&gt;You classify structured / semi-structured / unstructured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Design the ontology&lt;/td&gt;
&lt;td&gt;You loop back to Step 1 three times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Model the data&lt;/td&gt;
&lt;td&gt;You write Cypher and remember indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Ingest and transform&lt;/td&gt;
&lt;td&gt;LOAD CSV, APOC, LLM extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Build queries and APIs&lt;/td&gt;
&lt;td&gt;Cypher becomes your business logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Operate and extend&lt;/td&gt;
&lt;td&gt;Freshness, quality monitoring, schema evolution&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Steps 1 and 2 look easy on paper. Step 3 is the interesting one. Steps 4-7 are where the real technology sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: define the use case (or watch the whole project rot)
&lt;/h2&gt;

&lt;p&gt;The bad version of a use case:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We want to put all our internal data into a knowledge graph."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The good version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A new engineer needs to know, in 30 seconds, which services depend on &lt;code&gt;/api/users&lt;/code&gt; before they change it."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What separates the two versions is not scope but &lt;em&gt;specificity&lt;/em&gt;. A specific query pins down three things that would otherwise stay loose for months: which entities you need as nodes, which relationships you need as edges, and which query pattern has to be fast.&lt;/p&gt;

&lt;p&gt;The industry has a graveyard full of KG projects that started with "let us model everything." Neo4j's own guidance is blunt about this: if the use case is not concrete, the graph is decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: identify data sources (structured, semi-structured, unstructured)
&lt;/h2&gt;

&lt;p&gt;The classification is the whole step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured&lt;/strong&gt; — CSVs, relational tables, customer masters, product catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semi-structured&lt;/strong&gt; — JSON API responses, config files, OpenAPI specs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unstructured&lt;/strong&gt; — PDFs, meeting notes, source code, chat logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason this classification matters is that it decides how you get into Step 5. Structured data walks into Neo4j through &lt;code&gt;LOAD CSV&lt;/code&gt;. Semi-structured comes in via APOC. Unstructured needs an LLM extractor between the source and the graph.&lt;/p&gt;

&lt;p&gt;In September 2026 the LLM-extractor side of this has become a solved product problem: Neo4j's own &lt;a href="https://neo4j.com/labs/genai-ecosystem/llm-graph-builder/" rel="noopener noreferrer"&gt;LLM Knowledge Graph Builder&lt;/a&gt; requires Neo4j 5.23+ and now supports OpenAI, Gemini, Claude, Llama 3, Amazon Nova, Diffbot, and Qwen as the extraction model. The &lt;code&gt;neo4j-graphrag-python&lt;/code&gt; SimpleKGPipeline turns on structured-output extraction when the LLM supports it, which cuts the "the model returned invalid JSON" retries you would otherwise write yourself. If you are starting today, you build the extractor last and use these tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: ontology design (the one that ate the weekends)
&lt;/h2&gt;

&lt;p&gt;This is where the trouble happens. On paper, an ontology looks like a tiny document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node labels:
  - Service (name, version, team)
  - API (path, method, status)
  - Developer (name, email, team)
  - Repository (name, url, language)

Edge types:
  - EXPOSES:    Service -&amp;gt; API
  - CALLS:      API -&amp;gt; API
  - MAINTAINS:  Developer -&amp;gt; Service
  - HOSTED_IN:  Service -&amp;gt; Repository
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is maybe 15 lines. How did that eat two weekends?&lt;/p&gt;

&lt;p&gt;Because writing those 15 lines forces you to answer questions Step 1 quietly deferred. Is a deployed instance of a service a &lt;code&gt;Service&lt;/code&gt; or a &lt;code&gt;Deployment&lt;/code&gt;? Is a &lt;code&gt;Repository&lt;/code&gt; really one node, or one per branch? Does a &lt;code&gt;MAINTAINS&lt;/code&gt; edge point from the developer to the service or the other way around, and does the direction change what queries become expensive?&lt;/p&gt;

&lt;p&gt;The moment you start writing Cypher against a draft ontology, you find edges that need properties, node labels that need to split in two, and relationships whose direction you had wrong. You go back to Step 1, reread the use case, and change Step 3. Then you go back to Step 3 and change it again. The rough pattern I ended up with: Step 3 makes you loop back to Step 1 about three times before it settles.&lt;/p&gt;

&lt;p&gt;The lesson I wish I had absorbed earlier:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do not design the ontology in isolation. Sketch it in the same file as three sample Cypher queries you actually plan to run. If any of the three cannot be written cleanly, the ontology is wrong, not the query.&lt;/li&gt;
&lt;li&gt;Start small. Two node labels and one edge type is a legitimate first iteration. Expanding is easy; unpicking is a weekend.&lt;/li&gt;
&lt;li&gt;Read edges as sentences. "Service EXPOSES API." "Developer MAINTAINS Service." If you cannot say the edge out loud without it sounding weird, the direction is off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I would also, in retrospect, have used Neo4j's Graph Consolidation feature earlier. It rolls a sprawling schema into fewer, more meaningful node labels and relationship types automatically. On a first attempt this is exactly the kind of judgement I did not yet have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: data modelling in Cypher
&lt;/h2&gt;

&lt;p&gt;Once the ontology settles, Step 4 is mechanical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"UserAPI"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version:&lt;/span&gt; &lt;span class="s2"&gt;"2.1"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;team:&lt;/span&gt; &lt;span class="s2"&gt;"Platform"&lt;/span&gt;&lt;span class="ss"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"AuthService"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;version:&lt;/span&gt; &lt;span class="s2"&gt;"1.5"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;team:&lt;/span&gt; &lt;span class="s2"&gt;"Security"&lt;/span&gt;&lt;span class="ss"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:API&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;path:&lt;/span&gt; &lt;span class="s2"&gt;"/api/users"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;method:&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;status:&lt;/span&gt; &lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="ss"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"UserAPI"&lt;/span&gt;&lt;span class="ss"&gt;}),&lt;/span&gt;
      &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;     &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;path:&lt;/span&gt; &lt;span class="s2"&gt;"/api/users"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:EXPOSES&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;since:&lt;/span&gt; &lt;span class="s2"&gt;"2024-01-15"&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s.name&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a.path&lt;/span&gt;&lt;span class="ss"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most common mistake at this step: forgetting the indexes. A KG with millions of nodes and no index on the query's start-node property will take seconds per query, and you will spend a week thinking your ontology is wrong when the real problem is a &lt;code&gt;CREATE INDEX&lt;/code&gt; line you never wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: ingestion and transformation
&lt;/h2&gt;

&lt;p&gt;For structured data, &lt;code&gt;LOAD CSV WITH HEADERS&lt;/code&gt; is the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="k"&gt;CSV&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;HEADERS&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'file:///services.csv'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;name:&lt;/span&gt;    &lt;span class="n"&gt;row.name&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="py"&gt;version:&lt;/span&gt; &lt;span class="n"&gt;row.version&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="py"&gt;team:&lt;/span&gt;    &lt;span class="n"&gt;row.team&lt;/span&gt;
&lt;span class="ss"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For unstructured data, the modern move is to hand the LLM a schema-constrained extraction prompt rather than a free-form one. &lt;code&gt;SimpleKGPipeline&lt;/code&gt; in &lt;code&gt;neo4j-graphrag-python&lt;/code&gt; does this for you when the underlying model supports structured output; you get JSON that conforms to your ontology rather than JSON that hopes to. The retries you save at this step add up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: queries and APIs
&lt;/h2&gt;

&lt;p&gt;This is where the graph starts paying rent. The three query shapes I ended up using most:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius — what breaks if I change this API?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;target:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;path:&lt;/span&gt; &lt;span class="s2"&gt;"/api/users"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CALLS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;caller:&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:EXPOSES&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;s.name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;affected_service&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;caller.path&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;via_api&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Shortest path between two services.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shortestPath&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"UserAPI"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;b:&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"PaymentService"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Community detection — which services cluster together?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;gds.louvain.stream&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'service-graph'&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;YIELD&lt;/span&gt; &lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;communityId&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;gds.util.asNode&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodeId&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="n"&gt;.name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;communityId&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;communityId&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cypher is where a knowledge graph stops being a nice diagram and starts being business logic. Those three shapes cover most of what you actually reach for once the graph is live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: operate and extend
&lt;/h2&gt;

&lt;p&gt;The step nobody wants to write about, because it does not screenshot well.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Freshness.&lt;/strong&gt; The source data changes; your graph does not, unless you have a sync pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality.&lt;/strong&gt; Orphan nodes and duplicate entities creep in. Schedule a job to find and merge them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema evolution.&lt;/strong&gt; New use cases mean new node labels and new edges. Version your ontology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control.&lt;/strong&gt; Not every team should see every subgraph.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neo4j AuraDB shoulders most of the infrastructure side of this, which is why a lot of small teams end up on Aura even if they started on a self-hosted instance. The trade is real: less flexibility, less ops.&lt;/p&gt;

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

&lt;p&gt;If I were starting over today with the same use case:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the three killer queries first, before the ontology.&lt;/strong&gt; The queries constrain the ontology far better than the ontology constrains the queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the first ontology at two node types and one edge type.&lt;/strong&gt; Expand only when a query forces the expansion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;SimpleKGPipeline&lt;/code&gt; (or the LLM Knowledge Graph Builder if you want a UI) instead of hand-writing extraction prompts.&lt;/strong&gt; The structured-output layer alone saves days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not skip the indexes.&lt;/strong&gt; Ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget for Step 3 to take longer than Steps 1, 2, 4, 5 combined.&lt;/strong&gt; If it does not, either your use case is unusually narrow or you are about to find out you missed something.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 7-step arc is honest. It is the &lt;em&gt;shape of the effort&lt;/em&gt; inside each step that the diagram does not capture. Once you know Step 3 is going to be the weekend eater, you can plan around it — you sketch queries alongside the ontology, you keep the first pass small, and you accept that "the graph is designed" is a claim you cannot make until you have written Cypher against it.&lt;/p&gt;

&lt;p&gt;If you want the longer version, my Zenn book &lt;a href="https://kenimoto.dev/books/knowledge-graph-practical-guide?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=neo4j-kg-7-steps" rel="noopener noreferrer"&gt;Knowledge Graph Practical Guide&lt;/a&gt; walks the full arc, and Chapter 4 is exactly this 7-step build — including the ontology-loop story that turned my weekends into a lesson.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Neo4j — &lt;a href="https://neo4j.com/labs/genai-ecosystem/llm-graph-builder/" rel="noopener noreferrer"&gt;LLM Knowledge Graph Builder (Neo4j Labs)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Neo4j — &lt;a href="https://neo4j.com/blog/developer/llm-knowledge-graph-builder-release/" rel="noopener noreferrer"&gt;LLM knowledge graph builder — First release of 2025&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Neo4j — &lt;a href="https://neo4j.com/docs/neo4j-graphrag-python/current/user_guide_kg_builder.html" rel="noopener noreferrer"&gt;User Guide: Knowledge Graph Builder (neo4j-graphrag-python)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Neo4j Labs — &lt;a href="https://github.com/neo4j-labs/llm-graph-builder/releases" rel="noopener noreferrer"&gt;llm-graph-builder GitHub releases&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Local LLM vs Claude Code: 96% of My Requests Failed Locally. Half the Steps Didn't.</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 29 Sep 2026 19:53:48 +0000</pubDate>
      <link>https://dev.to/kenimo49/local-llm-vs-claude-code-96-of-my-requests-failed-locally-half-the-steps-didnt-1600</link>
      <guid>https://dev.to/kenimo49/local-llm-vs-claude-code-96-of-my-requests-failed-locally-half-the-steps-didnt-1600</guid>
      <description>&lt;p&gt;The pitch for running Claude Code on a local model goes like this: point it at Ollama, stop paying per token, keep your code on your machine. I have an RTX 4070 with &lt;code&gt;qwen3.5:4b&lt;/code&gt; on it, so I wanted that to be true.&lt;/p&gt;

&lt;p&gt;Before swapping anything, I counted. I took 100 requests I had actually sent to Claude Code and asked, for each one, whether a local model could have done it end to end.&lt;/p&gt;

&lt;p&gt;96 could not. So much for cancelling the subscription.&lt;/p&gt;

&lt;p&gt;Then I counted a different way, and the answer flipped. This post is about the second count and the small config file that came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting whole requests: 96 of 100 go to the frontier
&lt;/h2&gt;

&lt;p&gt;A request to Claude Code is usually one line. "Fix the failing test in the billing module." "Why is this deploy slow?" The request itself contains very little context. The work happens after that, when the agent reads files, runs commands, fetches docs, and decides what to try next.&lt;/p&gt;

&lt;p&gt;So when you judge the request as a whole, you are judging the hardest part of it: the planning. That part needs the frontier model almost every time. In my set, 96 of the 100 had to go there.&lt;/p&gt;

&lt;p&gt;If you route at the request level, a local model gets almost nothing. Swapping the whole agent onto a local model asks it to do exactly that planning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting single steps: 97 of 200 run fine locally
&lt;/h2&gt;

&lt;p&gt;The agent's work is a chain of steps: tool calls, fetches, summaries, commit messages. I broke Claude Code sessions into 200 of those steps and asked the same question for each one.&lt;/p&gt;

&lt;p&gt;97 of 200 were fine on a local model.&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%2Fq852gmze1hevd3va8v1l.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%2Fq852gmze1hevd3va8v1l.png" alt="Dot chart: 4 of 100 whole requests could run on a local LLM, versus 97 of 200 single steps" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The clearest case was &lt;code&gt;WebFetch&lt;/code&gt;: fetch a page, pull out the part that answers a question. That is extraction, not planning. I tried it on 20 real fetches, and it was not perfect: some pages never loaded, and some answers came back partly wrong. That is why the example config sends a failed fetch to the frontier (&lt;code&gt;on_fetch_error: frontier&lt;/code&gt;). But when the page arrived, the local model usually got the extraction right.&lt;/p&gt;

&lt;p&gt;Claude Code already does a version of this. Its own &lt;code&gt;WebFetch&lt;/code&gt; tool description says the fetched page is processed with a small, fast model. That makes the tool itself an example of choosing a model per step.&lt;/p&gt;

&lt;h2&gt;
  
  
  One rule beat a 9B router
&lt;/h2&gt;

&lt;p&gt;My first router asked a local model for a probability: "should this step go local?" It worked, sort of. Then I wrote a one-line rule, "WebFetch goes local", and the rule beat the 9B model's probability.&lt;/p&gt;

&lt;p&gt;That changed the design. Rules decide what rules can decide. When enabled, the probability judge only sees what is left. Here is the rules block from the companion repo's example config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# step rules, top to bottom, first match wins&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;WebFetch&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Agent&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;commit_message&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;         &lt;span class="c1"&gt;# frontier until a probability judge is added&lt;/span&gt;

&lt;span class="na"&gt;probability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;           &lt;span class="c1"&gt;# off until measured with the same judge model&lt;/span&gt;
&lt;span class="na"&gt;default_route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probability judge is &lt;strong&gt;off by default&lt;/strong&gt;. The routing function is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;probability&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_threshold&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;probability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_threshold&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frontier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my config, sub-agents (&lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;Task&lt;/code&gt;) are pinned to the frontier.&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%2Ft240dpx0e6357yex1hbp.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%2Ft240dpx0e6357yex1hbp.png" alt="Routing table checked top to bottom: WebFetch goes local, Agent and Task go to the frontier, commit messages go to the frontier, the probability judge is skipped, anything else defaults to the frontier" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a probability out of Ollama
&lt;/h2&gt;

&lt;p&gt;For the steps rules can't settle, you want a number, not a paragraph. The trick is to make the model answer with one letter (Y or N) and read that letter's probability from &lt;code&gt;logprobs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the request body I send to Ollama's native &lt;code&gt;/api/chat&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"qwen3.5:4b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stream"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"think"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"logprobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"top_logprobs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"options"&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="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"num_predict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"messages"&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="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"... Answer with a single letter, Y or N."&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;Three things fail silently here, with no error:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The OpenAI-compatible endpoint drops the probabilities.&lt;/strong&gt; Use the native API. The repo's scripts expect Ollama 0.12.11 or later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thinking models don't answer in the first token.&lt;/strong&gt; The first token is the start of their reasoning, so the Y/N you are looking for isn't there. &lt;code&gt;think: false&lt;/code&gt; and &lt;code&gt;num_predict: 1&lt;/code&gt; pin it to the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A candidate outside the top 20 looks like probability 0.&lt;/strong&gt; Also, &lt;code&gt;" Y"&lt;/code&gt; and &lt;code&gt;"Y"&lt;/code&gt; are different tokens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix for the third one is to add up the probability that landed on your candidate letters and refuse to trust the judgment when that total is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lab&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lab&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logprobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_logprobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# " Y" and "Y" are the same answer
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logprob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;mass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c1"&gt;# below 0.5: the answer wasn't where we looked
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  0.5 is a line nobody chose
&lt;/h2&gt;

&lt;p&gt;I used the same kind of judge as a gate in front of Claude Code: "does this request contain a secret?" It read a note with a production database password in it and said it was fine, with 69% confidence. The password was there with 100% confidence.&lt;/p&gt;

&lt;p&gt;The ranking was fine. Secrets scored higher than non-secrets. The scale was off: real secrets were landing well below 0.5. Cut at 0.5, the gate missed 20 secrets. When I drew the line from my own labelled examples instead, it missed 2.&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%2F45s2mbt2c4uv1r07l1na.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%2F45s2mbt2c4uv1r07l1na.png" alt="A local secrets gate cut at 0.5 missed 20 secrets; with a line drawn from my own labelled examples it missed 2" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is why the gate threshold in the example config looks strange:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;judge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen3.5:4b&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.0071&lt;/span&gt;    &lt;span class="c1"&gt;# example only. lowest P among your confidential examples x 0.3&lt;/span&gt;
  &lt;span class="na"&gt;at_or_above&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;block&lt;/span&gt;
&lt;span class="na"&gt;on_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;block&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0.0071&lt;/code&gt; is only an example from my setup, so don't copy it. The repo has a small CLI (&lt;code&gt;steprouter-threshold&lt;/code&gt;) that takes your labelled judgments and prints a line, AUROC, and 5-fold cross-validated misses and false alarms.&lt;/p&gt;

&lt;p&gt;A regex pass (gitleaks plus personal-data patterns) runs before the model. It catches secrets that have a recognizable shape. The model is there for the ones that don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free local worker cost the most
&lt;/h2&gt;

&lt;p&gt;One more result surprised me. I set up a strong model to plan and a free local model to do the work. That setup was the most expensive one I tested. Free, the way a puppy is free.&lt;/p&gt;

&lt;p&gt;The local model's tokens were free. The orchestrator re-reading everything the worker sent back was not. What comes back from a routed step is part of the routing, so the example config pins the return value to a small typed shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;return&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;green&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mypy&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ruff&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pytest_failed&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;summary_max_lines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;full_log&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass/fail counts, the files touched, and one or two lines of what was done. The full log is written to a file instead of the reply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own logs
&lt;/h2&gt;

&lt;p&gt;My numbers come from my work. To get yours:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take a day of Claude Code sessions and list the steps, not the requests.&lt;/li&gt;
&lt;li&gt;Mark which step types always go one way. Those become rules.&lt;/li&gt;
&lt;li&gt;Leave the probability judge off until you have measured it on your own examples.&lt;/li&gt;
&lt;li&gt;Before you add a local worker, decide what it is allowed to send back.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The companion code, including the gate, the logprobs judge, the threshold CLI, the router, and a made-up evaluation set, is on GitHub as &lt;a href="https://github.com/kenimo49/local-step-router" rel="noopener noreferrer"&gt;local-step-router&lt;/a&gt; (MIT). Clone it, run &lt;code&gt;pip install -e .&lt;/code&gt;, and start by editing &lt;code&gt;examples/routing.yaml&lt;/code&gt;. The README has an English summary at the bottom.&lt;/p&gt;




&lt;p&gt;The full measurement (550 of my own requests and steps on one RTX 4070, how big the judge model needs to be from 0.8B to 35B, why none of my six real secrets matched the evaluation set, and the final blueprint) is written up in &lt;a href="https://kenimoto.dev/books/local-llm-step-routing?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=local-llm-vs-cc-96" rel="noopener noreferrer"&gt;Local LLM or Claude Code?&lt;/a&gt;. Chapters 2 and 3 cover the step count and the rules-first router; chapter 8 is the threshold worksheet.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>llm</category>
      <category>agents</category>
    </item>
    <item>
      <title>200ms Humans vs 700ms Voice Agents: What ACL IWSDS 2025 Says About the Turn-Taking Gap</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/200ms-humans-vs-700ms-voice-agents-what-acl-iwsds-2025-says-about-the-turn-taking-gap-2iap</link>
      <guid>https://dev.to/kenimo49/200ms-humans-vs-700ms-voice-agents-what-acl-iwsds-2025-says-about-the-turn-taking-gap-2iap</guid>
      <description>&lt;p&gt;The complaint that keeps showing up in every voice-agent beta test I've watched is not about the wrong answer. It's about the &lt;em&gt;timing&lt;/em&gt; of the right answer.&lt;/p&gt;

&lt;p&gt;"The AI cut me off." "It answered while I was still thinking." "I said 'um' and it started talking." Latency looks like a stopwatch problem, but users experience it as a manners problem, which is a much harder thing to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 200ms vs 700ms gap the field is trying to close
&lt;/h2&gt;

&lt;p&gt;The ACL IWSDS 2025 survey on turn-taking in spoken dialogue puts a concrete number on the mismatch. In natural human conversation, a listener starts their reply roughly &lt;strong&gt;200ms&lt;/strong&gt; after the speaker's turn ends. Current dialogue agents take &lt;strong&gt;700 to 1000ms&lt;/strong&gt; to do the same thing.&lt;/p&gt;

&lt;p&gt;Three to five times slower. If human dialogue is a tennis rally, current voice AI is playing chess by post.&lt;/p&gt;

&lt;p&gt;Closing that gap is not one problem, it's two. And they pull in opposite directions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Too slow to reply&lt;/strong&gt;: awkward silence, dropped rapport, users start repeating themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too fast to reply&lt;/strong&gt;: the agent talks over the user, interrupts mid-word, kills trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both fail modes come from the same root cause: silence is not the same as end-of-turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why plain VAD is not enough
&lt;/h2&gt;

&lt;p&gt;Most current systems still decide the user is done talking with a &lt;strong&gt;Voice Activity Detector&lt;/strong&gt;. VAD is cheap and fast, and it answers exactly one question: is there voice in this audio frame, yes or no.&lt;/p&gt;

&lt;p&gt;That question is not the one the agent actually needs answered.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The 0.3–0.5s pause a speaker takes mid-sentence looks identical to end-of-turn.&lt;/li&gt;
&lt;li&gt;The silence after "uh" or "let me think" looks identical to end-of-turn.&lt;/li&gt;
&lt;li&gt;A cough or a sigh looks like voice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can tune the silence threshold, but you cannot tune your way out of the fact that VAD does not know what a sentence is. VoiceInfra's production data lands on &lt;strong&gt;300–500ms&lt;/strong&gt; of trailing silence as the least-bad setting: shorter and the agent chops off natural pauses, longer and the delay becomes obvious. The comfortable range depends on the use case. Call-center flows tolerate 400–500ms because callers pause to think; command interfaces want 200–300ms because utterances are short; long-form narration wants 500–600ms because the pauses inside the story are longer.&lt;/p&gt;

&lt;p&gt;The threshold is a compromise, not a fix. The real fix has to know what the words &lt;em&gt;mean&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantic endpointing: Deepgram Flux and the "one model does both" bet
&lt;/h2&gt;

&lt;p&gt;The interesting move in 2026 is folding transcription and turn detection into the same model. Deepgram's &lt;strong&gt;Flux&lt;/strong&gt;, launched as their first ASR built for voice agents rather than for general transcription, does exactly that. The model outputs turn boundaries directly from audio, using the same joint architecture it uses to output text. Because the same weights see both the acoustic signal and the emerging transcript, the model has a shot at answering the harder question: "is this utterance semantically complete?"&lt;/p&gt;

&lt;p&gt;Flux exposes three knobs that make the tradeoff explicit rather than hidden inside a silence threshold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eot_threshold&lt;/code&gt; — how confident the model has to be before it commits to end-of-turn.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eager_eot_threshold&lt;/code&gt; — a lower bar for tentative end-of-turn, useful for starting inference speculatively.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eot_timeout_ms&lt;/code&gt; — the maximum silence duration before the model forces end-of-turn even if the confidence never crosses the threshold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also switch modes per-turn with a Configure message, so a barge-friendly interaction ("agent, wait") and a monologue turn ("read me the terms") do not have to share a threshold.&lt;/p&gt;

&lt;p&gt;The pattern is a hint at where the field is going. Turn-taking will stop being a pipeline stage after ASR and start being a joint output of the ASR itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Krisp's 6M-parameter turn model: same idea, edge-shaped
&lt;/h2&gt;

&lt;p&gt;Not everyone can afford a cloud round-trip on every silence check. Krisp ships two small audio-only models trained to handle the acoustic cues a naive VAD collapses — a &lt;strong&gt;9M-parameter&lt;/strong&gt; turn prediction model (~30MB) that scores end-of-turn from prosody and pausing patterns, and a &lt;strong&gt;6M-parameter&lt;/strong&gt; interruption model (~24MB) that separates real barge-ins from backchannels like "yeah" and "mhm". Together they cover the same failure modes VAD misclassifies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;intentional speech vs a thinking pause&lt;/li&gt;
&lt;li&gt;filler words (um, uh, well) mid-sentence&lt;/li&gt;
&lt;li&gt;backchannels vs interruptions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that size it runs on-device in real time, which matters for privacy-sensitive deployments and for wearables where the round-trip to the cloud is itself the biggest source of latency.&lt;/p&gt;

&lt;p&gt;The Krisp and Flux paths look opposite. One shrinks the model, the other gives the ASR the extra job. But they are attacking the same VAD failure mode from opposite sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graceful abort: what to do when you're wrong
&lt;/h2&gt;

&lt;p&gt;No detector, semantic or acoustic, is going to be right every time. The interesting question is what your pipeline does when it decides "the user is done" and turns out to be wrong.&lt;/p&gt;

&lt;p&gt;Twilio's &lt;strong&gt;graceful abort&lt;/strong&gt; pattern is the cleanest version I've seen written up. When STT signals end-of-turn early, the LLM starts generating a response. If fresh audio arrives before the response reaches the speaker, the pipeline kills the generation in flight and swallows the partial output. The window for this is the few hundred milliseconds it takes STT → LLM → TTS to produce audible speech. If you can revoke the guess inside that window, the user never hears it.&lt;/p&gt;

&lt;p&gt;The economics matter here: you are paying for LLM tokens on turns that get thrown away. In exchange, you get to be aggressive on end-of-turn detection without punishing the user when you're aggressive-and-wrong. For most conversational products that tradeoff is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Barge-in: hearing the user through your own voice
&lt;/h2&gt;

&lt;p&gt;The other half of turn-taking is the reverse case: the user interrupts while the agent is still speaking. Detecting that seems trivial until you realize the AI's own audio is leaking into the microphone, and any naive VAD will happily flag that leak as a barge-in and abort the agent's own utterance.&lt;/p&gt;

&lt;p&gt;Sparkco's write-up on duplex barge-in handling is the clearest description of the fix I've read. Three moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Full-duplex audio&lt;/strong&gt; — keep the mic hot the entire time the agent is speaking, never gate it on TTS output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Echo cancellation&lt;/strong&gt; — subtract the speaker output (as a reference signal) from the mic input, so what remains is the user's voice minus the agent's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nuisance rejection&lt;/strong&gt; — filter environmental noise and short transients that a bare VAD would misclassify as speech onsets.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[agent is speaking]
  speaker out ──► reference signal ─┐
                                    ▼
  mic in ─────► echo canceller ────► user voice only ──► VAD / turn model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without echo cancellation you get the failure mode users describe as "the AI got startled by its own voice and stopped talking." The dog scared of its own reflection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bot-feel paradox and the 200-300ms delay trick
&lt;/h2&gt;

&lt;p&gt;Now the counterintuitive move. Once you've closed the acoustic and semantic gaps and the agent &lt;em&gt;can&lt;/em&gt; reply in 300ms, it turns out that replying in 300ms feels &lt;em&gt;worse&lt;/em&gt;. The response arrives before the user has finished processing their own sentence, and it registers as robotic rather than sharp.&lt;/p&gt;

&lt;p&gt;The fix is to inject &lt;strong&gt;200–300ms of intentional delay before the agent starts speaking, while continuing to run the LLM in the background&lt;/strong&gt;. You get the "thinking for a moment" cue humans read as attention, without paying real latency for it. The tokens are already streaming; you're just holding the TTS start.&lt;/p&gt;

&lt;p&gt;It's a UX trick, not a technical one. But it's the piece of the puzzle that gets forgotten when a team spends a quarter shaving milliseconds off ASR and then wonders why user ratings didn't move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually closes the 500ms gap
&lt;/h2&gt;

&lt;p&gt;There is no single component that takes you from 700ms to 200ms. You get there by stacking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it buys you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Semantic endpointing (Flux-class)&lt;/td&gt;
&lt;td&gt;Stops chopping mid-sentence, stops waiting for silence that already means end-of-turn.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small on-device turn + interruption models (Krisp-class)&lt;/td&gt;
&lt;td&gt;Score end-of-turn and separate backchannels from real barge-ins without a round-trip.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Graceful abort&lt;/td&gt;
&lt;td&gt;Lets you be aggressive on early end-of-turn without punishing the user when wrong.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplex barge-in with AEC&lt;/td&gt;
&lt;td&gt;Lets you leave the mic hot without the agent interrupting itself.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200–300ms intentional delay&lt;/td&gt;
&lt;td&gt;Buys back the "thinking" cue that pure speed removes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The move that unlocks the stack is admitting that end-of-turn is a language problem, not a silence problem. Once you accept that, VAD-only pipelines look the way pre-BERT NLP pipelines look now: perfectly reasonable for their era, obviously incomplete in retrospect.&lt;/p&gt;

&lt;p&gt;The gap to 200ms is not going to close by tuning thresholds. It closes when the model that hears the audio is the same model that understands the sentence.&lt;/p&gt;




&lt;p&gt;The full 300ms-UX playbook (latency budgets by pipeline stage, when to pick Whisper vs Deepgram vs Piper, and how to design the wait states so users forgive the last 200ms you can't remove) is written up in &lt;a href="https://kenimoto.dev/books/voice-ai-300ms-ux?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=turn-taking-200-vs-700" rel="noopener noreferrer"&gt;Voice AI 300ms UX&lt;/a&gt;. Chapter 9 covers turn-taking end-to-end; chapters 4 and 5 cover the latency anatomy that decides whether closing the gap is even possible for your stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ACL IWSDS 2025. "A Survey of Recent Advances on Turn-taking Modeling in Spoken Dialogue."&lt;/li&gt;
&lt;li&gt;Krisp AI. "Audio-only, 6M weights Turn-Taking model for Voice AI Agents." 2025.&lt;/li&gt;
&lt;li&gt;Twilio. "Core Latency in AI Voice Agents." 2025.&lt;/li&gt;
&lt;li&gt;Deepgram. "Flux: End-of-Turn Detection Parameters." 2026.&lt;/li&gt;
&lt;li&gt;Sparkco. "Optimizing Voice Agent Barge-in Detection." 2025.&lt;/li&gt;
&lt;li&gt;VoiceInfra. "Voice AI Prompt Engineering: Complete Technical Guide." 2025.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>voiceai</category>
      <category>webrtc</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
    <item>
      <title>Claude Code + Playwright MCP + GitHub Actions: 3 Test Layers That Actually Kill Regressions</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 21 Sep 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/claude-code-playwright-mcp-github-actions-3-test-layers-that-actually-kill-regressions-34f6</link>
      <guid>https://dev.to/kenimo49/claude-code-playwright-mcp-github-actions-3-test-layers-that-actually-kill-regressions-34f6</guid>
      <description>&lt;p&gt;The first time I asked Claude Code to write tests for a feature it had just implemented, the tests passed. All of them. On the first try. I felt great about it for about ten minutes, until I realized the tests were describing the code, not the requirements. The code had a bug I had specified against, and the tests happily locked the bug in.&lt;/p&gt;

&lt;p&gt;That is context pollution. It is the fundamental problem with letting one model both implement and verify. The New Stack put it best: &lt;strong&gt;letting the model that wrote the code write the tests is asking a student to grade their own homework.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is architectural, not attitudinal. Split the loop across three isolated contexts, each running at a different point in the development lifecycle. Here is what those three layers look like when you build them out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: TDD by subagent (dev time)
&lt;/h2&gt;

&lt;p&gt;Red-Green-Refactor works with Claude Code, but not the way most teams first try it. The naive setup is one Claude Code session doing all three phases. That is the setup that produces homework-grading.&lt;/p&gt;

&lt;p&gt;The working setup uses three subagents, each blind to the others' work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Skills (orchestrator)
  ├── tdd-test-writer   (RED)    — sees feature description, NOT implementation
  ├── tdd-implementer   (GREEN)  — sees test file, NOT the writer's reasoning
  └── tdd-refactorer    (REFACTOR) — sees passing code, must keep tests green
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical bit is that each subagent runs in its own context. The test writer cannot peek at how the implementer plans to solve the problem. The implementer cannot see the notes the test writer wrote about "edge cases we should probably cover."&lt;/p&gt;

&lt;p&gt;A minimal skill definition for the writer:&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;# .claude/agents/tdd-test-writer.md&lt;/span&gt;

You are a test writer. Your ONLY job is to write failing tests.

Rules:
&lt;span class="p"&gt;-&lt;/span&gt; You CANNOT see or reference any implementation files
&lt;span class="p"&gt;-&lt;/span&gt; Write tests based ONLY on the feature description
&lt;span class="p"&gt;-&lt;/span&gt; Tests must cover happy path + edge cases (nulls, empty, negative, overflow)
&lt;span class="p"&gt;-&lt;/span&gt; Run tests and confirm they FAIL before finishing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the implementer, which is deliberately narrow:&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;# .claude/agents/tdd-implementer.md&lt;/span&gt;

You are an implementer. Your ONLY job is to make tests pass.

Rules:
&lt;span class="p"&gt;-&lt;/span&gt; Write the MINIMUM code to make tests pass
&lt;span class="p"&gt;-&lt;/span&gt; Do NOT refactor or optimize (that's the refactorer's job)
&lt;span class="p"&gt;-&lt;/span&gt; Do NOT modify test files
&lt;span class="p"&gt;-&lt;/span&gt; Run tests and confirm they PASS before finishing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this works: the "design intent" that a normal Claude Code session leaks between phases now cannot leak. The test author writes what the feature &lt;em&gt;should&lt;/em&gt; do; the implementer writes what makes those tests pass; the refactorer cleans up without breaking anything. It is exactly the discipline TDD was invented to enforce, made mechanical by the fact that context boundaries are hard walls now.&lt;/p&gt;

&lt;p&gt;The other nice property: you can CLAUDE.md this policy at the repo level and forget about it.&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;## Testing policy&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; All new features go through the TDD subagent chain
&lt;span class="p"&gt;-&lt;/span&gt; No test code and implementation code in the same Claude session
&lt;span class="p"&gt;-&lt;/span&gt; Skip TDD only for one-line typo fixes and docs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Layer 2: Quinn, the Playwright MCP QA agent (PR time)
&lt;/h2&gt;

&lt;p&gt;The second layer runs at pull-request time and does the thing no unit test can do: &lt;strong&gt;click around the UI like a person who has never seen the code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Alexop's Quinn pattern is the reference here. Quinn is an AI QA engineer defined by a prompt: a 12-year-veteran QA persona, instructed to test only through the UI, never look at source, always exercise the mobile viewport (375x667), and file findings with reproduction steps.&lt;/p&gt;

&lt;p&gt;The mechanism that makes Quinn possible in 2026 is Playwright MCP. The server ships dozens of browser-automation tools exposed over MCP, and the important detail is that it hands the agent an &lt;strong&gt;accessibility tree as structured JSON&lt;/strong&gt; rather than a screenshot. No vision model needed. Claude Code reads the tree, decides what to click, tells Playwright to click it, reads the resulting tree, and so on. Deterministic, cheap, and stable across page redesigns.&lt;/p&gt;

&lt;p&gt;A working &lt;code&gt;.github/workflows/ai-qa.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI QA (Quinn)&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;labeled&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;qa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.label.name == 'qa-review'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm install &amp;amp;&amp;amp; pnpm build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Start dev server&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm dev &amp;amp;&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropics/claude-code-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;anthropic_api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;claude_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;--mcp-config '{&lt;/span&gt;
              &lt;span class="s"&gt;"mcpServers": {&lt;/span&gt;
                &lt;span class="s"&gt;"playwright": {&lt;/span&gt;
                  &lt;span class="s"&gt;"command": "npx",&lt;/span&gt;
                  &lt;span class="s"&gt;"args": ["@playwright/mcp@latest", "--headless", "--no-sandbox"]&lt;/span&gt;
                &lt;span class="s"&gt;}&lt;/span&gt;
              &lt;span class="s"&gt;}&lt;/span&gt;
            &lt;span class="s"&gt;}'&lt;/span&gt;
          &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;You are Quinn, a senior QA engineer with 12 years of experience.&lt;/span&gt;
            &lt;span class="s"&gt;Test the application at http://localhost:3000.&lt;/span&gt;
            &lt;span class="s"&gt;DO NOT look at source code. Test only through the UI.&lt;/span&gt;
            &lt;span class="s"&gt;Test on desktop (1280x720) and mobile (375x667).&lt;/span&gt;
            &lt;span class="s"&gt;Try edge cases: empty inputs, huge inputs, negatives, unicode names.&lt;/span&gt;
            &lt;span class="s"&gt;Report each bug with a screenshot and a reproduction sequence.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flags in that config carry more weight than they look: &lt;code&gt;--headless --no-sandbox&lt;/code&gt; on the MCP server side makes it run under GitHub Actions' container without display. Claude Code itself runs non-interactively under &lt;code&gt;claude-code-action@v1&lt;/code&gt;, so the action handles the "exit when done" part for you.&lt;/p&gt;

&lt;p&gt;Quinn is not a replacement for scripted E2E tests. Scripted E2Es catch known-bad regressions. Quinn catches the class of bug where "nobody thought to write a test for that particular sequence." Two different tools, two different failure modes.&lt;/p&gt;

&lt;p&gt;The trigger being &lt;code&gt;types: [labeled]&lt;/code&gt; matters. Quinn burns real tokens, and running her on every push to every PR gets expensive. Making her opt-in via a &lt;code&gt;qa-review&lt;/code&gt; label means the team pays for her when the PR is close to ready, not while it is still churning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: claude-code-action on the CI gate (merge time)
&lt;/h2&gt;

&lt;p&gt;The third layer is the boring one, which is why it works. Once a PR passes Quinn, one more Claude Code invocation runs on the CI gate, looking at the diff against the existing test suite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/claude-test.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Claude Code Test Gate&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm install&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropics/claude-code-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;anthropic_api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;Analyze the changes in this PR. Then:&lt;/span&gt;
            &lt;span class="s"&gt;1. Run the existing test suite: pnpm test&lt;/span&gt;
            &lt;span class="s"&gt;2. Identify code paths in this diff not covered by any test&lt;/span&gt;
            &lt;span class="s"&gt;3. Propose specific test cases for the gaps (do not write them yet)&lt;/span&gt;
            &lt;span class="s"&gt;4. Post the analysis as a PR comment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the "did we miss anything" pass. It does not write tests (that would push us back into homework-grading), it flags gaps. A human reads the comment, decides which gaps matter, and either accepts them or opens follow-up tickets.&lt;/p&gt;

&lt;p&gt;Anthropic ships &lt;code&gt;claude-code-action@v1&lt;/code&gt; as the official integration, and the setup is one CLI call away — &lt;code&gt;/install-github-app&lt;/code&gt; from Claude Code walks you through installation, permissions, and mentioning &lt;code&gt;@claude&lt;/code&gt; on issues or PRs to get responses.&lt;/p&gt;

&lt;p&gt;Between the three layers, the diff of &lt;code&gt;who runs which tests when&lt;/code&gt; looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Who tests&lt;/th&gt;
&lt;th&gt;What they use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. TDD subagents&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dev time (per commit)&lt;/td&gt;
&lt;td&gt;Claude Code, subagent per phase&lt;/td&gt;
&lt;td&gt;Feature description + failing test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Quinn (Playwright MCP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PR time (label-triggered)&lt;/td&gt;
&lt;td&gt;Claude Code as QA persona&lt;/td&gt;
&lt;td&gt;Browser via MCP, no source access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. claude-code-action&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Merge time (every push)&lt;/td&gt;
&lt;td&gt;Claude Code as reviewer&lt;/td&gt;
&lt;td&gt;Diff + existing suite + coverage report&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice what each layer has that the others don't. Layer 1 has the requirements but not the implementation. Layer 2 has the running app but not the source. Layer 3 has the diff but is forbidden from writing tests. Each layer's blindness is what makes it useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why selector reliability improves under this shape
&lt;/h2&gt;

&lt;p&gt;Small side-benefit worth mentioning. When Quinn writes down what she clicked (for the reproduction steps she files with bugs), she writes it in Playwright MCP's accessibility-tree vocabulary. That means:&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;// The kind of selector Quinn produces&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;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Log in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Not the kind you get from asking Claude to "look at the DOM"&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;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;.btn-primary.mt-4.px-6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role-plus-text form survives redesigns, works with screen readers, and doesn't break the first time someone renames a Tailwind class. When you promote Quinn's reproduction steps into permanent E2E tests (which you should, whenever the bug is worth guarding against), you get selector stability for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each layer is doing that the others can't
&lt;/h2&gt;

&lt;p&gt;The reason context pollution is a whole category of AI-testing bug is that most teams try to solve testing with &lt;em&gt;more&lt;/em&gt; AI in &lt;em&gt;one&lt;/em&gt; place. More prompts, better instructions, longer CLAUDE.md rules about "please check your own work." None of that helps. The problem is information leakage between phases, and stronger prompts inside one leaky pipeline still leak.&lt;/p&gt;

&lt;p&gt;Three isolated contexts, three different data diets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The test writer eats requirements, produces tests.&lt;/li&gt;
&lt;li&gt;Quinn eats the running app, produces bug reports.&lt;/li&gt;
&lt;li&gt;The CI reviewer eats the diff, produces gap analysis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roll any two of those together and you get a subtle regression somewhere. Keep them separate and each does its narrow job well.&lt;/p&gt;

&lt;p&gt;The Anthropic tooling to make this work — subagents, Playwright MCP, claude-code-action — all landed in 2025-2026. The pattern was possible before, in the way most patterns are "possible" before the tooling; now it is a config-file exercise, not a research project.&lt;/p&gt;

&lt;p&gt;If your team already has one Claude Code session doing all three jobs, split them next sprint. The tests will start finding things the single-session setup silently endorsed.&lt;/p&gt;




&lt;p&gt;The full Claude Code testing playbook (subagent memory design, when to use skills vs commands vs subagents, how to structure a repo so the three layers stay independent, and what to do when Quinn files a false positive) is written up in &lt;a href="https://kenimoto.dev/books/claude-code-mastery?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cc-playwright-mcp-3-layers" rel="noopener noreferrer"&gt;Claude Code Mastery&lt;/a&gt;. Chapter 10 covers test automation end-to-end, including the TDD-subagent split this article is a distillation of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The New Stack. "Claude Code and the Art of Test-Driven Development." 2025.&lt;/li&gt;
&lt;li&gt;alexop.dev. "Building an AI QA Engineer with Claude Code and Playwright MCP." 2025.&lt;/li&gt;
&lt;li&gt;alexop.dev. "Forcing Claude Code to TDD: An Agentic Red-Green-Refactor Loop." 2025.&lt;/li&gt;
&lt;li&gt;Anthropic. "Claude Code GitHub Actions." Official docs, 2026.&lt;/li&gt;
&lt;li&gt;Microsoft Playwright team. "Playwright MCP Server." github.com/microsoft/playwright-mcp, 2026.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>testing</category>
      <category>playwright</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>MCP Resources vs Tools vs Prompts: 3 Layers That Cut My Agent's Tokens From 114K to 27K</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/mcp-resources-vs-tools-vs-prompts-3-layers-that-cut-my-agents-tokens-from-114k-to-27k-440a</link>
      <guid>https://dev.to/kenimo49/mcp-resources-vs-tools-vs-prompts-3-layers-that-cut-my-agents-tokens-from-114k-to-27k-440a</guid>
      <description>&lt;p&gt;Most MCP posts on this site stop at the Tools layer. That is the layer everyone builds first: &lt;code&gt;get_weather()&lt;/code&gt;, &lt;code&gt;send_email()&lt;/code&gt;, &lt;code&gt;query_database()&lt;/code&gt;. It is also the layer that will eat your token budget if you leave it as the only thing you use.&lt;/p&gt;

&lt;p&gt;The Model Context Protocol has three layers, not one. Resources, Tools, and Prompts. Each of them solves a different problem, each of them costs a different amount of tokens, and each of them is supported unevenly across clients right now. On the same browser-automation task, a CLI-style setup that pre-loaded the full tool documentation into the context ran at &lt;strong&gt;114,000 input tokens&lt;/strong&gt;. The MCP-native setup, where the server abstracted the low-level details away, ran at &lt;strong&gt;27,000&lt;/strong&gt;. A 4.2x cut without changing the model or the underlying browser — pulled from the case study in the Context Engineering book this article is adapted from.&lt;/p&gt;

&lt;p&gt;Below is the concrete framework I use to decide which layer a piece of context belongs in, why the three layers cost so differently, and what to do when your MCP client only supports the layers it feels like supporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3 layers, side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;th&gt;Cost profile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resources&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read-only information sources the model can pull on demand (&lt;code&gt;file://&lt;/code&gt;, &lt;code&gt;db://&lt;/code&gt;, &lt;code&gt;api://&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Static or slow-changing context the agent may need&lt;/td&gt;
&lt;td&gt;Cheap. Pulled only when referenced.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tools&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Executable operations with side effects (&lt;code&gt;create_file&lt;/code&gt;, &lt;code&gt;send_email&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Actions the agent takes in the world&lt;/td&gt;
&lt;td&gt;Moderate. Definition sits in context every call.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prompts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reusable, parameterized templates the client can invoke&lt;/td&gt;
&lt;td&gt;Repeated flows: code review, ticket triage, incident response&lt;/td&gt;
&lt;td&gt;Cheap. Server-side; the client fetches on request.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fpki1inj957hirwbq49y5.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%2Fpki1inj957hirwbq49y5.png" alt="Resources vs Tools vs Prompts: purpose, when to use, example, and token cost profile for each MCP layer." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Skip any of these three and you either lose expressiveness or you overpay in tokens. Both mistakes are common.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 114K → 27K happened
&lt;/h2&gt;

&lt;p&gt;The 114K number came from a CLI-style approach to browser automation: dump the full command-line documentation, error handling, and usage examples for every primitive into the context, so the LLM knows how to compose them. Something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# CLI-style design (114K tokens per run)
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Available CLI commands:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;generate_cli_documentation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# 30 primitives, each with full docs
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;generate_cli_examples&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;        &lt;span class="c1"&gt;# example usage for each
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;generate_error_handling_docs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# every error code + retry strategy
# task itself: ~8K tokens sitting on top of ~106K of tooling documentation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 27K number came from an MCP-native rewrite where the server hides those details behind a small set of high-level tools. The chapter this article adapts frames it as roughly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# MCP-native design (27K tokens per run)
&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright_navigate(url)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright_click(selector)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright_type(selector, text)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright_screenshot()&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright_extract_text(selector)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# server holds the CLI-level docs, retry policies, and error mapping internally
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The book credits three mechanisms for the drop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Abstraction level.&lt;/strong&gt; CLI-style is low-level detail. MCP is a high-level abstraction. The client sends intent; the server figures out how to execute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separation of context.&lt;/strong&gt; The MCP server holds implementation details. The client only sees what it needs to decide the next action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic resolution.&lt;/strong&gt; MCP resolves details when the tool actually runs. CLI-style has to describe everything up front, in case it is needed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Where the three MCP layers come in is that each of them is one of those separation strategies. &lt;strong&gt;Tools&lt;/strong&gt; are the intent layer. &lt;strong&gt;Resources&lt;/strong&gt; are how the "docs the agent occasionally needs" get moved off the always-loaded context. &lt;strong&gt;Prompts&lt;/strong&gt; are how the "repeat this whole flow again" logic gets moved onto the server. Together they are what makes the 4.2x abstraction gain possible in the first place.&lt;/p&gt;

&lt;p&gt;The one-line takeaway from the chapter: &lt;strong&gt;your token bill is the description of the tools, not the tools themselves.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which layer does a piece of context belong in?
&lt;/h2&gt;

&lt;p&gt;The decision is not that hard once you have a rule. Here is the one I use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask: does the agent need to &lt;em&gt;do&lt;/em&gt; something, &lt;em&gt;read&lt;/em&gt; something, or &lt;em&gt;repeat&lt;/em&gt; something?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do&lt;/strong&gt; something → Tool. Side effects, mutations, external calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read&lt;/strong&gt; something → Resource. Read-only, addressable by URI, pulled on demand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repeat&lt;/strong&gt; something → Prompt. Templates the client can invoke by name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you feel yourself dumping a full API doc into a tool's description field, stop. That is a Resource. If you feel yourself teaching the model the same 6-step recipe over and over in the system prompt, stop. That is a Prompt.&lt;/p&gt;

&lt;p&gt;Tools are the loud, expensive layer because their definitions live in the context on every call. Resources and Prompts are the quiet, cheap layers because their content is fetched only when the agent decides it needs them. Most teams over-invest in Tools and under-invest in Resources and Prompts, then wonder why their agents feel expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What your client actually supports right now (September 2026)
&lt;/h2&gt;

&lt;p&gt;Here is the part nobody warns you about. The MCP spec has three layers. The clients do not implement all three uniformly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Desktop&lt;/strong&gt; supports Resources, Tools, and Prompts. Prompts appear in the slash-menu inside the chat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor&lt;/strong&gt; supports Tools well. Resource support has been improving through 2026 but is uneven. Prompt support arrived in a recent update and is now working end-to-end in the mcp.json config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT&lt;/strong&gt; has been rolling out MCP since 2025 (Developer Mode beta shipped September 2025), with the Enterprise and Business rollout continuing into 2026. Tool support is solid. Resources and Prompts are still shipping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom SDK clients&lt;/strong&gt; (via the Python or TypeScript SDK) implement whatever you write. The floor is Tools; adding Resources and Prompts is trivial in the SDK and worth doing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical takeaway is unglamorous. Even if your server exposes all three layers, some fraction of your users are on a client that only invokes Tools. Build your server so that a Tools-only client still gets meaningful behavior, and Resources and Prompts stack on top for the clients that can use them. Do not gate the &lt;em&gt;core&lt;/em&gt; action on a Prompt the client will never call.&lt;/p&gt;

&lt;p&gt;The July 28, 2026 spec revision helped here: capability discovery is now handled through the &lt;code&gt;server/discover&lt;/code&gt; method, so a well-behaved client can at least ask what the server offers instead of assuming. Whether it actually &lt;em&gt;uses&lt;/em&gt; what the server offers is a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design mistake I keep watching people make
&lt;/h2&gt;

&lt;p&gt;The most common failure mode is not "picked the wrong layer." It is "put everything in Tools because Tools is the layer they understood first." I did this on my first two servers before I noticed the pattern. It is a rite of passage I would rather you skip.&lt;/p&gt;

&lt;p&gt;Symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool descriptions running 500+ words each.&lt;/li&gt;
&lt;li&gt;30+ tools on the surface.&lt;/li&gt;
&lt;li&gt;System prompt contains "when using tool X, remember to first check Y" instructions.&lt;/li&gt;
&lt;li&gt;Token cost per turn is embarrassing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is almost always the same shape: collapse related low-level tools into an intent tool, move the docs the agent occasionally needs into Resources, and lift the repeated flows into Prompts. The MCP server picks up the orchestration; the agent stops paying for it in tokens.&lt;/p&gt;

&lt;p&gt;The design principle behind this is the one line from the MCP guide I keep rereading: &lt;strong&gt;the description of the tool is context, and context has a price.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;If you already run an MCP server, three concrete actions.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count the tokens your tool definitions consume on a typical run. If they are more than 20% of the total input budget, you have room to compress.&lt;/li&gt;
&lt;li&gt;Identify one thing you currently put in the system prompt or in a tool description that is really &lt;em&gt;reference material&lt;/em&gt;. Move it to a Resource this week.&lt;/li&gt;
&lt;li&gt;Identify one multi-step flow you invoke often. Lift it to a Prompt so it lives on the server instead of being rebuilt in the client each time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these three moves require rewriting your agent. They are configuration changes on the MCP server side, and the client will start benefiting the next time it connects.&lt;/p&gt;

&lt;p&gt;If you want the longer walkthrough that this article is based on, my Zenn book &lt;a href="https://kenimoto.dev/books/context-engineering?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp-3-layers-114k-27k" rel="noopener noreferrer"&gt;Context Engineering: Turn LLMs From Liars Into Experts&lt;/a&gt; has a full chapter on the MCP three-layer split, including the code snippets for the Playwright server refactor that got me from 114K to 27K.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Model Context Protocol — &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro" rel="noopener noreferrer"&gt;Official spec and getting started&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Maxim AI — &lt;a href="https://www.getmaxim.ai/articles/what-is-model-context-protocol-mcp-a-2026-guide/" rel="noopener noreferrer"&gt;What Is Model Context Protocol (MCP)? A 2026 Guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Descope — &lt;a href="https://www.descope.com/learn/post/mcp" rel="noopener noreferrer"&gt;What Is MCP and How It Works&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Prompt Architects — &lt;a href="https://prompt-architects.com/blog/57-mcp-prompt-management-cursor-claude" rel="noopener noreferrer"&gt;How to Use MCP to Manage Prompts Inside Cursor &amp;amp; Claude Desktop&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Claude Code Hooks vs Skills vs Subagents: Three Ways to Extend the Agent, and When Each Backfires</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Sun, 13 Sep 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/claude-code-hooks-vs-skills-vs-subagents-three-ways-to-extend-the-agent-and-when-each-backfires-1728</link>
      <guid>https://dev.to/kenimo49/claude-code-hooks-vs-skills-vs-subagents-three-ways-to-extend-the-agent-and-when-each-backfires-1728</guid>
      <description>&lt;p&gt;The first time I tried to make Claude Code "always run the linter," I wrote a Skill. It worked most of the time. The rest of the time, Claude decided the task didn't need linting and skipped it. I spent an embarrassing afternoon tightening the Skill's description before I understood the actual problem: I had picked the wrong mechanism. "Always do X" is a Hook. "Decide whether to do X" is a Skill. They are not interchangeable, and the docs at the time were happy to let me confuse them.&lt;/p&gt;

&lt;p&gt;Claude Code now ships four ways to extend the agent: Hooks, Skills, Subagents, and the Agent SDK. They overlap enough to look like alternatives and differ enough that using one where another belongs costs you reliability, tokens, or both. I've built real harnesses on the first three, and read the SDK docs closely enough to know where it takes over. Here is how each one actually fires, what it costs you in context, and the specific way each one bites back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line mental model
&lt;/h2&gt;

&lt;p&gt;Before the table, the version I tell teammates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hooks&lt;/strong&gt; are deterministic. They fire on an event whether Claude likes it or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills&lt;/strong&gt; are probabilistic. Claude reads a description and &lt;em&gt;decides&lt;/em&gt; to pull them in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents&lt;/strong&gt; are isolation. They run in their own context window and hand you back a summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Agent SDK&lt;/strong&gt; is you taking the wheel: the same agent loop, running in your own process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you remember nothing else: Hooks remove a decision from the model, Skills add one, Subagents quarantine one.&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%2Fn43ujz1wiftzj1fs1bpe.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%2Fn43ujz1wiftzj1fs1bpe.png" alt="Comparison of Claude Code Hooks, Skills, and Subagents across how each fires, its token cost, and when it backfires" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;How it fires&lt;/th&gt;
&lt;th&gt;Token cost&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;When it backfires&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lifecycle events (&lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;, etc.) defined in &lt;code&gt;settings.json&lt;/code&gt;. Not the model's choice.&lt;/td&gt;
&lt;td&gt;Runs as a shell command outside the context window. Near zero, unless you deliberately inject stdout.&lt;/td&gt;
&lt;td&gt;Non-negotiable rules: format on write, block secret files, run tests before stop.&lt;/td&gt;
&lt;td&gt;A hook exiting &lt;code&gt;2&lt;/code&gt; blocks the action. A bad &lt;code&gt;Stop&lt;/code&gt; hook loops the agent forever.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Skills&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Claude matches your prompt against the Skill's &lt;code&gt;description&lt;/code&gt;, or you type &lt;code&gt;/name&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Progressive disclosure: only &lt;code&gt;name&lt;/code&gt;+&lt;code&gt;description&lt;/code&gt; (a few dozen tokens) loaded per Skill at session start; body loads on invoke.&lt;/td&gt;
&lt;td&gt;Reusable procedures Claude should choose when relevant: a review workflow, a release-notes drafter.&lt;/td&gt;
&lt;td&gt;A bloated &lt;code&gt;description&lt;/code&gt; taxes every turn and misroutes. The model can decline to fire at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Subagents&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Auto-delegated when a task matches the agent's &lt;code&gt;description&lt;/code&gt;, or via the &lt;code&gt;Agent&lt;/code&gt; tool (the old &lt;code&gt;Task&lt;/code&gt;).&lt;/td&gt;
&lt;td&gt;Runs in its own context window. Only the final message returns to the parent.&lt;/td&gt;
&lt;td&gt;Quarantining noisy work: codebase-wide greps, log trawls, parallel exploration.&lt;/td&gt;
&lt;td&gt;It can't ask you follow-ups mid-task, and the summary drops the detail you needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Programmatic. You call &lt;code&gt;query()&lt;/code&gt; from TypeScript or Python and drive the loop yourself.&lt;/td&gt;
&lt;td&gt;A separate program in your own process. Context is whatever your code feeds it.&lt;/td&gt;
&lt;td&gt;Productizing the agent: CI bots, backend services, anything outside the CLI.&lt;/td&gt;
&lt;td&gt;You own retries, sandboxing, session state, and cost. Nothing is automatic anymore.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Hooks: the mechanism that removes the model's vote
&lt;/h2&gt;

&lt;p&gt;A Hook is a shell command wired to a lifecycle event in &lt;code&gt;settings.json&lt;/code&gt;. The model does not get a say. This is the whole point and the whole danger.&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;"hooks"&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;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write|Edit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&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;"prettier --write &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_FILE_PATHS&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;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 2026 event surface is much wider than the original set. Beyond the familiar &lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;, and &lt;code&gt;Notification&lt;/code&gt;, there are now session events like &lt;code&gt;SessionEnd&lt;/code&gt;, agent events like &lt;code&gt;SubagentStart&lt;/code&gt;/&lt;code&gt;SubagentStop&lt;/code&gt;, and context events like &lt;code&gt;PreCompact&lt;/code&gt;/&lt;code&gt;PostCompact&lt;/code&gt; (&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The footgun is the exit code. Exit &lt;code&gt;0&lt;/code&gt; is success. Exit &lt;code&gt;2&lt;/code&gt; is a &lt;em&gt;blocking&lt;/em&gt; error: stderr goes back to Claude and the action is rejected. On a &lt;code&gt;PreToolUse&lt;/code&gt; hook that's a feature: you can stop Claude from touching &lt;code&gt;.env&lt;/code&gt;. On a &lt;code&gt;Stop&lt;/code&gt; or &lt;code&gt;SubagentStop&lt;/code&gt; hook, exit &lt;code&gt;2&lt;/code&gt; means "you are not allowed to stop, keep going." I once wrote a &lt;code&gt;Stop&lt;/code&gt; hook that checked for uncommitted changes and exited &lt;code&gt;2&lt;/code&gt; if it found any. The agent dutifully refused to stop, tried again, still had uncommitted changes, refused to stop again. I had built a loop with my own hands. The lesson: a Hook that can block is a Hook that can deadlock. Test the exit-&lt;code&gt;2&lt;/code&gt; path before you trust it.&lt;/p&gt;

&lt;p&gt;Because Hooks run with your shell privileges and outside the context window, they cost almost nothing in tokens. That's why "always run the linter" belongs here and not in a Skill. You are not asking Claude to remember; you are removing the choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills: the mechanism that adds the model's vote
&lt;/h2&gt;

&lt;p&gt;A Skill is a &lt;code&gt;SKILL.md&lt;/code&gt; file with frontmatter. Claude reads the &lt;code&gt;description&lt;/code&gt; of every installed Skill at session start and decides, per turn, whether your prompt warrants pulling one in. You can also force it with &lt;code&gt;/name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The mechanic that makes Skills scale is progressive disclosure. Three tiers load at three different times:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always loaded:&lt;/strong&gt; the &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; only, a few dozen tokens per Skill. This is how one agent can know about hundreds of Skills without drowning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loaded on invoke:&lt;/strong&gt; the SKILL.md body. Anthropic's own guidance keeps the body under ~500 lines (&lt;a href="https://github.com/anthropics/claude-code/blob/main/plugins/plugin-dev/skills/skill-development/SKILL.md" rel="noopener noreferrer"&gt;skill-development SKILL.md&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loaded only when referenced:&lt;/strong&gt; bundled scripts and detail files, zero tokens until Claude reaches for them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the failure mode is specific and quiet. Every Skill's &lt;code&gt;description&lt;/code&gt; sits in context on &lt;em&gt;every&lt;/em&gt; turn. Write a paragraph there and you've levied a permanent tax on the whole session, and you've made the routing decision harder, so the model misfires more often. My original "always lint" Skill failed because I was asking a probabilistic router to behave deterministically. The router did its job: it routed. Sometimes away from me.&lt;/p&gt;

&lt;p&gt;Skills are right when you genuinely want Claude to choose. A code-review workflow, a "draft release notes from the git log" procedure, a triage routine. Things that should happen &lt;em&gt;when relevant&lt;/em&gt;, judged by the model. Write the &lt;code&gt;description&lt;/code&gt; like a routing key, not a summary (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview" rel="noopener noreferrer"&gt;Agent Skills overview&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Subagents: the mechanism that quarantines context
&lt;/h2&gt;

&lt;p&gt;A Subagent is a Markdown file in &lt;code&gt;.claude/agents/&lt;/code&gt; with frontmatter, and its body becomes that agent's system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;codebase-explorer&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Search the codebase for where a symbol is defined and used. Use proactively before refactors.&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Glob, Grep&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonnet&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="s"&gt;You locate code. Report file paths and line numbers, not full file dumps.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defining property: it runs in its own context window. Its system prompt, its tool calls, every grep result and log line it reads, none of that enters your main conversation. Only the final message comes back (&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;custom subagents&lt;/a&gt;). This is the single best tool for a task that would otherwise flood your context: "find every caller of this function across 400 files." Run it in a Subagent and you get back a clean list instead of 400 files of noise.&lt;/p&gt;

&lt;p&gt;Two backfires bit me here. First, a Subagent can't stop to ask you a question. It runs to completion and reports once. If it hits an ambiguous decision halfway through, it guesses, and you find out from the summary. Second, that summary is lossy by design. The detail lived in the isolated window and is gone unless the agent thought to surface it. I've had a Subagent confidently report "no usages found" when it had quietly searched the wrong directory. The fix is to write the agent's instructions to return evidence (paths, counts), not just conclusions.&lt;/p&gt;

&lt;p&gt;One 2026 gotcha worth flagging: permission modes are inherited from the parent and can override the Subagent's own frontmatter. If your main session is running with relaxed permissions, the Subagent's careful &lt;code&gt;tools&lt;/code&gt; allowlist may not save you. Treat the tool list as scoping, not a security boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent SDK: the mechanism that hands you the keys
&lt;/h2&gt;

&lt;p&gt;When the agent needs to live outside the terminal, in CI, a backend service, a scheduled job, you reach for the Agent SDK. It was renamed from the Claude Code SDK because the same loop powers far more than coding (&lt;a href="https://platform.claude.com/docs/en/agent-sdk/migration-guide" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt;). It ships for both TypeScript (&lt;code&gt;@anthropic-ai/claude-agent-sdk&lt;/code&gt;) and Python (&lt;code&gt;claude-agent-sdk&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;claude_agent_sdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClaudeAgentOptions&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find and fix the failing test in auth.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ClaudeAgentOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed_tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the same agent loop, built-in tools, hooks, subagents, and MCP that power the CLI, but now running in your process. The backfire is everything the CLI used to do for free: retries, sandboxing, session persistence, and cost control are now your problem. One auth constraint to know before you build a product on it: third-party products on the Agent SDK can't use claude.ai subscription login, you authenticate with an API key (directly or via Bedrock/Vertex). Prototype on the SDK, then decide whether a hosted option fits production.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I actually choose
&lt;/h2&gt;

&lt;p&gt;The question I ask, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Must this happen every time, no exceptions?&lt;/strong&gt; Hook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Should Claude decide when it's relevant?&lt;/strong&gt; Skill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is this going to dump a pile of junk into my context?&lt;/strong&gt; Subagent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does this need to run without me in the loop?&lt;/strong&gt; Agent SDK.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most of my mistakes came from answering question 2 when the honest answer was question 1. "Always" and "when relevant" feel similar when you're typing the config. They are opposites at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hooks remove the model's choice.&lt;/strong&gt; Use them for non-negotiable rules. Watch the exit-&lt;code&gt;2&lt;/code&gt; path: a blocking &lt;code&gt;Stop&lt;/code&gt; hook can loop your agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills add a choice.&lt;/strong&gt; Progressive disclosure keeps them cheap, but every &lt;code&gt;description&lt;/code&gt; is taxed on every turn. Don't use a Skill to enforce something that must always happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents quarantine context.&lt;/strong&gt; Best for noisy exploration. They can't ask follow-ups and the summary is lossy, so make them return evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Agent SDK hands you the keys.&lt;/strong&gt; Same loop, your process, your operational burden.&lt;/li&gt;
&lt;li&gt;The biggest reliability win isn't picking the "best" mechanism. It's noticing when "always" is masquerading as "when relevant."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've been wiring Claude Code config long enough to feel the shape of a coherent harness, where Hooks, Skills, Subagents, and CLAUDE.md all sit in one place and reinforce each other, that's the book I wrote.&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=cc-extension-points" rel="noopener noreferrer"&gt;Claude Code Mastery&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference — Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Create custom subagents — Claude Code Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview" rel="noopener noreferrer"&gt;Agent Skills overview — Claude Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/agent-sdk/migration-guide" rel="noopener noreferrer"&gt;Migrate to the Claude Agent SDK — Claude Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills" rel="noopener noreferrer"&gt;Equipping agents for the real world with Agent Skills — Anthropic&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more" rel="noopener noreferrer"&gt;Steering Claude Code: skills, hooks, subagents — Claude blog&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>agents</category>
      <category>devtools</category>
    </item>
  </channel>
</rss>
