<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: AI Dev Hub</title>
    <description>The latest articles on DEV Community by AI Dev Hub (@aidevhub).</description>
    <link>https://dev.to/aidevhub</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%2F3769170%2F51b2c1be-6090-4a70-b86f-000759e46929.png</url>
      <title>DEV Community: AI Dev Hub</title>
      <link>https://dev.to/aidevhub</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aidevhub"/>
    <language>en</language>
    <item>
      <title>Zod vs Pydantic vs Ajv: I ran one broken schema in all 3</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 15 Sep 2026 14:00:04 +0000</pubDate>
      <link>https://dev.to/aidevhub/zod-vs-pydantic-vs-ajv-i-ran-one-broken-schema-in-all-3-n4g</link>
      <guid>https://dev.to/aidevhub/zod-vs-pydantic-vs-ajv-i-ran-one-broken-schema-in-all-3-n4g</guid>
      <description>&lt;h1&gt;
  
  
  Zod vs Pydantic vs Ajv: I ran one broken schema in all 3
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Ajv. It was the only one of the three that rejected my malformed tool schema, because it validates the schema document itself rather than validating data against it. Zod prevents the bug by construction if you author in TypeScript, and Pydantic has by far the best runtime error messages. None of them knew anything about provider-specific rules, which is where my actual bad afternoon came from.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Disclosure first: the structured output validator I link to below is one I built. I got there after running an Anthropic tool definition through four generic JSON Schema tools and finding that not one of them knew &lt;code&gt;input_schema&lt;/code&gt; from &lt;code&gt;parameters&lt;/code&gt;. It's free, runs client side, no signup, nothing gets uploaded anywhere. If you know a better one, tell me and I'll link that instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: one tool definition, two different questions
&lt;/h2&gt;

&lt;p&gt;On Tuesday, August 18, 2026, an invoice extraction agent I maintain booked a credit note as a charge. A customer watched $91.64 land on the wrong side of their ledger. Out of 1,247 extraction calls that week, 3 came back with a negative &lt;code&gt;total&lt;/code&gt;, and the field I was certain had been guarding against exactly that looked like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;"total": { "type": "number", "exclusiveMinimum": "0" }&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The zero is a string. JSON Schema says &lt;code&gt;exclusiveMinimum&lt;/code&gt; takes a number, so what I shipped was a keyword with an invalid value, which most runtimes quietly skip over. It sat there for 11 days.&lt;/p&gt;

&lt;p&gt;Once I stopped being annoyed at myself, I noticed I'd been collapsing two questions into one. Question one: is this schema document legal, and legal for the endpoint I'm posting it to? Question two: does the JSON the model sent back match it? I had only ever automated the second one.&lt;/p&gt;

&lt;p&gt;So I took the same broken tool definition and pushed it through the three validators I reach for most: Ajv 8.17 on Node 22, Zod 4, and Pydantic 2.11. Same schema, same sample payload (a credit note with &lt;code&gt;total: -91.64&lt;/code&gt;), same question each time. Does anything warn me before this reaches production?&lt;/p&gt;

&lt;h2&gt;
  
  
  Ajv: the only one that read the schema as a document
&lt;/h2&gt;

&lt;p&gt;Ajv does something the other two don't. Before it looks at any data, it compiles the schema, and by default it validates that schema against the JSON Schema meta-schema. Your schema is data too. That's the whole trick, and it's why Ajv was the only tool here that said a word.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// npm i ajv@8&lt;/span&gt;
&lt;span class="c1"&gt;// node validate-tool-schema.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Ajv2020&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ajv/dist/2020.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;array&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;integer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sku&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qty&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;additionalProperties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;exclusiveMinimum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;line_items&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;additionalProperties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ajv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Ajv2020&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;allErrors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ajv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INV-4471&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;91.64&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schema rejected:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;Output: &lt;code&gt;schema rejected: schema is invalid: data/properties/total/exclusiveMinimum must be number&lt;/code&gt;. Four minutes from &lt;code&gt;npm i&lt;/code&gt; to that line. Had those four minutes existed as a pre-commit hook back in July, the credit note would have bounced.&lt;/p&gt;

&lt;p&gt;Ajv's data errors are less pleasant. You get &lt;code&gt;instancePath: "/line_items/0/qty"&lt;/code&gt; and &lt;code&gt;message: "must be &amp;gt;= 1"&lt;/code&gt;, precise and joyless, and turning that into something a model can act on is your job. &lt;code&gt;strict: true&lt;/code&gt; also gets opinionated about unknown keywords, which flagged two &lt;code&gt;example&lt;/code&gt; fields I'd copied straight out of a docs page. Mildly irritating. Technically correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zod: the bug can't happen, until you need the other direction
&lt;/h2&gt;

&lt;p&gt;You cannot write &lt;code&gt;exclusiveMinimum: "0"&lt;/code&gt; in Zod. There's no syntax for it. You write &lt;code&gt;z.number().positive()&lt;/code&gt; and the broken version simply isn't expressible, so a whole category of typo stops existing. Since March 2026 every TypeScript agent I've started defines its tools in Zod first and emits JSON Schema with &lt;code&gt;z.toJSONSchema()&lt;/code&gt;, and I haven't hand-written a tool schema in that codebase since.&lt;/p&gt;

&lt;p&gt;The trouble starts when the schema isn't yours. Most of the tool definitions I deal with now arrive from somewhere else: an MCP server's &lt;code&gt;inputSchema&lt;/code&gt;, a partner's OpenAPI fragment, something a coworker generated with a model at 2am. To check those with Zod you need a converter, and converters are lossy in the direction that hurts. I ran the broken schema through &lt;code&gt;json-schema-to-zod&lt;/code&gt; and got back &lt;code&gt;z.number()&lt;/code&gt;. Clean. No warning. The bad keyword had been dropped on the floor, and the resulting type happily accepted -91.64.&lt;/p&gt;

&lt;p&gt;I don't know whether that's a deliberate be-generous-with-input choice or just unimplemented. Either way, silently discarding a keyword is precisely the failure I was hunting, so Zod scored zero on question one through no real fault of its own. Wrong layer for the job.&lt;/p&gt;

&lt;p&gt;One wrinkle to plan for: &lt;code&gt;z.toJSONSchema()&lt;/code&gt; factors reused sub-schemas into &lt;code&gt;$defs&lt;/code&gt; with &lt;code&gt;$ref&lt;/code&gt; pointers. Perfectly legal. Not universally loved by strict function-calling modes, so I inline them before anything goes over the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pydantic: the error messages I actually wanted
&lt;/h2&gt;

&lt;p&gt;Pydantic loses question one outright. It doesn't ingest foreign JSON Schema at all. &lt;code&gt;model_json_schema()&lt;/code&gt; is a one-way street, and checking a handwritten document in Python means reaching for &lt;code&gt;jsonschema.Draft202012Validator.check_schema()&lt;/code&gt;, which is a different library entirely.&lt;/p&gt;

&lt;p&gt;On question two it wins, and it isn't close. A &lt;code&gt;ValidationError&lt;/code&gt; hands you &lt;code&gt;loc&lt;/code&gt;, &lt;code&gt;msg&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, and &lt;code&gt;input&lt;/code&gt; for every failure, and that's the only validator output I've managed to serialize and pass straight back to the model as a repair message with results I trust. Last month 44 responses failed validation in that pipeline and 41 were fixed on the first retry, after I started sending Pydantic's &lt;code&gt;errors()&lt;/code&gt; list verbatim instead of my own tidy summary string. I never measured the before number, which I do regret.&lt;/p&gt;

&lt;p&gt;Its generated schemas deserve a look before you send them. Optional fields come out as &lt;code&gt;anyOf: [{...}, {"type": "null"}]&lt;/code&gt; and nested models land in &lt;code&gt;$defs&lt;/code&gt;. Both correct. Both have been rejected by a strict mode at least once in my experience, usually around 4pm on a Friday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scores, and the one I'd actually use
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Ajv 8.17&lt;/th&gt;
&lt;th&gt;Zod 4&lt;/th&gt;
&lt;th&gt;Pydantic 2.11&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Caught the malformed &lt;code&gt;exclusiveMinimum&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes, at compile time&lt;/td&gt;
&lt;td&gt;n/a, can't express it&lt;/td&gt;
&lt;td&gt;no, won't read the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data error precision&lt;/td&gt;
&lt;td&gt;high, phrased for machines&lt;/td&gt;
&lt;td&gt;compile time only&lt;/td&gt;
&lt;td&gt;high, phrased for humans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors good enough to feed back to a model&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knows provider tool-schema rules&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first useful error&lt;/td&gt;
&lt;td&gt;4 minutes&lt;/td&gt;
&lt;td&gt;20+ minutes (rewrite required)&lt;/td&gt;
&lt;td&gt;6 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;JS/TS&lt;/td&gt;
&lt;td&gt;TS&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The split I've settled on isn't a single tool. In Node, author in Zod and emit with &lt;code&gt;z.toJSONSchema()&lt;/code&gt;. Then push the emitted document through Ajv's &lt;code&gt;compile()&lt;/code&gt; inside a test, which proves the thing is still legal after conversion. About 15 lines of test code total. In Python, &lt;code&gt;check_schema()&lt;/code&gt; on anything handwritten and Pydantic on everything the model returns, with &lt;code&gt;.errors()&lt;/code&gt; going directly into the retry prompt.&lt;/p&gt;

&lt;p&gt;There's a fourth check none of the three perform. A schema can be flawless JSON Schema and still be wrong for the endpoint you're posting it to. Anthropic wants it under &lt;code&gt;input_schema&lt;/code&gt;, OpenAI's function tools want it under &lt;code&gt;parameters&lt;/code&gt;, and MCP spells the key &lt;code&gt;inputSchema&lt;/code&gt; in camelCase. Same object, three different homes. Strict mode piles on more: every property has to be listed in &lt;code&gt;required&lt;/code&gt;, and &lt;code&gt;additionalProperties: false&lt;/code&gt; stops being optional. That gap is what I built the &lt;a href="https://aidevhub.io/structured-output-validator/" rel="noopener noreferrer"&gt;structured output validator&lt;/a&gt; to close, because I got tired of learning about it from a 400 response.&lt;/p&gt;

&lt;p&gt;If you're only installing one thing today, install Ajv. It answers the question the other two structurally cannot, and it costs four minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can't I just send the schema and let the API reject it?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Partly. Both Anthropic and OpenAI refuse some malformed definitions at request time, and strict mode refuses more. A keyword with a wrong value type is the case that has slipped past me. Valid-enough JSON to accept, meaningless enough to ignore. And a 400 in staging is a much slower loop than a red test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Ajv catch every schema mistake?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It catches illegal JSON Schema. It has nothing to say about a schema that's legal and wrong, like &lt;code&gt;qty&lt;/code&gt; typed as a string, or a required field the model has never once produced. Sample payloads and evals cover that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Zod or Pydantic for a new agent in 2026?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Whichever language the rest of your service already speaks. Genuinely. I watched a team stand up a Python sidecar purely to get Pydantic errors, and the deploy complexity cost more than the errors were worth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Doesn't constrained decoding make this moot?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It removes parse failures, which is most of the day-to-day pain. It can't tell you your schema encodes the wrong rule. Constrained decoding against &lt;code&gt;exclusiveMinimum: "0"&lt;/code&gt; produces beautifully formatted negative totals.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/structured-output-validator/" rel="noopener noreferrer"&gt;aidevhub.io/structured-output-validator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>programming</category>
      <category>python</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Building an API changelog with GitHub REST API in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 10 Sep 2026 14:00:04 +0000</pubDate>
      <link>https://dev.to/aidevhub/building-an-api-changelog-with-github-rest-api-in-2026-2ah8</link>
      <guid>https://dev.to/aidevhub/building-an-api-changelog-with-github-rest-api-in-2026-2ah8</guid>
      <description>&lt;h1&gt;
  
  
  Building an API changelog with GitHub REST API in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Build an endpoint changelog by fetching two OpenAPI specs through the GitHub REST API, indexing operations by HTTP method and path, and comparing those indexes. The script below produces Markdown you can attach to a release review. It detects added, removed, and modified operations within a deliberately limited scope; compatibility checks need a separate pass.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The OpenAPI Changelog Generator I link to below is one I built. I tried three alternatives that all left me digging through schema changes to assemble an endpoint list. That gap annoyed me. You don't need to upload either spec to follow this walkthrough; the script runs locally. If you have a better one, tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal: a changelog someone can review
&lt;/h2&gt;

&lt;p&gt;The useful output fits in a pull request comment: a heading naming the revisions, followed by a short list of endpoint changes. Something like &lt;code&gt;Added: POST /invoices&lt;/code&gt;, with a removed route immediately below it. A reviewer should be able to scan that list before opening the underlying specification diff.&lt;/p&gt;

&lt;p&gt;As of September 9, 2026, I still prefer a plain Markdown artifact for this job. It survives copying into release notes, and nobody needs access to another dashboard to read it.&lt;/p&gt;

&lt;p&gt;The question we're answering is narrow: which endpoint definitions changed between these two revisions?&lt;/p&gt;

&lt;p&gt;An endpoint here means an HTTP method plus its literal path template. &lt;code&gt;GET /invoices/{id}&lt;/code&gt; and &lt;code&gt;DELETE /invoices/{id}&lt;/code&gt; are separate entries. Renaming &lt;code&gt;{id}&lt;/code&gt; to &lt;code&gt;{invoice_id}&lt;/code&gt; appears as a removal and an addition. That's intentional for this small implementation, although a human might describe it as one rename.&lt;/p&gt;

&lt;p&gt;We're using the GitHub REST API to retrieve repository files at two refs. Python handles the comparison locally. This approach is useful when your specification already lives beside the service code and you want a repeatable release artifact without installing a diff service.&lt;/p&gt;

&lt;p&gt;There's a boundary worth setting early: this script compares operation objects and selected inherited fields. It doesn't resolve schema references or decide whether a consumer will break. A green run means the comparison completed. It says nothing about backward compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup and auth
&lt;/h2&gt;

&lt;p&gt;You'll need Python 3.10 or newer. There are no packages to install.&lt;/p&gt;

&lt;p&gt;Save the code below as &lt;code&gt;changelog.py&lt;/code&gt;. Running &lt;code&gt;python changelog.py&lt;/code&gt; uses two embedded fixtures, so you can inspect the output before touching a repository or creating a credential.&lt;/p&gt;

&lt;p&gt;For repository mode, pass four arguments: repository, specification path, base ref, and target ref. For example, &lt;code&gt;python changelog.py acme/billing-api openapi.json v1.8.2 v1.9.0&lt;/code&gt; works once those names match your repository.&lt;/p&gt;

&lt;p&gt;Use commit SHAs when you need reproducible output. A branch can move between requests, and fetching two files from moving branches gives you a comparison whose inputs may be difficult to reconstruct later.&lt;/p&gt;

&lt;p&gt;Public repositories usually work without authentication, subject to GitHub's unauthenticated rate limit. For a private repository, put a fine-grained token in the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; environment variable and grant it Contents read access to that repository. Organization policies may require approval before the token works.&lt;/p&gt;

&lt;p&gt;Don't paste a token into the script. Set the environment variable through your shell's secret mechanism or your CI platform's secret store. The code sends it in the authorization header and never prints it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://aidevhub.io/openapi-changelog/" rel="noopener noreferrer"&gt;OpenAPI Changelog Generator&lt;/a&gt; is where I use the same two-spec comparison idea for an endpoint-focused review. This tutorial uses GitHub's documented API directly; it doesn't depend on a hosted API for the generator.&lt;/p&gt;

&lt;p&gt;One input constraint saves a surprising amount of setup: both files must be JSON. An OpenAPI document can be YAML, but Python's standard library doesn't include a YAML parser. Convert YAML during your existing build, or extend the loader with a parser you already trust.&lt;/p&gt;

&lt;p&gt;The repository path must also exist at both refs. A renamed specification needs separate paths, which this version intentionally leaves out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core code
&lt;/h2&gt;

&lt;p&gt;The Contents API accepts a &lt;code&gt;ref&lt;/code&gt; query parameter. We request the raw file representation, which avoids decoding the base64 wrapper returned by the default representation.&lt;/p&gt;

&lt;p&gt;The script sorts operation keys so the report stays stable between runs. Dictionary comparison ignores JSON object key order, while list order still affects equality.&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;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.error&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTTPError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;URLError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urlencode&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urlopen&lt;/span&gt;

&lt;span class="n"&gt;METHODS&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;get&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;put&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;post&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;delete&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;options&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;head&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;patch&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;trace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Repository must have the form owner/repo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&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;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;file_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&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;https://api.github.com/repos/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/contents/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ref&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&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;application/vnd.github.raw+json&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;X-GitHub-Api-Version&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;2022-11-28&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-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;endpoint-changelog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GITHUB_TOKEN&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;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# A timeout prevents a stalled fetch from hanging the job indefinitely.
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openapi&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="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expected an OpenAPI 3.x document&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&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;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paths&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;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="c1"&gt;# Resolving Path Item references needs a separate resolver.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&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;Resolve the Path Item reference at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;METHODS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;result&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&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="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;operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path_parameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameters&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;# An explicit empty list disables inherited security.
&lt;/span&gt;                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;changelog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&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;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;- Added: `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;- Removed: `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&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;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;- Modified: `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No changes within the comparison scope.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;demo&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openapi&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;3.0.3&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;info&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;title&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;Billing API&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;version&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;1.0.0&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;paths&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;document&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/invoices&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;get&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;responses&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;200&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;description&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;OK&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;/legacy&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;get&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;responses&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;200&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;description&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;OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}}},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;document&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/invoices&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;get&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;responses&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;200&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;description&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;OK&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;429&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;description&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;Rate limited&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;post&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;responses&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;201&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;description&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;Created&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;demo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo-before&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;demo-after&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;
        &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Usage: changelog.py [owner/repo path base target]&lt;/span&gt;&lt;span class="sh"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;# Endpoint changes: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;changelog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;GitHub returned HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; check access and refs.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URLError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;Changelog failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo should produce three entries. &lt;code&gt;GET /invoices&lt;/code&gt; is modified because its responses now include HTTP 429. &lt;code&gt;POST /invoices&lt;/code&gt; is added. &lt;code&gt;GET /legacy&lt;/code&gt; is removed.&lt;/p&gt;

&lt;p&gt;That makes the fixture useful as a quick sanity check: each branch of the comparison has a visible result. It isn't a substitute for tests against your own document shapes.&lt;/p&gt;

&lt;p&gt;For a Markdown file, redirect stdout with &lt;code&gt;python changelog.py &amp;gt; changelog.md&lt;/code&gt;. Repository mode supports the same redirection. Errors go to stderr and produce a nonzero exit status, so a failed download doesn't masquerade as an empty changelog.&lt;/p&gt;

&lt;p&gt;The script includes path-level parameters because those apply across operations. It also checks inherited security requirements. An explicit &lt;code&gt;security: []&lt;/code&gt; overrides global security, so the lookup must preserve that empty list.&lt;/p&gt;

&lt;p&gt;Notice what happens to descriptions. Editing an operation's description produces a modified entry. That's useful for a literal definition changelog, though it may be noisy for release notes. If you remove documentation fields before comparison, make that policy explicit and apply it recursively where intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this compares with other approaches
&lt;/h2&gt;

&lt;p&gt;GitHub's API supplies versioned inputs. It doesn't understand OpenAPI compatibility. The comparison step determines how much meaning you get from those inputs.&lt;/p&gt;

&lt;p&gt;For a repository-based workflow, these are the tradeoffs I'd actually consider:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Input access&lt;/th&gt;
&lt;th&gt;Meaning of a change&lt;/th&gt;
&lt;th&gt;Dependency cost&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub REST API plus this script&lt;/td&gt;
&lt;td&gt;Repository files at explicit refs&lt;/td&gt;
&lt;td&gt;Literal operation changes within the stated scope&lt;/td&gt;
&lt;td&gt;Python standard library and optional token&lt;/td&gt;
&lt;td&gt;Small endpoint reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Git plus &lt;code&gt;git diff&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Existing checkout and history&lt;/td&gt;
&lt;td&gt;Text changes, including formatting&lt;/td&gt;
&lt;td&gt;Git and a checkout&lt;/td&gt;
&lt;td&gt;Inspecting exact source edits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub REST API plus oasdiff&lt;/td&gt;
&lt;td&gt;Downloaded specs passed to oasdiff&lt;/td&gt;
&lt;td&gt;OpenAPI-aware reports and compatibility checks&lt;/td&gt;
&lt;td&gt;Separate CLI and its configuration&lt;/td&gt;
&lt;td&gt;Release gates and deeper review&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I wouldn't introduce API fetching into a CI job that already has both revisions checked out. Read the files locally and reuse &lt;code&gt;changelog()&lt;/code&gt;. That removes network failure from the comparison step and avoids spending GitHub API quota.&lt;/p&gt;

&lt;p&gt;The API route earns its place in a release helper that operates without a checkout, or in a central job that reads specifications from several repositories.&lt;/p&gt;

&lt;p&gt;For compatibility enforcement, I'd use an established OpenAPI diff engine and review its configuration. Required request properties and response schema changes deserve more analysis than Python object inequality provides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong the first time
&lt;/h2&gt;

&lt;p&gt;My first pass at the comparison was just the operation dictionary. Too narrow.&lt;/p&gt;

&lt;p&gt;Consider a required header defined under the Path Item's &lt;code&gt;parameters&lt;/code&gt;. Every operation under that path inherits it. Comparing only the nested &lt;code&gt;get&lt;/code&gt; or &lt;code&gt;post&lt;/code&gt; value misses the header change completely. Including path-level parameters fixes that omission, although this simple approach can overreport changes when an operation overrides the same parameter.&lt;/p&gt;

&lt;p&gt;Security had a similar trap. Falling back with &lt;code&gt;operation.get("security") or global_security&lt;/code&gt; would treat an empty list as absent. That changes the meaning of the document. The explicit default argument in the code preserves the override.&lt;/p&gt;

&lt;p&gt;The larger unresolved problem is &lt;code&gt;$ref&lt;/code&gt;. If an operation refers to &lt;code&gt;#/components/schemas/Invoice&lt;/code&gt;, changing that component leaves the reference string identical. This script won't report the affected endpoint unless something else in its comparison snapshot changes.&lt;/p&gt;

&lt;p&gt;Don't patch that by copying the entire components object into every operation. One unrelated schema edit would mark every endpoint as modified. Resolving references properly requires dependency tracking, including cycle handling, or an existing comparison engine that already understands those relationships.&lt;/p&gt;

&lt;p&gt;Server URLs are outside this implementation's scope too. So are OpenAPI 3.1 webhooks. Keep those limitations attached to the report if teammates could mistake it for a complete contract review.&lt;/p&gt;

&lt;p&gt;A fetch can also fail before comparison begins. GitHub may return 404 for a private repository your token can't access, so check repository permissions as well as spelling. Large specification files encounter Contents API limits; this loader is intended for ordinary JSON specs, not arbitrary repository blobs.&lt;/p&gt;

&lt;p&gt;Before adding it to a release job, I'd check one real removed endpoint and one change inside a referenced schema. The first should appear. The second demonstrates the current blind spot.&lt;/p&gt;

&lt;p&gt;That's the point where I'd decide whether this endpoint list is enough for the release reviewer or whether the job needs a semantic diff engine. The small script gives you a readable artifact today, with a clear boundary around what it can claim.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/openapi-changelog/" rel="noopener noreferrer"&gt;aidevhub.io/openapi-changelog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>automation</category>
      <category>github</category>
      <category>software</category>
    </item>
    <item>
      <title>Reading Claude message_stream events without guessing in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 08 Sep 2026 14:00:05 +0000</pubDate>
      <link>https://dev.to/aidevhub/reading-claude-messagestream-events-without-guessing-in-2026-14p7</link>
      <guid>https://dev.to/aidevhub/reading-claude-messagestream-events-without-guessing-in-2026-14p7</guid>
      <description>&lt;h1&gt;
  
  
  Reading Claude message_stream events without guessing in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Log the raw SSE frames, then replay them through a parser that tracks &lt;code&gt;content_block&lt;/code&gt; indices. Anthropic's stream is a typed event sequence, so text deltas, &lt;code&gt;input_json_delta&lt;/code&gt; fragments for tool calls, and thinking blocks all arrive interleaved on separate indices. Reconstructing the message means grouping by index, not concatenating in arrival order.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quick disclosure before anything else: the stream event viewer I link to further down is one I built. I'd been pasting SSE dumps into three different generic JSON formatters and a browser devtools panel, and every one of them choked on the fact that a message stream isn't one JSON document, it's a few hundred of them separated by &lt;code&gt;data:&lt;/code&gt; lines. None of them knew what a &lt;code&gt;content_block_delta&lt;/code&gt; was. Mine is free, runs client-side, has no signup, and uploads nothing. If you already use something better, please tell me what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that cost me a Tuesday afternoon
&lt;/h2&gt;

&lt;p&gt;On March 12, 2026 I shipped a streaming endpoint that proxied Claude responses to a web client. It worked in every test I wrote. It broke in production for roughly 1 in 40 requests, and the failures were always the same shape: the assistant's answer would come through with a chunk of JSON spliced into the middle of a sentence.&lt;/p&gt;

&lt;p&gt;My handler was doing the naive thing. For every &lt;code&gt;content_block_delta&lt;/code&gt; it grabbed &lt;code&gt;delta.text&lt;/code&gt; if present, &lt;code&gt;delta.partial_json&lt;/code&gt; otherwise, and appended both to one buffer. That's fine as long as the model produces exactly one content block. The moment it emitted a short text preamble, then a &lt;code&gt;tool_use&lt;/code&gt; block, then more text, my buffer became a blender.&lt;/p&gt;

&lt;p&gt;I spent about 47 minutes staring at the wrong layer. I assumed the SDK was mis-ordering events, or that my reverse proxy was reassembling chunks badly. Neither. The events arrived in perfect order. I was throwing away the one field that mattered, which is &lt;code&gt;index&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What finally fixed it was dumping the raw stream to a file and reading it end to end. Not the parsed objects my code produced, the actual bytes on the wire. That's a boring debugging move and it's the one I keep forgetting to do first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the event sequence actually looks like
&lt;/h2&gt;

&lt;p&gt;A single streamed call from the Messages API is a fixed skeleton with a variable middle. You get &lt;code&gt;message_start&lt;/code&gt; once, carrying the message envelope and the initial &lt;code&gt;usage&lt;/code&gt; object. Then, for each content block, a &lt;code&gt;content_block_start&lt;/code&gt; with an &lt;code&gt;index&lt;/code&gt; and a stub of the block, a run of &lt;code&gt;content_block_delta&lt;/code&gt; events on that same index, and a &lt;code&gt;content_block_stop&lt;/code&gt;. At the end, &lt;code&gt;message_delta&lt;/code&gt; carries &lt;code&gt;stop_reason&lt;/code&gt; and the final &lt;code&gt;output_tokens&lt;/code&gt;, followed by &lt;code&gt;message_stop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The deltas are typed, and the type tells you which field to read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;text_delta&lt;/code&gt; has &lt;code&gt;.text&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input_json_delta&lt;/code&gt; has &lt;code&gt;.partial_json&lt;/code&gt; (a raw string fragment, only valid JSON once the whole block is concatenated)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;thinking_delta&lt;/code&gt; has &lt;code&gt;.thinking&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;signature_delta&lt;/code&gt; closes out an extended-thinking block&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;partial_json&lt;/code&gt; one bites people. Each fragment is a slice of a JSON string, so &lt;code&gt;{"loc&lt;/code&gt; and &lt;code&gt;ation":"&lt;/code&gt; and &lt;code&gt;Berlin"}&lt;/code&gt; arrive as three separate events. Parse them individually and you get an exception. Concatenate them across the block's entire lifetime and you get valid input for the tool call.&lt;/p&gt;

&lt;p&gt;The other thing worth going after is usage accounting. &lt;code&gt;message_start&lt;/code&gt; gives you &lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;cache_creation_input_tokens&lt;/code&gt;, and &lt;code&gt;cache_read_input_tokens&lt;/code&gt;. On a cached run I checked last Tuesday, a request reported 218 input tokens and 14,208 cache read tokens. If you only log &lt;code&gt;input_tokens&lt;/code&gt; you will look at that call and conclude it was nearly free, which is true, but you'll have no idea why, and no way to tell when your cache starts missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the parser puts a call back together
&lt;/h2&gt;

&lt;p&gt;Here's a small script that takes a saved SSE dump and reconstructs the whole call. It's the same logic the viewer runs, minus the UI. Save your stream by writing every raw line from the response body to a file first.&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;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text_parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&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="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&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="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content_block_start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ev&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_block&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;ev&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_block&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;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_use&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;tool_json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content_block_delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&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;d&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="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text_delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;text_parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;d&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="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_json_delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;tool_json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partial_json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}))&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool_json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_parts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;usage&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;python replay.py stream.txt&lt;/code&gt; and you get the assembled text, every tool call's fully-parsed input keyed by block index, the stop reason, and a merged usage object. Fifty lines, no dependencies.&lt;/p&gt;

&lt;p&gt;Two details in there that took me longer than they should have. The &lt;code&gt;line[5:]&lt;/code&gt; slice assumes &lt;code&gt;data:&lt;/code&gt; with no space, which is why the &lt;code&gt;.strip()&lt;/code&gt; follows it. And &lt;code&gt;usage.update()&lt;/code&gt; on &lt;code&gt;message_delta&lt;/code&gt; is deliberate: the final event only carries &lt;code&gt;output_tokens&lt;/code&gt;, so updating rather than replacing keeps the input and cache counts from &lt;code&gt;message_start&lt;/code&gt; intact. I got that backwards on my first pass and every call reported zero input tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it compares to what I tried first
&lt;/h2&gt;

&lt;p&gt;I went through four options before writing my own. The comparison below reflects what each one did with a 900-line dump containing two text blocks and one tool call.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Understands typed events&lt;/th&gt;
&lt;th&gt;Rebuilds tool_use JSON&lt;/th&gt;
&lt;th&gt;Cache token breakdown&lt;/th&gt;
&lt;th&gt;Data stays local&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generic JSON formatter&lt;/td&gt;
&lt;td&gt;No (fails on multi-doc SSE)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Usually&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser devtools EventStream tab&lt;/td&gt;
&lt;td&gt;Shows frames only&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand-rolled &lt;code&gt;jq&lt;/code&gt; pipeline&lt;/td&gt;
&lt;td&gt;With enough effort&lt;/td&gt;
&lt;td&gt;Manual concat&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aidevhub.io/anthropic-stream-event-viewer/" rel="noopener noreferrer"&gt;Anthropic Stream Event Viewer&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, client-side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The devtools EventStream tab is genuinely useful and I still open it, just for a different job. It shows you that frames arrived and in what order. It won't tell you the tool input was truncated because the model hit &lt;code&gt;max_tokens&lt;/code&gt; mid-JSON, which is a real failure mode and shows up as &lt;code&gt;stop_reason: "max_tokens"&lt;/code&gt; with an unparseable &lt;code&gt;partial_json&lt;/code&gt; accumulation.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;jq&lt;/code&gt; route works. I have a 12-line pipeline in a gist that handles text deltas fine. Extending it to group tool JSON by index is where I gave up and wrote actual code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't bother with this
&lt;/h2&gt;

&lt;p&gt;If you're using the official SDK's &lt;code&gt;client.messages.stream()&lt;/code&gt; helper and you never touch tool use, skip all of it. The SDK accumulates the final message for you, exposes &lt;code&gt;.text_stream&lt;/code&gt; for the simple case, and handles indices correctly. Reaching for a raw event parser there is work you don't need.&lt;/p&gt;

&lt;p&gt;Same if your bottleneck is latency rather than correctness. An event viewer tells you what came back, not how fast. For time-to-first-token you want timestamps recorded at the socket, and no post-hoc replay of a saved dump will give you those.&lt;/p&gt;

&lt;p&gt;And if your streams carry customer data under a policy that forbids pasting content into any web page, use the script above locally instead. The viewer runs entirely in your browser and sends nothing anywhere, but "trust me, it's client-side" is not a compliance argument, and I wouldn't expect anyone to accept it as one. Read the network tab or run the 50 lines yourself.&lt;/p&gt;

&lt;p&gt;The case where this pays off is the messy middle: you're building on the raw HTTP API, or through a gateway that reshapes events, or debugging why a tool call sometimes arrives with an empty input object. That last one turned out, for me, to be a proxy that buffered and split SSE frames at 4,096 bytes without respecting event boundaries. I would never have found it from parsed objects.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need to log the raw stream, or can I feed it the SDK's parsed events?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Raw is better. The SDK's accumulated message hides exactly the ordering and index information you're trying to inspect. Write the response body lines to a file before anything parses them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why does &lt;code&gt;partial_json&lt;/code&gt; fail to parse on its own?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Because each delta is an arbitrary byte slice of the tool input JSON, split wherever the token boundary landed. Only the concatenation of every fragment in that block is valid JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What does &lt;code&gt;stop_reason: "tool_use"&lt;/code&gt; mean versus &lt;code&gt;"end_turn"&lt;/code&gt;?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;tool_use&lt;/code&gt; means the model stopped because it wants a tool result back before continuing. Send the result as a &lt;code&gt;tool_result&lt;/code&gt; block in the next user turn. &lt;code&gt;end_turn&lt;/code&gt; means it finished on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Where do cache write and cache read counts show up?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; In the &lt;code&gt;usage&lt;/code&gt; object on &lt;code&gt;message_start&lt;/code&gt;, as &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; and &lt;code&gt;cache_read_input_tokens&lt;/code&gt;. They're separate from &lt;code&gt;input_tokens&lt;/code&gt;, so summing all three gives you the real prompt size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does the viewer work with streams from Bedrock or Vertex?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Mostly. The event types match, though the envelope framing differs by platform, so you may need to strip a wrapper before pasting.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/anthropic-stream-event-viewer/" rel="noopener noreferrer"&gt;aidevhub.io/anthropic-stream-event-viewer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>claude</category>
      <category>llm</category>
    </item>
    <item>
      <title>8 free AI agent tools I use in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 03 Sep 2026 14:00:02 +0000</pubDate>
      <link>https://dev.to/aidevhub/8-free-ai-agent-tools-i-use-in-2026-3255</link>
      <guid>https://dev.to/aidevhub/8-free-ai-agent-tools-i-use-in-2026-3255</guid>
      <description>&lt;h1&gt;
  
  
  8 free AI agent tools I use in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Eight, all browser-based, none of them asking for a signup: agent-skill-validator, skill-scope-collision-detector, skill-payload-budget-optimizer, trace-failure-classifier, tool-approval-matrix-compiler, skill-spec-converter, skill-regression-suite-builder, and skill-release-canary-planner. Between them they cover the unglamorous work of shipping agent skills: checking manifests, catching overlapping triggers, trimming startup context, reading failure traces. Comparison table with dealbreakers is further down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Being upfront: the tools I link to below are ones I built. I tried six existing skill linters in January 2026 and every one of them wanted my repo uploaded to somebody's server before it would tell me a frontmatter field was missing. Mine run client-side, cost nothing, and don't ask for an account or an email address. If you know something better, tell me and I'll happily switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agent skills got messy in 2026
&lt;/h2&gt;

&lt;p&gt;In February I took over a repo with 23 skill definitions in it. Nobody had touched the docs since October. My first bug report was a support agent that kept picking the wrong skill for anything containing the word "invoice", because two separate skills claimed that word in their trigger text and the model just guessed.&lt;/p&gt;

&lt;p&gt;Finding that took me 47 minutes. Fixing it took four seconds.&lt;/p&gt;

&lt;p&gt;That ratio is the whole problem. Agent skills are markdown files with frontmatter. There's no compiler. Nothing type-checks them. You discover a mistake when a production trace goes sideways at 2am, and by then you're squinting at a JSON blob trying to work out which of your 23 markdown files talked the model into calling &lt;code&gt;refund_customer&lt;/code&gt; instead of &lt;code&gt;fetch_invoice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The tooling gap is real and nobody's filling it especially well. Most of what exists is either bundled into a platform you have to adopt wholesale, or it's a $200/month observability product that draws nice graphs of failures you already knew about. What I wanted was &lt;code&gt;eslint&lt;/code&gt; for a folder of markdown. Runs in two seconds, tells me line 238 is broken, needs no account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools I actually open every week
&lt;/h2&gt;

&lt;h3&gt;
  
  
  agent-skill-validator
&lt;/h3&gt;

&lt;p&gt;I run this before commits. Paste a SKILL.md, get back the structural problems: missing &lt;code&gt;description&lt;/code&gt;, a &lt;code&gt;name&lt;/code&gt; that doesn't match its folder, allowed-tools entries pointing at tools that don't exist in your config, YAML that parses cleanly while meaning something other than what you intended.&lt;/p&gt;

&lt;p&gt;The check that earns its keep is the description one. A skill description is the only thing the model sees when deciding whether to load your skill, and roughly half the ones I've inherited read like internal API docs ("Handles the invoice subsystem"). The validator flags descriptions below a length threshold and descriptions containing no trigger language. It's a dumb heuristic. It has also been right every single time for me.&lt;/p&gt;

&lt;p&gt;I was wrong about this tool at first. Frontmatter validation seemed too trivial to bother with. Then I lost a morning to a skill that never loaded because I'd typed &lt;code&gt;allowed_tools&lt;/code&gt; instead of &lt;code&gt;allowed-tools&lt;/code&gt;, and YAML was perfectly happy to hand me a key that nobody read.&lt;/p&gt;

&lt;h3&gt;
  
  
  skill-scope-collision-detector
&lt;/h3&gt;

&lt;p&gt;Paste in every skill description you've got, get back a matrix of which pairs overlap and on which words. This is the one that would have saved me those 47 minutes in February.&lt;/p&gt;

&lt;p&gt;It runs on trigger-term overlap plus a similarity score, so it catches the obvious case (two skills that both say "use this for PDF extraction") and also the sneaky case, where skill A says "customer records" and skill B says "user accounts" and your model treats those as the same concept because of course it does.&lt;/p&gt;

&lt;p&gt;On that 23-skill repo it surfaced 6 collisions. Four were real. Two were fine, because surrounding context disambiguated them. That hit rate is about what I'd expect, and honestly it's plenty. I don't need precision here. I need a short list of things to eyeball.&lt;/p&gt;

&lt;h3&gt;
  
  
  skill-payload-budget-optimizer
&lt;/h3&gt;

&lt;p&gt;Every skill you register costs context before the user has typed anything at all. Nobody tells you the running total, so it creeps.&lt;/p&gt;

&lt;p&gt;Before I built the optimizer I was doing this with a script, which I'll leave here because it's a decent sanity check even if you'd rather not open another browser tab:&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;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Rough context cost of every SKILL.md under a directory, biggest first.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&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;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&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;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SKILL.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\A---.*?^---\s*&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;approx_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;approx_tokens&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&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;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  TOTAL across &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; skills&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;That reported 18,400 tokens across 23 skills on the repo I mentioned. The optimizer got it down to 11,200 without deleting a single skill, mostly by moving verbose examples out of description fields and into skill bodies, where they only load on demand.&lt;/p&gt;

&lt;h3&gt;
  
  
  tool-approval-matrix-compiler
&lt;/h3&gt;

&lt;p&gt;Feed it your skills and your tool list, get a grid of which skill is allowed to call what. Then you stare at the grid and say "hang on, why can the changelog writer call the deploy tool".&lt;/p&gt;

&lt;p&gt;That's the entire value. A read-only view of permissions you already configured, arranged so a human can spot the wrong ones. I found two over-broad grants in about a minute, both of them me being lazy months earlier and pasting an allowed-tools list from one skill into another.&lt;/p&gt;

&lt;p&gt;If you're shipping agents that touch anything with side effects, do this once a quarter. It's boring and takes ten minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  trace-failure-classifier
&lt;/h3&gt;

&lt;p&gt;Paste a failed agent trace, get the failure bucketed: wrong skill selected, right skill with bad arguments, tool errored and the model carried on regardless, model looped, context truncated mid-task.&lt;/p&gt;

&lt;p&gt;Those buckets matter more than they sound like they should. "The agent failed" isn't actionable. "The agent picked the right skill, then passed a date in the wrong format three times without noticing the tool error" tells you which line to open.&lt;/p&gt;

&lt;p&gt;I've fed it around 60 traces since April. It lands the right bucket most of the time, and when it's unsure it says so rather than confidently inventing a story, which I appreciate more than I expected to.&lt;/p&gt;

&lt;h2&gt;
  
  
  All eight, 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;Tool&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Pricing&lt;/th&gt;
&lt;th&gt;The one dealbreaker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;agent-skill-validator&lt;/td&gt;
&lt;td&gt;Pre-commit frontmatter checks&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;Structural checks only, semantics are on you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skill-scope-collision-detector&lt;/td&gt;
&lt;td&gt;Finding overlapping trigger text&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;Needs every description pasted at once, no repo crawl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skill-payload-budget-optimizer&lt;/td&gt;
&lt;td&gt;Cutting startup context cost&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;Token counts are approximate, not per-model exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trace-failure-classifier&lt;/td&gt;
&lt;td&gt;Triaging production agent failures&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;One trace at a time, no batch mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tool-approval-matrix-compiler&lt;/td&gt;
&lt;td&gt;Auditing which skill calls what&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;Shows you the problem, you apply the fix by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skill-spec-converter&lt;/td&gt;
&lt;td&gt;Moving skills between agent frameworks&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;Round trips drop custom fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skill-regression-suite-builder&lt;/td&gt;
&lt;td&gt;Generating test cases from a spec&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;Generated cases need a human pass before they're worth running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skill-release-canary-planner&lt;/td&gt;
&lt;td&gt;Staging a skill rollout across users&lt;/td&gt;
&lt;td&gt;Free, client-side&lt;/td&gt;
&lt;td&gt;Assumes you can already segment traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All eight sit on one page at &lt;a href="https://aidevhub.io/tools/ai/" rel="noopener noreferrer"&gt;aidevhub.io/tools/ai&lt;/a&gt;, which is where I keep the ones I bookmark. The bottom three in that table get pulled out maybe monthly, so they didn't earn a section above. The spec converter in particular is a thing you use twice and forget until the next migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I ditched
&lt;/h2&gt;

&lt;p&gt;A hosted eval platform. $91.64 a month including tax, an odd enough number that I remember it exactly. I ran it from December 2025 through March 2026 and cancelled on a Tuesday afternoon while looking at the invoice.&lt;/p&gt;

&lt;p&gt;It was a good product. It answered a question I didn't have. It could tell me my agent's success rate slid from 94% to 89% over a week. Fine. What it couldn't tell me was that the slide happened because someone added a skill whose description overlapped an existing one, which is exactly what had happened, twice.&lt;/p&gt;

&lt;p&gt;Most agent observability tooling right now measures outcomes at a level of abstraction too high to act on. I need to know which markdown file to edit. A line trending downward doesn't get me there, and paying $91.64 a month for that line felt worse every time I opened it.&lt;/p&gt;

&lt;p&gt;I dropped my own bash-and-python token counter too, the ancestor of the script above, once I got tired of maintaining an estimator that ran about 15% off in a direction I couldn't predict. Keeping it in this post regardless, since it's a fair first thing to run on a repo you've just inherited.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do these upload my skill files anywhere?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. They run inside the browser tab. I built them that way because I wasn't allowed to paste work skill definitions into a third-party server, and I assume plenty of people are in the same spot. Open devtools and watch the network panel if you want to verify that, it's a reasonable thing to check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do they work with skills that aren't Claude Code skills?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Mostly. The validator and the collision detector care about frontmatter and description text, which most agent frameworks have in some shape. The spec converter exists specifically for moving between formats. The approval matrix compiler assumes a per-skill tool allowlist, so if your framework handles permissions globally it won't tell you much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How accurate is the token counting?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Close enough to make decisions with. Too rough to bill against. It's a character-based approximation, so it drifts a few percent on text heavy with code or non-English content. For exact figures, run the text through your provider's tokenizer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Fastest way to get value out of this list on an existing repo?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Collision detector first, payload budget second. Those two find problems that are already costing you something today. Validation is a pre-commit habit, and it pays off going forward rather than retroactively.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/tools/ai/" rel="noopener noreferrer"&gt;aidevhub.io/tools/ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Generating 10,000 UUIDs without leaving the browser in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:00:06 +0000</pubDate>
      <link>https://dev.to/aidevhub/generating-10000-uuids-without-leaving-the-browser-in-2026-5ap7</link>
      <guid>https://dev.to/aidevhub/generating-10000-uuids-without-leaving-the-browser-in-2026-5ap7</guid>
      <description>&lt;h1&gt;
  
  
  Generating 10,000 UUIDs without leaving the browser in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Use crypto.randomUUID() in any modern browser console for quick v4s, and a client-side bulk generator when you need thousands at once or UUID v7 ordering. v4 is 122 random bits. v7 puts a 48-bit Unix timestamp up front, so rows sort by creation time and your database index stays happy. For new primary keys in 2026, default to v7.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quick disclosure: the UUID generator I link to below is one I built. Back in May I tried seven online generators during a database migration, and every one either capped bulk output at 100, skipped v7 entirely, or buried the copy button under ads. Mine is free and runs entirely client-side. No signup, and nothing you generate ever leaves your machine. If you know a better one, tell me in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 11pm seed script that started this
&lt;/h2&gt;

&lt;p&gt;Three weeks ago, on August 5th, I was putting together a demo environment that needed 8,500 fixture rows spread across four tables. The schema uses UUID primary keys, and the fixture data lives in a spreadsheet a teammate on the solutions side maintains. So I needed 8,500 UUIDs in a spreadsheet column before the next morning.&lt;/p&gt;

&lt;p&gt;The terminal was my first stop. &lt;code&gt;for i in {1..8500}; do uuidgen; done&lt;/code&gt; runs fine, but macOS prints uppercase UUIDs while our snapshot tests normalize everything to lowercase, so the first diff was thousands of lines of pointless noise. Piping through &lt;code&gt;tr '[:upper:]' '[:lower:]'&lt;/code&gt; fixed that. Then my teammate had to regenerate two of the tables the next day on a Windows laptop with no WSL, where uuidgen doesn't exist, and my clever one-liner helped nobody. Total damage: 47 minutes on a task that deserved 30 seconds.&lt;/p&gt;

&lt;p&gt;There was a second, quieter problem. Those were all v4 UUIDs, which are pure randomness, and random primary keys scatter inserts across the whole index. Each new row lands on a random B-tree page, so caches stay cold and large tables grow bloated indexes. UUID v7, standardized in RFC 9562 back in May 2024, fixes this by putting a millisecond timestamp in the first 48 bits, so new keys always sort after old ones and inserts append instead of scattering. Postgres 18 shipped a native uuidv7() function in September 2025, and at this point I treat v7 as the boring default for any new table.&lt;/p&gt;

&lt;p&gt;What surprised me is that most online generators still don't offer v7 at all. That gap is why this article, and the tool it reviews, exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's inside a UUID v7, and how to build one
&lt;/h2&gt;

&lt;p&gt;The layout is simple. First 48 bits: Unix timestamp in milliseconds. Then 4 version bits, 12 random bits, 2 variant bits, and 62 more random bits. That leaves 74 bits of randomness per millisecond, which is plenty for any workload I've ever touched.&lt;/p&gt;

&lt;p&gt;Here's a complete v7 implementation that runs in any modern browser console or in Node 19 and newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uuidv7&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mh"&gt;0x70&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// version 7&lt;/span&gt;
  &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x3f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// RFC 9562 variant&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;uuidv7&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// 01a04a2e-9d10-7c3b-a4f2-5b8e19c0d67d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every v7 you generate this month starts with the same few hex characters (01a0 and change, if you're reading this in August 2026). That's the timestamp doing its job. Sort v7s as plain strings and you've sorted them by creation time, which is the entire trick.&lt;/p&gt;

&lt;p&gt;Give or take a counter for ordering within the same millisecond, this is exactly what the generator at &lt;a href="https://aidevhub.io/uuid-generator/" rel="noopener noreferrer"&gt;aidevhub.io/uuid-generator&lt;/a&gt; runs when you click generate. Everything happens client-side; the page never phones home with your output. You pick v4 or v7 and a count up to 10,000, then choose the format: lowercase or uppercase, hyphens or none, plain lines or a JSON array, and braces if you need the old Microsoft GUID registry style. Generating the full 10,000 takes about 40 milliseconds on my 2023 MacBook Air because there's no server round trip. My spreadsheet mess from August is now one copy button.&lt;/p&gt;

&lt;p&gt;I'll admit I went back and forth on the bulk cap. 10,000 felt arbitrary. It still does, honestly, but every real use case I collected fit under it, and an unbounded loop in a browser tab is a crash waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it stacks up against what you already have
&lt;/h2&gt;

&lt;p&gt;You almost never need a website to mint one UUID. The interesting question is what to reach for when you need many of them, or v7 specifically, or a particular output format.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;v7 support&lt;/th&gt;
&lt;th&gt;Bulk output&lt;/th&gt;
&lt;th&gt;Format control&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;aidevhub UUID Generator&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Up to 10,000&lt;/td&gt;
&lt;td&gt;Case, hyphens, braces, JSON&lt;/td&gt;
&lt;td&gt;Your browser, client-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uuidgen (macOS/Linux)&lt;/td&gt;
&lt;td&gt;Not on macOS&lt;/td&gt;
&lt;td&gt;Shell loop&lt;/td&gt;
&lt;td&gt;tr and sed by hand&lt;/td&gt;
&lt;td&gt;Local terminal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;npm uuid package&lt;/td&gt;
&lt;td&gt;Yes (since v10)&lt;/td&gt;
&lt;td&gt;Yes, in code&lt;/td&gt;
&lt;td&gt;Whatever you write&lt;/td&gt;
&lt;td&gt;Node or a bundler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical ad-supported sites&lt;/td&gt;
&lt;td&gt;Sometimes&lt;/td&gt;
&lt;td&gt;Often capped at 100&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;Their server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Inside application code, the npm &lt;code&gt;uuid&lt;/code&gt; package is the right answer, full stop. It's supported v7 since version 10 came out in June 2024, and it handles same-millisecond ordering with an internal counter, which my 15-line snippet above doesn't bother with. IDs born in a service should be minted by that service.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;uuidgen&lt;/code&gt; is great when you're already in a terminal and want one ID. Newer util-linux builds can emit v7, but the macOS version tops out at random v4s (in uppercase, for reasons I've never understood), and a stock Windows machine doesn't ship it at all. Bulk means writing a loop plus a &lt;code&gt;tr&lt;/code&gt; pipeline, which is exactly the 47-minute hole I fell into.&lt;/p&gt;

&lt;p&gt;The ad-supported generator sites do work. My gripes are the caps (100 per click was the common ceiling when I surveyed seven of them in May), the thin v7 support, and the fact that your IDs get minted on someone else's server. That last one is mostly aesthetic, since a random identifier isn't a secret, though it does rule out offline use and it makes some corporate proxies grumpy.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a browser generator is the wrong call
&lt;/h2&gt;

&lt;p&gt;Don't pre-generate IDs for production inserts. If your application creates rows, the ID should be minted at insert time by the app or the database, where a library can guarantee uniqueness and monotonic ordering. A static list of UUIDs pasted into production code is a smell. Fixture files and one-off imports are the browser tool's territory; live traffic isn't.&lt;/p&gt;

&lt;p&gt;Don't use UUIDs as secrets, either. A v4 has 122 random bits, which sounds like enough, but session tokens deserve a dedicated generator with no structural bits and a shape that secret scanners recognize. And v7 is actively worse for anything sensitive because it embeds its own creation time. Anyone who sees the ID can read when the row was made. I honestly don't know how much that matters for a typical app. My instinct says it's harmless for orders and uploads, and wrong for rows where the creation time is itself private, like medical records. When in doubt there, use v4.&lt;/p&gt;

&lt;p&gt;If you need deterministic IDs, where the same input always produces the same UUID, you're looking for v5 with a namespace. That's hashing, and no random generator (mine included) can help you.&lt;/p&gt;

&lt;p&gt;And if you work somewhere locked down enough that visiting a web page is a compliance conversation, offline &lt;code&gt;uuidgen&lt;/code&gt; is still your friend. The tool keeps working with the network cable pulled, since it's all client-side JavaScript, but I've learned the hard way that policy doesn't always care about implementation details.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need to worry about v4 collisions?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. You'd need to generate about 103 trillion v4 UUIDs before the odds of a single duplicate reach one in a billion. If you ever see a real duplicate in the wild, the cause is a bug somewhere, like a copied row or a cloned VM with a frozen entropy pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I migrate existing v4 primary keys to v7?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Almost certainly no. The index-locality win applies to new writes, and rewriting millions of existing keys (plus every foreign key that references them) is a migration with real risk and little payoff. Use v7 for new tables and let the old ones be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What's the database support story in 2026?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Postgres 18 has native uuidv7(). Older Postgres versions store v7 in the regular uuid type without complaint, so generate the values in your application. MySQL's UUID() still emits v1, so there you'd generate v7 in code as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is a GUID different from a UUID?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Same 128 bits. GUID is Microsoft's older name for it, traditionally printed uppercase inside braces, which is why the format controls include a braces option.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/uuid-generator/" rel="noopener noreferrer"&gt;aidevhub.io/uuid-generator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop breaking prod with a .env file manager in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:00:06 +0000</pubDate>
      <link>https://dev.to/aidevhub/stop-breaking-prod-with-a-env-file-manager-in-2026-8aj</link>
      <guid>https://dev.to/aidevhub/stop-breaking-prod-with-a-env-file-manager-in-2026-8aj</guid>
      <description>&lt;h1&gt;
  
  
  Stop breaking prod with a .env file manager in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Run your .env file through a real parser before it ships. Most .env disasters come from quoting rules that differ between dotenv and the shell. A client-side env file manager checks every line against explicit grammar rules and shows you what will break before you deploy. It also converts between JSON, YAML, Docker, and shell export formats, and it runs entirely in your browser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quick disclosure: the Env File Manager I link to below is one I built. I tried five online .env converters back in January and every single one shipped my paste off to a server (check the network tab, it's grim). Mine is free and fully client-side. There's no signup, and nothing you paste leaves the browser. If you know a better one, tell me in the comments and I'll link it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deploy that made me care about quoting rules
&lt;/h2&gt;

&lt;p&gt;Back on July 14th, around 11pm, I deployed a change that rotated a Redis password. The new one came out of a password generator with a # in it. Locally everything passed, because python-dotenv only treats # as a comment when there's a space in front of it. In the container, where a Node service loaded the same file through the dotenv package, the value got cut off at the hash. Auth failures, but only in one service, and only after the pods recycled.&lt;/p&gt;

&lt;p&gt;I spent 51 minutes bisecting a deploy that contained no bad code. The problem was line 17 of a 43-line .env file, and nothing in our pipeline considered it a problem. That's the part that stung. Every parser behaved exactly as its own docs said it would. They just don't agree with each other.&lt;/p&gt;

&lt;p&gt;There's no spec for .env files. None. The format is folklore that Node's dotenv, python-dotenv, Ruby's dotenv, docker run --env-file, Docker Compose, and plain shell &lt;code&gt;source&lt;/code&gt; each retell a little differently. Quoting, inline comments, variable expansion, multiline values: each of those behaves differently somewhere, and the differences only surface at runtime.&lt;/p&gt;

&lt;p&gt;My first workaround was a pre-commit grep for suspicious characters. It false-positived constantly and nobody maintained it past week two, including me. My second workaround was "just double-quote everything," which is how I discovered that docker run --env-file keeps the quote characters as part of the value. We'll get to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a strict .env parser actually checks
&lt;/h2&gt;

&lt;p&gt;Here's the folk-wisdom way to load a .env file into your shell, next to what actually happens. This runs on any machine with bash and Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; demo.env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
APP_NAME="my app"
REDIS_PASS=Tr0ub4dor#42
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# The Stack Overflow classic:&lt;/span&gt;
&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^#'&lt;/span&gt; demo.env | xargs&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# prints: my&lt;/span&gt;
&lt;span class="c"&gt;# xargs stripped the quotes, then word splitting ate the rest&lt;/span&gt;

&lt;span class="c"&gt;# Docker does the opposite and keeps quotes as literal characters:&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--env-file&lt;/span&gt; demo.env alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "$APP_NAME"'&lt;/span&gt;
&lt;span class="c"&gt;# prints: "my app"&lt;/span&gt;
&lt;span class="c"&gt;# the quote characters are now part of the value inside the container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same two-line file, and we're already at two contradictory readings before any application code runs. Node's dotenv adds a third: it truncates REDIS_PASS to Tr0ub4dor, because an unquoted # starts a comment there. python-dotenv keeps the full value, because the # has no space before it. Four consumers, four opinions about two lines.&lt;/p&gt;

&lt;p&gt;A strict parser turns those ambient rules into visible ones. I got tired of holding them in my head, so I built &lt;a href="https://aidevhub.io/env-file-manager/" rel="noopener noreferrer"&gt;Env File Manager&lt;/a&gt; to hold them for me. You paste a .env file (or JSON or YAML, if you're converting the other way) and it parses every line against an explicit grammar. Then it flags what will hurt you later: unquoted values containing #, duplicate keys (most loaders silently keep the last one), CRLF line endings from a Windows teammate, a stray BOM at the top of the file, spaces around the equals sign. You can edit values in a table view, see the raw text next to the decoded value, and fix things in place.&lt;/p&gt;

&lt;p&gt;Under the hood it keeps one internal representation per entry: the key, the raw text, the decoded value, and any attached comment. Each export format then gets its own serializer that applies that format's escaping rules on the way out, which is the whole point. When you export for docker run, quotes get dropped because Docker would keep them literally. When you export a shell script, values get single-quoted with proper escaping, so &lt;code&gt;MSG=hello world&lt;/code&gt; can't end up executing &lt;code&gt;world&lt;/code&gt; as a command. YAML export quotes values like &lt;code&gt;on&lt;/code&gt; and &lt;code&gt;no&lt;/code&gt; so they don't silently turn into booleans.&lt;/p&gt;

&lt;p&gt;I don't know why Docker never made --env-file parse quotes the way Compose does. I assume it's backwards compatibility, but I couldn't find a definitive answer in their issue tracker, and I did look for a whole evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it stacks up against the usual suspects
&lt;/h2&gt;

&lt;p&gt;The honest comparison is that most tools in this space solve adjacent problems, and I use two of them alongside my own thing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Converts formats&lt;/th&gt;
&lt;th&gt;Flags bad lines&lt;/th&gt;
&lt;th&gt;Where your values go&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Env File Manager&lt;/td&gt;
&lt;td&gt;browser-based editor&lt;/td&gt;
&lt;td&gt;.env, JSON, YAML, Docker, shell&lt;/td&gt;
&lt;td&gt;yes, per line, with reasons&lt;/td&gt;
&lt;td&gt;nowhere, it's client-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dotenvx&lt;/td&gt;
&lt;td&gt;CLI and runtime loader&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;only at decrypt time&lt;/td&gt;
&lt;td&gt;encrypted file in your repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;direnv&lt;/td&gt;
&lt;td&gt;shell hook&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;plaintext .envrc on disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doppler&lt;/td&gt;
&lt;td&gt;hosted secrets manager&lt;/td&gt;
&lt;td&gt;via CLI templates&lt;/td&gt;
&lt;td&gt;schema checks, server-side&lt;/td&gt;
&lt;td&gt;their servers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;dotenvx is what I'd pick if the goal is committing encrypted .env files to the repo, and its runtime loader is genuinely solid. direnv does a different job: per-directory shell environments that happen to touch the same file format. Doppler and its hosted cousins solve team sync and rotation, which a browser tool never will and shouldn't pretend to. There are also a dozen paste-your-env converter sites out there; the five I tried in January all POSTed the textarea contents to a backend, which is how this whole project started.&lt;/p&gt;

&lt;p&gt;The gap I kept hitting sits between all of those: the ten minutes where a config has to cross a format boundary without anything getting mangled. Last month I had to turn a 28-key .env file into the env: block of a Kubernetes manifest. Doing that by hand is pure transcription, and transcription is where the one missed quote lives. Paste, convert, review the warnings, copy out. That's the whole workflow, and that's all it's trying to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't bother
&lt;/h2&gt;

&lt;p&gt;If you're already on a proper secrets manager, keep going. In that world, .env files are a build artifact your tooling generates, and a human editing one by hand is the anti-pattern. A formatter doesn't fix a process problem.&lt;/p&gt;

&lt;p&gt;Skip it in CI too. A browser tool has no business inside a pipeline. If you need conversion in automation, write the five lines of Python against python-dotenv and pin the version, so the parsing rules can't drift underneath you between runs. Reproducibility beats convenience there every time.&lt;/p&gt;

&lt;p&gt;Multiline private keys are a maybe. The tool converts them fine (quoted multiline blocks and \n escapes both work), but I think PEM blobs in environment variables are a smell regardless of tooling. Mount them as files and pass a path instead.&lt;/p&gt;

&lt;p&gt;And if your entire config is four keys of plain alphanumerics, honestly, anything works. vim works. Don't add ceremony to a file that can't break.&lt;/p&gt;

&lt;p&gt;I'll admit I still hand-edit .env files for one-key changes. The tool earns its keep at boundaries, when a file changes format or changes hands, and pretending otherwise would be marketing.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is it safe to paste real secrets into a browser tool?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; The parsing is client-side, and you can verify that yourself: open devtools and watch the network tab, or go offline before you paste. I paste real files into it, but I built the thing, so I'm biased. Audit it, and rotate anything truly radioactive out of habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why did my value show up inside the container with literal quote marks?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; You used docker run --env-file with a quoted value. That flag skips quote parsing entirely and takes everything after the first equals sign verbatim. Export a Docker-targeted version of the file with the quotes removed and it goes away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What's the difference between .env format and shell export format?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; A shell script needs the export prefix and full shell quoting. &lt;code&gt;MSG=hello world&lt;/code&gt; is a valid-ish .env line, but sourced as shell it sets MSG to "hello" and then tries to run &lt;code&gt;world&lt;/code&gt; as a command. Converting between the two is exactly where escaping bugs breed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does it handle variable expansion like ${HOST}?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; It parses and preserves the reference, then warns you about it, because dotenv-expand, Compose, and the shell each expand at different times with different rules. I'd rather flag it and let you decide than guess wrong quietly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/env-file-manager/" rel="noopener noreferrer"&gt;aidevhub.io/env-file-manager&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>softwareengineering</category>
      <category>tooling</category>
    </item>
    <item>
      <title>LangChain vs LlamaIndex vs Chonkie: same 94-page PDF</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:00:03 +0000</pubDate>
      <link>https://dev.to/aidevhub/langchain-vs-llamaindex-vs-chonkie-same-94-page-pdf-4a58</link>
      <guid>https://dev.to/aidevhub/langchain-vs-llamaindex-vs-chonkie-same-94-page-pdf-4a58</guid>
      <description>&lt;h1&gt;
  
  
  LangChain vs LlamaIndex vs Chonkie: same 94-page PDF
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;LlamaIndex. Its SentenceSplitter gave me the best recall@5 (0.86, against 0.79 for LangChain's recursive splitter) on a 94-page policy PDF, without writing a custom separator list. LangChain wins if you're already deep in LCEL. Chonkie is the fastest by a wide margin and the one I'd pick for a batch job over a million documents. Chunk size mattered more than the library did.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quick disclosure before anything else: the RAG chunk size calculator I link to below is one I built. I got tired of re-deriving the same token math in a scratch file, and the four existing pages I found all assumed OpenAI's tokenizer and 1,000-character chunks. Mine is free, runs entirely in your browser, no signup, nothing uploaded. If you know a better one, tell me and I'll link it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: one 94-page policy PDF and 38 questions
&lt;/h2&gt;

&lt;p&gt;Last Tuesday I got handed a commercial property insurance policy and a support inbox. The ask was ordinary: answer questions like "what's the deductible for wind damage during a named storm" without a human reading 94 pages every time.&lt;/p&gt;

&lt;p&gt;I pulled the text with &lt;code&gt;pdftotext -layout&lt;/code&gt;, which gave me 214,883 characters. Then I sat down and wrote 38 questions by hand, each paired with a "needle" string that appears on exactly one page. That labelling took 47 minutes and it's the only reason any number below means anything. A chunking benchmark without labels is just vibes with decimal places.&lt;/p&gt;

&lt;p&gt;Same setup for every run: &lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt; as the embedding model, normalized vectors, cosine similarity, top 5 results. The metric is recall@5, the fraction of questions where at least one of the top 5 chunks contains the needle.&lt;/p&gt;

&lt;p&gt;Here's the scoring script. No framework, no vector database, just numpy:&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;# eval_chunks.py - score a chunker by recall@5 on hand-labelled questions
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-small-en-v1.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;qv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;q&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qv&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cv&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;split_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;split_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; chunks=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  split=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s  recall@5=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;questions.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_text_splitters&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;
    &lt;span class="n"&gt;lc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_tiktoken_encoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langchain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;llama_index.core.node_parser&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceSplitter&lt;/span&gt;
    &lt;span class="n"&gt;li&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceSplitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llamaindex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chonkie&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RecursiveChunker&lt;/span&gt;
    &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RecursiveChunker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chonkie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;ch&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of output. Then you get to argue with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangChain: I was wrong about this one for an hour
&lt;/h2&gt;

&lt;p&gt;First run, LangChain came dead last. 0.44 recall against LlamaIndex's 0.86. That gap felt too big to be real, and it wasn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RecursiveCharacterTextSplitter(chunk_size=512)&lt;/code&gt; counts characters. &lt;code&gt;SentenceSplitter(chunk_size=512)&lt;/code&gt; counts tokens. Same parameter name, same value, and for English prose that's roughly a 4x difference in how much text lands in each chunk. My careful apples-to-apples comparison was quietly pitting 512-character chunks against chunks of about 2,000 characters. I'd made this exact mistake in April on a different project and still walked straight into it again.&lt;/p&gt;

&lt;p&gt;Switching to &lt;code&gt;RecursiveCharacterTextSplitter.from_tiktoken_encoder(chunk_size=512, chunk_overlap=64)&lt;/code&gt; fixed it: 137 chunks, 0.79 recall, 2.8 seconds. Perfectly respectable.&lt;/p&gt;

&lt;p&gt;The thing that still bugs me is what that helper counts with. It pulls in tiktoken and defaults to &lt;code&gt;cl100k_base&lt;/code&gt;, an OpenAI tokenizer. I'm embedding with a BERT-family model that uses WordPiece. On plain English the two agree within about 8%, so nothing explodes, but on code, JSON blobs or non-Latin scripts the drift gets ugly and your "512-token" chunks start overflowing a 512-token encoder. Silent truncation, no warning.&lt;/p&gt;

&lt;p&gt;Import paths are also a mess if you're following an older tutorial. It's &lt;code&gt;langchain_text_splitters&lt;/code&gt; now, and half the search results still say &lt;code&gt;from langchain.text_splitter import&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  LlamaIndex: the boring one that won
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SentenceSplitter&lt;/code&gt; at 512 tokens with 64 overlap: 129 chunks, 0.86 recall, 4.1 seconds. It was the slowest of the three on a single document because it actually runs a sentence tokenizer before doing anything else.&lt;/p&gt;

&lt;p&gt;That sentence tokenizer is the whole reason it won. Insurance definitions read like "Named Storm means any storm or weather disturbance that is named by the National Weather Service." Cut that in the middle and neither half retrieves for a question about named storms. The recursive splitters get this right most of the time via their separator list, but "most of the time" across 129 chunks is a handful of dead ones.&lt;/p&gt;

&lt;p&gt;Two things I didn't love. The install is heavy: &lt;code&gt;llama-index-core&lt;/code&gt; was 41 MB in a fresh venv against 2.9 MB for chonkie. If chunking is the only thing you want, that's a lot of framework to carry.&lt;/p&gt;

&lt;p&gt;Bigger issue is the defaults. &lt;code&gt;SentenceSplitter()&lt;/code&gt; with no arguments is chunk_size=1024, chunk_overlap=200. On my document that scored 0.68. Nobody's default is tuned for your corpus, and this one is off by enough to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chonkie: fast, small, and it surprised me
&lt;/h2&gt;

&lt;p&gt;Chonkie is the small library in this comparison and I expected it to lose. It didn't. &lt;code&gt;RecursiveChunker&lt;/code&gt; at 512 tokens gave 133 chunks and 0.82 recall in 0.38 seconds. That's within noise of LlamaIndex on quality and roughly 10x faster.&lt;/p&gt;

&lt;p&gt;Speed stops being an academic concern once the corpus grows. I ran all three over the client's full document set (1,240 files, 2.1 GB of extracted text). Chonkie finished in 47 seconds. LangChain took 3 minutes 4 seconds. LlamaIndex took 6 minutes 12 seconds. For a one-time ingest, who cares. For a nightly re-index that has to finish before the morning, that's the difference between a cron job you forget about and a Slack alert at 2am.&lt;/p&gt;

&lt;p&gt;I also tried &lt;code&gt;SemanticChunker&lt;/code&gt;, which embeds each sentence and splits where similarity drops. 0.84 recall, 11.3 seconds on one document. Thirty times the cost for two points I can't distinguish from measurement noise on 38 questions. On a genuinely mixed corpus it might earn its keep. Here it didn't.&lt;/p&gt;

&lt;p&gt;The rough edge is polish. The API moved between the version most blog posts describe and the 0.5.x I installed, so half the snippets I found were wrong. And when I passed a tokenizer name it didn't recognise, I got a bare &lt;code&gt;KeyError&lt;/code&gt; out of a dict lookup with no hint about what the valid names are. That cost me twenty minutes I'd rather have spent elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scores, and which one I'd actually ship
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;LangChain&lt;/th&gt;
&lt;th&gt;LlamaIndex&lt;/th&gt;
&lt;th&gt;Chonkie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best recall@5, 38 questions&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.86&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What &lt;code&gt;chunk_size&lt;/code&gt; counts by default&lt;/td&gt;
&lt;td&gt;characters&lt;/td&gt;
&lt;td&gt;tokens&lt;/td&gt;
&lt;td&gt;tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk one 94-page doc&lt;/td&gt;
&lt;td&gt;2.8s&lt;/td&gt;
&lt;td&gt;4.1s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.38s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk 1,240 docs&lt;/td&gt;
&lt;td&gt;3m 04s&lt;/td&gt;
&lt;td&gt;6m 12s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install size, fresh venv&lt;/td&gt;
&lt;td&gt;8.4 MB&lt;/td&gt;
&lt;td&gt;41 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.9 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error on a bad tokenizer name&lt;/td&gt;
&lt;td&gt;named ValueError&lt;/td&gt;
&lt;td&gt;named ValueError&lt;/td&gt;
&lt;td&gt;bare KeyError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Found the answer in docs under 2 min&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Defaults I'd ship unchanged&lt;/td&gt;
&lt;td&gt;no (characters)&lt;/td&gt;
&lt;td&gt;no (1024/200)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'd use LlamaIndex's &lt;code&gt;SentenceSplitter&lt;/code&gt; for anything under roughly ten thousand documents where answer quality is the point. Above that, or anywhere re-indexing runs on a schedule, Chonkie. If your pipeline already lives in LangChain, stay there and just call &lt;code&gt;from_tiktoken_encoder&lt;/code&gt; explicitly, because the character default will bite you and it won't be loud about it.&lt;/p&gt;

&lt;p&gt;Here's the finding I keep coming back to, though. Across the three libraries at their best settings the spread was 0.07. Across chunk sizes with a single library, it was 0.18: 256 tokens scored 0.74, 512 scored 0.86, 768 scored 0.81, 1024 scored 0.68. The parameter beat the vendor by more than double. If you're agonising over which splitter to import before you've swept chunk size, you're optimising the wrong variable.&lt;/p&gt;

&lt;p&gt;That gap is why I built the &lt;a href="https://aidevhub.io/rag-chunk-calculator/" rel="noopener noreferrer"&gt;RAG chunk size calculator&lt;/a&gt;. Feed it your embedding model and the kind of document you're indexing, and it hands back a starting chunk size and overlap along with the token math (context window, characters per token for your tokenizer, how many chunks that means for a document of size N). It's a starting point, not an oracle. You still need your own 30 labelled questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does overlap actually help, or is it cargo cult?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; It helps, less than people assume. Zero overlap scored 0.79, 64 tokens scored 0.86, 128 tokens scored 0.87 while producing 22% more chunks to store and search. I settled on 64, about 12.5% of chunk size, and that ratio has held up on two other projects since.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why not just use semantic chunking everywhere?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Because on this document it bought 0.02 recall for 30x the processing time. Semantic chunking pays off when a single file jumps between unrelated topics. A structured policy document already has that structure in its headings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Will these numbers hold for my documents?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Almost certainly not, and I'd be suspicious of anyone who told you otherwise. Legal and insurance prose is dense, repetitive and full of defined terms, which flatters sentence-aware splitting. Chat logs or source code behave differently. Copy the script above, label 30 questions of your own, rerun it. Mine took 47 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What about markdown and code files?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Different tools. Use a header-aware splitter for markdown so you keep section context attached, and Chonkie's &lt;code&gt;CodeChunker&lt;/code&gt; (or a tree-sitter based splitter) for source, so functions stay whole. Splitting code on blank lines destroys exactly the boundaries you want to retrieve on.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/rag-chunk-calculator/" rel="noopener noreferrer"&gt;aidevhub.io/rag-chunk-calculator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>rag</category>
    </item>
    <item>
      <title>A valid sitemap.xml for 1,247 URLs with a free generator in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:00:03 +0000</pubDate>
      <link>https://dev.to/aidevhub/a-valid-sitemapxml-for-1247-urls-with-a-free-generator-in-2026-4f15</link>
      <guid>https://dev.to/aidevhub/a-valid-sitemapxml-for-1247-urls-with-a-free-generator-in-2026-4f15</guid>
      <description>&lt;h1&gt;
  
  
  A valid sitemap.xml for 1,247 URLs with a free generator in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Paste your URLs into a client-side sitemap generator, add lastmod, and ignore changefreq and priority. Google ignores both, and has for years. A valid sitemap.xml is nine lines of boilerplate plus one url block per page, capped at 50,000 URLs or 50MB uncompressed. The XML is the easy part. Keeping the list free of redirects and 404s is what actually moves crawl budget.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Up front: the sitemap generator I link to below is one I built. I tried six existing ones in March 2026 and every one either uploaded my URL list to a server or capped the free tier at 100 URLs. Mine is free, runs entirely in the browser, no signup, and nothing leaves your machine. If you know a better one, tell me and I'll link it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that shipped 412 dead URLs
&lt;/h2&gt;

&lt;p&gt;In February 2026 I moved a docs site off a flat &lt;code&gt;/guides/&lt;/code&gt; structure and onto &lt;code&gt;/docs/&amp;lt;version&amp;gt;/&lt;/code&gt;. Around 1,247 pages. The sitemap came from a shell pipeline I wrote back in 2023: &lt;code&gt;find&lt;/code&gt; the build output and wrap each line in &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; tags with &lt;code&gt;sed&lt;/code&gt;. It had worked for three years without a single complaint.&lt;/p&gt;

&lt;p&gt;It kept working after the migration. That was the problem.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;find&lt;/code&gt; walked the old &lt;code&gt;dist/guides/&lt;/code&gt; directory, which the new build never cleaned out. So the sitemap listed 412 URLs that returned 404 sitting next to the 1,247 real ones. Search Console flagged it 9 days later under "Submitted URL not found". By then Googlebot had burned a chunk of its crawl budget on pages that didn't exist, and half the new &lt;code&gt;/docs/&lt;/code&gt; tree was still unindexed.&lt;/p&gt;

&lt;p&gt;I fixed it the dumb way first: &lt;code&gt;rm -rf dist/&lt;/code&gt; at the top of the build. That killed the stale files and immediately created a different problem. My &lt;code&gt;sed&lt;/code&gt; pipeline had no concept of &lt;code&gt;lastmod&lt;/code&gt;, so every entry either got an identical timestamp or none at all, depending on which branch of the script ran. A sitemap claiming all 1,247 pages changed at the same second tells a crawler nothing. I was wrong to treat lastmod as optional decoration. It's the one optional field Google actually reads.&lt;/p&gt;

&lt;p&gt;Attempt two was a Node script using the &lt;code&gt;sitemap&lt;/code&gt; npm package. That's still what runs in CI today and I have no complaints about it. It's the wrong tool when a colleague drops a CSV of 90 URLs in Slack and wants a sitemap before lunch. Installing a dependency and re-reading the API docs for a one-off costs 20 minutes I'd rather not spend.&lt;/p&gt;

&lt;p&gt;That's the gap a paste-in generator fills. Paste, configure, download. 47 seconds, nothing to maintain afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a valid sitemap.xml actually needs
&lt;/h2&gt;

&lt;p&gt;The spec is smaller than most people expect. sitemaps.org froze at version 0.9 and never moved. You need one &lt;code&gt;urlset&lt;/code&gt; element carrying the namespace, and inside it one &lt;code&gt;url&lt;/code&gt; element per page with a required &lt;code&gt;loc&lt;/code&gt;. Everything else is optional.&lt;/p&gt;

&lt;p&gt;The rules that actually bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;loc&lt;/code&gt; has to be absolute and fully qualified, under 2,048 characters. Relative paths are invalid, and plenty of generators emit them anyway.&lt;/li&gt;
&lt;li&gt;Ampersands and angle brackets inside URLs must be entity-escaped. &lt;code&gt;?a=1&amp;amp;b=2&lt;/code&gt; becomes &lt;code&gt;?a=1&amp;amp;amp;b=2&lt;/code&gt;. This is the most common reason a hand-rolled sitemap fails validation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lastmod&lt;/code&gt; must be W3C Datetime. &lt;code&gt;2026-08-10&lt;/code&gt; is legal. &lt;code&gt;2026-08-10T14:32:00+00:00&lt;/code&gt; is legal. &lt;code&gt;08/10/2026&lt;/code&gt; gets the file rejected.&lt;/li&gt;
&lt;li&gt;50,000 URLs or 50MB uncompressed per file, whichever hits first. Past that you need a sitemap index pointing at multiple files.&lt;/li&gt;
&lt;li&gt;Every URL has to share a host with the sitemap's own location, unless you've verified cross-domain ownership in Search Console.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;changefreq&lt;/code&gt; and &lt;code&gt;priority&lt;/code&gt; are still valid elements, and Google ignores both. Bing too. I still emit priority out of habit, which is probably pointless and definitely harmless.&lt;/p&gt;

&lt;p&gt;Here's the whole thing as a script, which is what every sitemap generator does underneath, browser-based ones included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// build-sitemap.mjs  -&amp;gt;  node build-sitemap.mjs urls.txt &amp;gt; sitemap.xml&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;esc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;apos;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; URLs: split these into a sitemap index`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`  &amp;lt;url&amp;gt;\n    &amp;lt;loc&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/loc&amp;gt;\n    &amp;lt;lastmod&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/lastmod&amp;gt;\n  &amp;lt;/url&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&amp;gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&amp;lt;/urlset&amp;gt;\n`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// $ printf 'https://example.com/\nhttps://example.com/s?a=1&amp;amp;b=2\n' &amp;gt; urls.txt&lt;/span&gt;
&lt;span class="c1"&gt;// $ node build-sitemap.mjs urls.txt&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;loc&amp;gt;https://example.com/&amp;lt;/loc&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;lastmod&amp;gt;2026-08-10&amp;lt;/lastmod&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;loc&amp;gt;https://example.com/s?a=1&amp;amp;amp;b=2&amp;lt;/loc&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;lastmod&amp;gt;2026-08-10&amp;lt;/lastmod&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;/urlset&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 25 lines and it covers escaping plus the 50k guard. The browser version I built adds per-URL lastmod editing and a validation pass before download, which is the piece I kept missing in the script. &lt;a href="https://aidevhub.io/sitemap-generator/" rel="noopener noreferrer"&gt;The sitemap generator on aidevhub&lt;/a&gt; parses its own output back before handing you the file, so a malformed URL fails in the tab instead of in Search Console 9 days later. It all runs client-side, so the list never leaves the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it compares to the alternatives
&lt;/h2&gt;

&lt;p&gt;I checked four options in March 2026 before deciding to build anything. Here's how they line up.&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;aidevhub generator&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;sitemap&lt;/code&gt; npm package&lt;/th&gt;
&lt;th&gt;XML-Sitemaps.com&lt;/th&gt;
&lt;th&gt;Screaming Frog&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free to 500 pages, ~$20/yr after&lt;/td&gt;
&lt;td&gt;Free to 500 URLs, GBP 199/yr after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where your URLs go&lt;/td&gt;
&lt;td&gt;Stays in the browser&lt;/td&gt;
&lt;td&gt;Stays local&lt;/td&gt;
&lt;td&gt;Uploaded to their server&lt;/td&gt;
&lt;td&gt;Stays local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;npm install plus a script&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;250MB desktop install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finds URLs for you&lt;/td&gt;
&lt;td&gt;No, you paste them&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, it crawls&lt;/td&gt;
&lt;td&gt;Yes, full crawl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;One-off lists up to 50k&lt;/td&gt;
&lt;td&gt;CI pipelines&lt;/td&gt;
&lt;td&gt;Small sites, no dev on hand&lt;/td&gt;
&lt;td&gt;Audits and large sites&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real split is URL discovery. Screaming Frog and XML-Sitemaps crawl your site and find the pages for you, which matters a lot when nobody has an authoritative list. That crawl is also the expensive part, and it's exactly where both hit you with limits: 500 URLs free, money after.&lt;/p&gt;

&lt;p&gt;Paste-in tools skip discovery and assume you already have the list. If you run any static site generator, you do. My build already knows every route it emitted. Making a crawler rediscover them is work I've done once already.&lt;/p&gt;

&lt;p&gt;The privacy column matters more than people admit. A URL list from staging or an internal tool leaks structure: admin paths and unreleased feature routes. Uploading that to a third-party server to get 40 lines of XML back is a trade I stopped making after the second time a security review asked me about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't use a paste-in generator
&lt;/h2&gt;

&lt;p&gt;The list of bad fits is longer than the pitch usually admits.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You don't have a URL list.&lt;/strong&gt; The tool takes URLs. It doesn't crawl. If you've inherited a WordPress install with an unknown page count, run a crawler first and paste the output in second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sitemap has to regenerate on every deploy.&lt;/strong&gt; Automate it instead. The script above is 25 lines and costs nothing to run in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're past 50,000 URLs.&lt;/strong&gt; Now you need a sitemap index plus per-shard files, and that's a build-time job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need the image or video extensions.&lt;/strong&gt; Those pull in extra namespaces that simple generators don't emit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your CMS already ships one.&lt;/strong&gt; WordPress with Yoast, or Next.js with its sitemap route, already handles this. Hand-generating on top of that guarantees it goes stale the first time someone publishes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honestly, the case for a manual generator is narrower than I'd like: one-off lists and migrations, plus sites without a build step that emits XML. It just happens to be a case that lands on me 4 or 5 times a year and annoys me every single time.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Will a sitemap improve my rankings?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. A sitemap affects discovery, so it helps a crawler find pages it might otherwise miss (deep pages, weak internal linking, fresh content). Ranking is a separate question entirely. If your pages are already indexed, adding a sitemap changes nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need changefreq and priority in 2026?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Google has said publicly for years that it ignores both, and Bing treats them the same way. They're valid XML, so including them won't break anything. I leave priority in because muscle memory is hard to unlearn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I actually submit the file?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Drop it at &lt;code&gt;https://yoursite.com/sitemap.xml&lt;/code&gt;, add &lt;code&gt;Sitemap: https://yoursite.com/sitemap.xml&lt;/code&gt; to your robots.txt, then submit the URL in Search Console under Sitemaps. The robots.txt line is what other crawlers use, so don't skip it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is a browser-based generator safe for internal URLs?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Depends on the tool. Client-side ones do the string building in JavaScript in your tab, so nothing is transmitted. Open devtools, switch to the Network panel, and generate a file. If you see zero requests, it's local. That check takes 10 seconds and I'd run it on any tool before pasting a staging URL list.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/sitemap-generator/" rel="noopener noreferrer"&gt;aidevhub.io/sitemap-generator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Debugging a 41-second regex hang with a regex tester in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 11 Aug 2026 14:00:06 +0000</pubDate>
      <link>https://dev.to/aidevhub/debugging-a-41-second-regex-hang-with-a-regex-tester-in-2026-2fog</link>
      <guid>https://dev.to/aidevhub/debugging-a-41-second-regex-hang-with-a-regex-tester-in-2026-2fog</guid>
      <description>&lt;h1&gt;
  
  
  Debugging a 41-second regex hang with a regex tester in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Use a regex tester that shows capture groups live and warns about catastrophic backtracking, because the pattern that takes production down is rarely a wrong match. It's usually a pattern that works fine on 20 characters and hangs forever on 40. Paste the expression, paste a real input line, read the group table, then check the backtracking warning before you ship anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Full disclosure: the regex tester I link to below is one I built. I'd been bouncing between four online testers for years and every one of them either skipped Go's RE2 flavor or only told me about catastrophic backtracking after the engine had already timed out. Mine is free, runs entirely in the browser, needs no account, and never sends your pattern to a server. If you've got a better one, tell me and I'll switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 41 seconds that broke our log parser
&lt;/h2&gt;

&lt;p&gt;Late April 2026, a Tuesday afternoon. We had a Node service chewing through nginx access logs and turning them into structured events. It had been running in staging for six weeks without a complaint. Then the pod started getting OOM-killed on a loop, and the container logs went completely quiet right before each restart.&lt;/p&gt;

&lt;p&gt;The culprit was one line of code. Line 238, a field validator that looked harmless:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;^(\s*\w+\s*)+$&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That pattern is fine. It matches. It also happens to be exponential, because &lt;code&gt;\s*&lt;/code&gt; and &lt;code&gt;\w+&lt;/code&gt; can both consume the same characters, and the outer &lt;code&gt;+&lt;/code&gt; lets the engine try every possible split. Feed it a string that almost matches (say, a request path with a trailing character that isn't a word character) and the backtracking explodes.&lt;/p&gt;

&lt;p&gt;One malformed log line. 1,247 requests already queued behind it. 41 seconds pinned at 100% CPU on a single call to &lt;code&gt;.test()&lt;/code&gt;, and then the kernel took the process out.&lt;/p&gt;

&lt;p&gt;Here's the part that annoyed me for most of a day: I debugged it wrong. I did what everyone does. Console.log, a Node REPL, some print statements around the call site. That took 47 minutes and told me absolutely nothing, because a REPL only answers "does this match", and the answer was "yes, eventually." The question I actually needed answered was "how many steps does the engine take to get there, and how does that scale with input length."&lt;/p&gt;

&lt;p&gt;A REPL will not tell you that. It just sits there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a regex tester actually does under the hood
&lt;/h2&gt;

&lt;p&gt;A good tester isn't a wrapper around &lt;code&gt;String.prototype.match&lt;/code&gt;. Three things happen when you paste a pattern in.&lt;/p&gt;

&lt;p&gt;First, the pattern gets compiled against the flavor you picked. This matters more than people expect. JavaScript, Python's &lt;code&gt;re&lt;/code&gt;, and Go's &lt;code&gt;regexp&lt;/code&gt; disagree on lookbehind support, on named group syntax (&lt;code&gt;(?&amp;lt;name&amp;gt;...)&lt;/code&gt; vs &lt;code&gt;(?P&amp;lt;name&amp;gt;...)&lt;/code&gt;), and on whether backtracking exists at all. Go uses RE2, which has no backtracking, so the pattern that killed our Node service would have run in linear time there. Same regex, wildly different runtime characteristics.&lt;/p&gt;

&lt;p&gt;Second, matching runs in a loop with &lt;code&gt;lastIndex&lt;/code&gt; tracked manually, so every match and every capture group gets collected instead of just the first. That's what fills the group table. You see group 1, group 2, the named ones, and the exact character offsets, updated as you type.&lt;/p&gt;

&lt;p&gt;Third, and this is the one that would have saved me a day: static analysis for nested quantifiers over overlapping character classes. That's the ReDoS signature. &lt;code&gt;(a+)+&lt;/code&gt;, &lt;code&gt;(\s*\w+\s*)+&lt;/code&gt;, &lt;code&gt;(\w|\d)*$&lt;/code&gt; and friends. The check is a heuristic, so it produces false positives on patterns that are technically safe, but I'd rather see a yellow warning I can dismiss than find out from a pager.&lt;/p&gt;

&lt;p&gt;You can reproduce the actual blowup yourself. Save this and run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backtrack.mjs - run with: node backtrack.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// "!" guarantees a failed match&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; words: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; ms`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 14 words:    1.02 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 16 words:    3.91 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 18 words:   15.62 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 20 words:   62.45 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 22 words:  249.80 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly 4x per two extra words. Extrapolate to a 60-character path and you get my 41 seconds. Nothing in that output requires a debugger or a profiler, but you do have to know to go looking for it, and you won't go looking if your tool only says "no match."&lt;/p&gt;

&lt;h2&gt;
  
  
  Regex101, RegExr, or a five-line node script
&lt;/h2&gt;

&lt;p&gt;I used regex101 for years and still open it for PCRE work. It has the best debugger of anything in this space, full stop. But the workflow I wanted was different: paste, see the risk immediately, no account, no network round trip, and Go's flavor in the same dropdown as JavaScript. That's the gap I built &lt;a href="https://aidevhub.io/regex-tester/" rel="noopener noreferrer"&gt;the aidevhub regex tester&lt;/a&gt; to fill, and it's where I'd start if you're chasing a pattern that behaves badly rather than one that's simply wrong.&lt;/p&gt;

&lt;p&gt;Checked against the others in July 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I checked&lt;/th&gt;
&lt;th&gt;aidevhub Regex Tester&lt;/th&gt;
&lt;th&gt;regex101&lt;/th&gt;
&lt;th&gt;RegExr&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;node -e&lt;/code&gt; one-liner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flavors&lt;/td&gt;
&lt;td&gt;JavaScript, Python, Go&lt;/td&gt;
&lt;td&gt;PCRE2, JS, Python, Go, Java, .NET&lt;/td&gt;
&lt;td&gt;JS, PCRE&lt;/td&gt;
&lt;td&gt;JS only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backtracking warning&lt;/td&gt;
&lt;td&gt;Static check, before you run it&lt;/td&gt;
&lt;td&gt;Reports it after the engine times out&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain-English breakdown&lt;/td&gt;
&lt;td&gt;Yes, per token&lt;/td&gt;
&lt;td&gt;Yes, token list plus debugger&lt;/td&gt;
&lt;td&gt;Yes, on hover&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capture group table&lt;/td&gt;
&lt;td&gt;Yes, live&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;console.log&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account needed to save&lt;/td&gt;
&lt;td&gt;No, nothing leaves the tab&lt;/td&gt;
&lt;td&gt;Yes, to save permalinks&lt;/td&gt;
&lt;td&gt;Yes, to save patterns&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step-by-step debugger&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, best in class&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That "No" in the debugger row is real and I'm not going to hide it. If you need to walk the engine's backtracking path step by step to understand &lt;em&gt;why&lt;/em&gt; a pattern fails, regex101 does that and mine doesn't. Different jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a browser regex tester is the wrong tool
&lt;/h2&gt;

&lt;p&gt;I'd skip all of this in a few cases, and I say that as the person who built one of them.&lt;/p&gt;

&lt;p&gt;If your pattern is longer than about 200 characters, the honest answer is that you have a parser, and you should write a parser. I've watched a team maintain a 600-character email validation regex for two years. Every tester in the table above will happily render it, and none of them will make it a good idea.&lt;/p&gt;

&lt;p&gt;If you're working against a flavor nobody supports well (Oracle's &lt;code&gt;REGEXP_LIKE&lt;/code&gt;, or the subset that ships in some embedded Lua runtimes), test in the actual engine. A JS-flavored tester giving you a green checkmark on a pattern that Oracle parses differently is worse than no tool, because now you're confident and wrong.&lt;/p&gt;

&lt;p&gt;If the input is sensitive, read the tool's docs before pasting. Mine runs client-side and I'll say so plainly, but "client-side" is a claim you should verify in the network tab rather than take on faith from a blog post. Open devtools, type a pattern, watch for requests. Thirty seconds.&lt;/p&gt;

&lt;p&gt;And if you're already deep in a debugging session with a profiler attached, don't context-switch to a browser tab. The five-line script above lives in your repo and answers the scaling question directly.&lt;/p&gt;

&lt;p&gt;One thing I got wrong for a long time: I assumed ReDoS was an exotic security-research problem, something that mattered for input parsers exposed to hostile users. It isn't. Our log line wasn't hostile. It was a truncated request path from a client that hung up mid-request, and it hit a validator I'd written myself and never once tested with input longer than a sample string.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does a ReDoS warning mean my pattern is definitely vulnerable?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. The detection is static and errs toward false positives, so nested quantifiers get flagged even when the surrounding anchors make the bad path unreachable. Treat it as a prompt to run the timing loop above, not a verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why do JavaScript, Python, and Go produce different results for the same pattern?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Different engines. Go's &lt;code&gt;regexp&lt;/code&gt; package uses RE2, which guarantees linear time and drops backreferences and lookaheads entirely. JS and Python both backtrack. A pattern that's a landmine in Node is safe in Go and won't even compile if it uses lookbehind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I test replacement strings too?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes, including &lt;code&gt;$1&lt;/code&gt;-style group references in JS mode and &lt;code&gt;\1&lt;/code&gt; in Python mode. This is where the flavor selector earns its place, since getting the replacement syntax backwards is the most common silent bug I hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What's the fastest way to fix a catastrophic pattern once I've found one?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Usually by making the inner quantifier possessive or atomic where the flavor supports it, or by rewriting so the alternatives can't match the same characters. For our validator, &lt;code&gt;^\w+(\s+\w+)*$&lt;/code&gt; fixed it. Same matches, linear time.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/regex-tester/" rel="noopener noreferrer"&gt;aidevhub.io/regex-tester&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>performance</category>
      <category>regex</category>
      <category>tools</category>
    </item>
    <item>
      <title>Building an MCP tool-call test rig with the Python SDK in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 06 Aug 2026 14:00:01 +0000</pubDate>
      <link>https://dev.to/aidevhub/building-an-mcp-tool-call-test-rig-with-the-python-sdk-in-2026-4iln</link>
      <guid>https://dev.to/aidevhub/building-an-mcp-tool-call-test-rig-with-the-python-sdk-in-2026-4iln</guid>
      <description>&lt;h1&gt;
  
  
  Building an MCP tool-call test rig with the Python SDK in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;You can test an agent's tool-call loop without a model. Write down the calls the model would have made, replay them against your real MCP server over stdio, and assert on what comes back. It runs offline in about two seconds, costs nothing per run, and catches renamed tools and schema drift before a customer does. The model is the last thing you should be faking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Function Call Flow Simulator I link to below is one I built. I tried five existing playgrounds first and every one wanted an API key before it would render a single tool_use block, which is backwards when the whole point is sketching a flow you haven't paid for yet. Mine runs in the browser, free, no signup, nothing leaves the tab. If you know a better one, tell me and I'll link to it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;p&gt;Here's the picture I wanted on my screen. One command, one JSON file, six lines of output: each tool call my agent would make during a customer refund, run against the same MCP server that handles production traffic, with a failure line and an exit code of 1 the moment something breaks. No API key. Nothing over the network. Under two seconds.&lt;/p&gt;

&lt;p&gt;The model is the easy part to fake. Everything around it is where I keep getting hurt. The loop that reads tool_use blocks, dispatches them, feeds results back, and decides when to stop is ordinary code, and it fails in ordinary ways. Someone renamed &lt;code&gt;refund_order&lt;/code&gt; to &lt;code&gt;issue_refund&lt;/code&gt; on the server and my agent quietly degraded into apologising to people instead of paying them. A required field got added to a schema and half the calls started coming back with &lt;code&gt;isError&lt;/code&gt; set, which my loop passed straight back to the model as though it were a normal result.&lt;/p&gt;

&lt;p&gt;Testing that against a live model doesn't work the way people hope. Run the same prompt twice and you get different arguments, sometimes a different tool, sometimes a friendly paragraph and no call at all. That's correct behaviour from the model and useless behaviour for a test. So pin the model's output. Write it down as data. Then the only moving part left is your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup and the auth you don't need
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pip install "mcp==1.13.1"&lt;/code&gt; is the entire dependency list. I pin the version because the stdio client's environment handling shifted twice inside the 1.x line and I lost most of an afternoon to it.&lt;/p&gt;

&lt;p&gt;For replay you need no key whatsoever. That's the point: no model is in the room. You need a key exactly once, on the day you record a real conversation to seed your first transcript, and after that the JSON file is the fixture and CI never sees a credential.&lt;/p&gt;

&lt;p&gt;Three environment gotchas worth knowing before you start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server is spawned as a subprocess over stdio, so it inherits the working directory you launched pytest from, not the directory your test file lives in. Relative paths inside your server config will resolve somewhere surprising.&lt;/li&gt;
&lt;li&gt;Anything your server writes to stdout that isn't JSON-RPC corrupts the stream. One stray &lt;code&gt;print()&lt;/code&gt; left in a tool handler kills the session with a parse error that names no file and no line.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StdioServerParameters.env&lt;/code&gt; replaces the child environment rather than merging into it. More on that below, because it cost me 41 minutes and my dignity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before I hand-write a transcript I sketch the flow in the &lt;a href="https://aidevhub.io/function-call-simulator/" rel="noopener noreferrer"&gt;Function Call Flow Simulator&lt;/a&gt;, which lets me lay out the tool_use and tool_result pairs across several turns and copy the JSON straight out. Faster than getting the nesting right by hand at 1am, which is how I used to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;This is the whole runner. It reads a transcript, checks each tool still exists on the server, calls it, and stops on the first thing that looks wrong.&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;# replay.py - drive a real MCP server with a scripted tool-call transcript.
# pip install "mcp==1.13.1"
# usage: python replay.py flows/refund.json
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StdioServerParameters&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.client.stdio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stdio_client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_default_environment&lt;/span&gt;

&lt;span class="c1"&gt;# What the model would have emitted. Hand-written, or recorded from one real run:
# [{"name": "search_orders", "input": {"customer_id": "cus_8812", "status": "open"}},
#  {"name": "refund_order",  "input": {"order_id": "ord_4471", "amount_cents": 9164}}]
&lt;/span&gt;&lt;span class="n"&gt;TRANSCRIPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

&lt;span class="n"&gt;SERVER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StdioServerParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;args&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;-m&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;billing_mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                 &lt;span class="c1"&gt;# your real server, unmodified
&lt;/span&gt;    &lt;span class="c1"&gt;# gotcha: env REPLACES the child environment, it does not merge. Drop the
&lt;/span&gt;    &lt;span class="c1"&gt;# get_default_environment() spread and the child loses PATH and dies quietly.
&lt;/span&gt;    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="nf"&gt;get_default_environment&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BILLING_MODE&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;sandbox&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;stdio_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SERVER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;name&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_tools&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="n"&gt;tools&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;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TRANSCRIPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: server no longer declares &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="c1"&gt;# gotcha: a failed tool call does NOT raise. isError is a field.
&lt;/span&gt;                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; returned isError&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&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="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ok  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;declared&lt;/code&gt; set is doing more work than it looks like. &lt;code&gt;list_tools()&lt;/code&gt; is the server telling you what it actually exposes right now, so comparing your transcript against it turns "the model will call a tool that vanished" from a production surprise into a failing test. The &lt;code&gt;amount_cents: 9164&lt;/code&gt; in the sample transcript is deliberate. Round numbers hide off-by-one and unit bugs, and I want $91.64 flowing through, not $100.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scripted replay against the alternatives
&lt;/h2&gt;

&lt;p&gt;I've run all three of these in anger. The numbers in the cost row are mine, from a week in February when our CI ran the agent suite on every push.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;Scripted replay&lt;/th&gt;
&lt;th&gt;Live model in CI&lt;/th&gt;
&lt;th&gt;Recorded HTTP cassettes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1,000 runs&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$63.18&lt;/td&gt;
&lt;td&gt;$0 after recording&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same result every run&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches renamed or dropped tools&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;sometimes&lt;/td&gt;
&lt;td&gt;no, the cassette hides it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches bad model arguments&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works with no network&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first green test&lt;/td&gt;
&lt;td&gt;~20 min&lt;/td&gt;
&lt;td&gt;~5 min&lt;/td&gt;
&lt;td&gt;~90 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cassettes look like the obvious answer and they're the one I'd steer you away from. They freeze the server's responses too, so the day your MCP server changes its schema the cassette keeps replaying the old world and your suite stays green while production burns. Scripted replay freezes only the model side and lets the server be real, which is the exact split you want.&lt;/p&gt;

&lt;p&gt;The live-model column has one row nobody else can fill: whether the model picks sensible arguments. That's a real question. It's just a different suite, on a different schedule, with a budget cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong the first time
&lt;/h2&gt;

&lt;p&gt;I was wrong about &lt;code&gt;isError&lt;/code&gt;. I assumed a failing tool call would raise an exception, because that's what every HTTP client I've used does. It doesn't. &lt;code&gt;call_tool&lt;/code&gt; returns a &lt;code&gt;CallToolResult&lt;/code&gt; with &lt;code&gt;isError=True&lt;/code&gt; and content that reads like a normal text block. My runner was green for three days straight while every single refund step came back with "customer cus_8812 not found". Three days of a passing suite that was testing nothing. I only caught it because a teammate asked why the sandbox ledger was empty.&lt;/p&gt;

&lt;p&gt;Then the environment thing. I passed &lt;code&gt;env={"BILLING_MODE": "sandbox"}&lt;/code&gt; on its own, assuming it merged with the parent process environment the way &lt;code&gt;subprocess.run&lt;/code&gt; does with &lt;code&gt;env=None&lt;/code&gt;. It doesn't merge. The child got an environment containing exactly one variable, lost &lt;code&gt;PATH&lt;/code&gt;, and failed to launch. What I saw was &lt;code&gt;session.initialize()&lt;/code&gt; hanging until the 30 second timeout with zero output, because the child's stderr goes nowhere unless you wire it up. I spent 41 minutes convinced my server had a deadlock in its startup handler. The fix is the two-character spread in the code above.&lt;/p&gt;

&lt;p&gt;Last Tuesday I added a sixth step to the refund flow and hit the third one: I'd been asserting with &lt;code&gt;result.content[0].text&lt;/code&gt; and a substring check. The server had started returning &lt;code&gt;structuredContent&lt;/code&gt; alongside a JSON dump in the text block, so my substring &lt;code&gt;"refunded"&lt;/code&gt; matched a field name rather than a status value. The assertion passed for entirely the wrong reason. I don't have a clean rule for this yet beyond "parse the JSON, assert on a key", and honestly I'm still annoyed that the loose version survived as long as it did.&lt;/p&gt;

&lt;p&gt;None of these three are bugs in the MCP SDK. They're all me assuming a library behaved like a different library I knew better. Worth budgeting an hour for that on any new protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Isn't this just testing my own mocks?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No, and that's the whole design. The server is your actual MCP server, running as a real subprocess, hitting your real sandbox database. The only mocked thing is the model's choice of tool and arguments, which is the one component you can't assert on deterministically anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I get the first transcript?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Run the conversation once for real, log every tool_use block your loop receives, and dump the list to JSON. About 15 lines of throwaway code. After that you edit the file by hand to add edge cases the model never happened to produce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does this work for HTTP or SSE servers instead of stdio?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. Swap &lt;code&gt;stdio_client&lt;/code&gt; for &lt;code&gt;streamablehttp_client&lt;/code&gt; and pass a URL. Everything inside the &lt;code&gt;ClientSession&lt;/code&gt; block stays byte-for-byte identical, which is the nicest property of the client API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I still test that the model picks the right tool?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Separate suite, run nightly rather than per-push, with a hard spend cap and assertions loose enough to tolerate variation (did it call any refund-shaped tool, did it stop after four turns). Keep it away from your fast suite.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/function-call-simulator/" rel="noopener noreferrer"&gt;aidevhub.io/function-call-simulator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>testing</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Jinja2 vs Claude vs SkillSpec in 2026: same skill, 3 ways</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 30 Jul 2026 14:00:03 +0000</pubDate>
      <link>https://dev.to/aidevhub/jinja2-vs-claude-vs-skillspec-in-2026-same-skill-3-ways-dam</link>
      <guid>https://dev.to/aidevhub/jinja2-vs-claude-vs-skillspec-in-2026-same-skill-3-ways-dam</guid>
      <description>&lt;h1&gt;
  
  
  Jinja2 vs Claude vs SkillSpec in 2026: same skill, 3 ways
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;SkillSpec Converter wins for anything I need in more than two output formats at once. The Jinja2 script is still faster for a single format I fully control, and asking Claude directly is fine for a one-off, though it quietly drops required fields. I timed the same skill through all three last week. Below are the actual outputs and the exact spot each one broke.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The SkillSpec Converter I link to below is one I built. I got tired of hand-porting the same skill across OpenClaw, Claude, Codex, and MCP, and the four converters I tried each handled one target well and mangled the rest. It's free, runs entirely in your browser, needs no signup, and uploads nothing. If you know a better one, tell me and I'll switch to it happily.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: one skill, four target formats
&lt;/h2&gt;

&lt;p&gt;Last Tuesday I had a skill that worked in exactly one place and needed to run in four by the end of the week. The canonical definition is small. It's a PR summarizer that takes a pull request URL and returns five bullet points, gated behind a single read-only GitHub tool. That's the whole spec, and I keep it as one YAML file that acts as my source of truth.&lt;/p&gt;

&lt;p&gt;The four targets were OpenClaw's &lt;code&gt;SKILL.md&lt;/code&gt;, a Claude system block, a Codex scaffold, and an MCP manifest snippet. My expected output was four files that all agree: same inputs, same tool allowlist, and the &lt;code&gt;required: true&lt;/code&gt; on &lt;code&gt;pr_url&lt;/code&gt;, because a summarizer with no URL is just an apology generator. The test was drift. If any of the four copies disagreed with the YAML, I'd call the run a failure, because that's the bug you don't catch until it breaks in production three weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: the Jinja2 script I already had
&lt;/h2&gt;

&lt;p&gt;I reached for the script first because it already existed. It's roughly 60 lines of Python that loads the YAML spec and renders one target through a Jinja2 template. Here's the trimmed version that produces the OpenClaw &lt;code&gt;SKILL.md&lt;/code&gt;, and you can run it as-is after &lt;code&gt;pip install pyyaml jinja2&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="c1"&gt;# pip install pyyaml jinja2
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jinja2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Template&lt;/span&gt;

&lt;span class="n"&gt;CANONICAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
name: pr-summarizer
description: Summarize a GitHub pull request into five bullet points
inputs:
  - name: pr_url
    type: string
    required: true
  - name: max_bullets
    type: integer
    required: true
  - name: tone
    type: string
    required: true
tools:
  - github_read
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;SKILL_MD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;---
name: {{ name }}
description: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{ description }}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
allowed-tools: {{ tools | join(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;) }}
---

# {{ name }}

{{ description }}

## Inputs
{% for i in inputs -%}
- `{{ i.name }}` ({{ i.type }}){% if i.required %}, required{% endif %}
{% endfor %}&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CANONICAL&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;SKILL_MD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, and the output was correct on the first pass. I trust it more than anything else here because I wrote every line of the template, so when something looks wrong I know where to look. The catch is the other three targets. Each one needs its own template, and the Codex scaffold and the MCP manifest have structural quirks that don't fall out of a flat YAML file cleanly. Building and debugging all four templates took me 43 minutes. I burned a chunk of that on a bug at line 41, where a tool with no parameters rendered an empty &lt;code&gt;properties: {}&lt;/code&gt; block that the MCP validator flat-out rejected. Great for a format I'll render a hundred times. Rough for something I need once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: just asking Claude to convert it
&lt;/h2&gt;

&lt;p&gt;Next I pasted the same YAML into a chat and asked for all four formats in one go. Six minutes, four clean-looking code blocks, done. Or so I thought.&lt;/p&gt;

&lt;p&gt;Two of the three inputs came back without their &lt;code&gt;required&lt;/code&gt; flag. I don't fully know why. The description text came through fine and the tool allowlist was right, but the one boolean I actually cared about just wasn't there. I ran it twice more with a stricter prompt. It behaved once, then dropped the flag again on the third try. The MCP manifest it produced used &lt;code&gt;input_schema&lt;/code&gt; where the spec wants &lt;code&gt;inputSchema&lt;/code&gt;, which is the kind of casing slip that reads fine to a human and dies in a parser. If I were shipping a throwaway skill I'd have taken it and moved on. For four files that have to stay in lockstep, I couldn't trust the output without diffing every field against the YAML by hand, and at that point six minutes isn't really six minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: SkillSpec Converter
&lt;/h2&gt;

&lt;p&gt;Then I ran it through my own tool, which is the honest reason this comparison exists. I pasted the same canonical YAML into the box, and it rendered all four targets into tabs in under a second. The &lt;code&gt;required&lt;/code&gt; flags survived intact. The MCP manifest came out with the camelCase key. The Codex scaffold handled the empty-properties case, which is the precise bug I'd hit by hand at line 41 an hour earlier.&lt;/p&gt;

&lt;p&gt;I want to be fair about the limits. It only knows the four targets I taught it, so the moment you need a fifth output format you're back to a template or a model. There's no clever inference happening under the hood. But for the narrow job of keeping one skill definition consistent across OpenClaw, Claude, Codex, and MCP, it did in one paste what my script did in 43 minutes and what the chat couldn't do reliably at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scores, and which one I keep open
&lt;/h2&gt;

&lt;p&gt;Here's how the three landed on the things I actually measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Jinja2 script&lt;/th&gt;
&lt;th&gt;Ask Claude&lt;/th&gt;
&lt;th&gt;SkillSpec Converter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to get all four formats&lt;/td&gt;
&lt;td&gt;43 min&lt;/td&gt;
&lt;td&gt;6 min&lt;/td&gt;
&lt;td&gt;under 1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Required fields kept&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;1 of 3&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid MCP manifest out of the box&lt;/td&gt;
&lt;td&gt;after a fix&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup cost&lt;/td&gt;
&lt;td&gt;high, four templates&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customization ceiling&lt;/td&gt;
&lt;td&gt;total&lt;/td&gt;
&lt;td&gt;prompt-dependent&lt;/td&gt;
&lt;td&gt;the four built-in targets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;td&gt;API tokens&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So which one do I reach for? It depends on the day, and I'll give you my honest split instead of pretending one tool won outright.&lt;/p&gt;

&lt;p&gt;If I need exactly one format and I'll be generating it over and over, the Jinja2 script takes it. Full control, and I can version the template right next to the code it belongs to. If I need a rough draft and I'm going to read every line anyway, asking Claude is the quickest way to a starting point, as long as I treat its output as a draft and diff it against the spec.&lt;/p&gt;

&lt;p&gt;For the case that actually stung (one skill, four formats, zero drift allowed), I keep &lt;a href="https://aidevhub.io/skill-spec-converter/" rel="noopener noreferrer"&gt;the SkillSpec Converter&lt;/a&gt; open in a tab. It's free, and it runs client-side. It also stopped me from shipping a manifest with the wrong casing twice this month. I'm biased here, plainly. I built it after the four alternatives I tried each nailed one target and butchered the rest. If yours does this better, I want to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does converting a skill lose information between formats?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It can. The formats don't have identical feature sets, so a field in one target may have no home in another. A good converter maps what it can and leaves a comment where a target can't represent something, which beats silently dropping it and finding out in prod.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use the Jinja2 approach for MCP manifests too?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes, and you should if MCP is your only target. Write one template, test it against the manifest validator, and you're set for good. The pain only shows up once you're maintaining three or four templates in parallel and they start disagreeing with each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why did Claude drop the required flags?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; I don't have a clean answer. My guess is that boolean flags on nested objects are easy to lose across a long generation, especially when the surrounding prose is more salient to the model. A stricter prompt helped but didn't fix it every single time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Which format is hardest to get right by hand?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; The MCP manifest, easily. The nesting runs deep and the key casing is unforgiving. An empty &lt;code&gt;properties&lt;/code&gt; object trips the validator, which is the same trap I fell into at line 41. It's the one target where I stopped trusting my own templates first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is my skill definition uploaded anywhere?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not with SkillSpec Converter, since it runs in the browser and the spec never leaves your machine. For the Claude route, your spec does go to the API, which matters if the skill contains anything you'd rather keep off a server.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/skill-spec-converter/" rel="noopener noreferrer"&gt;aidevhub.io/skill-spec-converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>llm</category>
    </item>
    <item>
      <title>Reading 50MB JSONL logs with a viewer in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:00:04 +0000</pubDate>
      <link>https://dev.to/aidevhub/reading-50mb-jsonl-logs-with-a-viewer-in-2026-262k</link>
      <guid>https://dev.to/aidevhub/reading-50mb-jsonl-logs-with-a-viewer-in-2026-262k</guid>
      <description>&lt;h1&gt;
  
  
  Reading 50MB JSONL logs with a viewer in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Use a browser-based JSONL viewer that parses each line as its own JSON object and lays the results out in a filterable, sortable table. That's the fastest way to read newline-delimited logs without writing a throwaway script. Paste the file, get columns, filter to the rows you care about, export what's left. No terminal gymnastics and no 2GB file crashing your editor. Works offline because everything runs client-side.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The jsonl viewer I link to below is one I built. I got tired of it: I tried six different online JSON tools last spring and every one of them choked the moment I pasted newline-delimited data, because they all assume a single JSON document and JSONL is a stream of them. Mine runs entirely in your browser. No signup, no upload, nothing leaves your machine, and it's free. If you've got a better one, please tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log file that killed my text editor
&lt;/h2&gt;

&lt;p&gt;Last Tuesday, around 2am, I was chasing a production timeout. The only evidence I had was an NDJSON log the service had been streaming to disk: 340 MB, roughly 1.2 million lines, one JSON object per line. I did the obvious thing first and opened it in my editor. It thought about that for a while. The fans spun up. Then the window went white and stopped responding. Cool.&lt;/p&gt;

&lt;p&gt;So I fell back to jq. &lt;code&gt;jq 'select(.level == "error")' app.log&lt;/code&gt; does work, and honestly jq is a wonderful tool, but I couldn't remember the exact field names, I kept getting the filter slightly wrong, and every failed guess re-streamed the whole file from the top. Twenty minutes in I still hadn't seen a single row. The error turned out to be on line 811,406, but I didn't know that yet. All I wanted was to see the shape of the data first, then decide what to filter. That's the gap.&lt;/p&gt;

&lt;p&gt;JSONL (also called NDJSON or JSON Lines, same idea under different names) is a stream of JSON values with one per line. Log pipelines love it because you can append a line without rewriting the file, and a crash mid-write only costs you the last line instead of the whole document. The catch is that most JSON tooling assumes a single document, so it reads your 1.2 million lines and immediately throws on the second &lt;code&gt;{&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a JSONL viewer parses a stream of objects
&lt;/h2&gt;

&lt;p&gt;The core of a JSONL viewer is almost embarrassingly small. You split on newlines and parse each non-blank line on its own. The one trick that matters is not letting a single bad line kill the whole render, so you catch per line and keep going.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Split, drop blanks, parse each line independently.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseJSONL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`{"ts":"2026-04-11T02:14:03Z","level":"error","msg":"timeout","ms":9812}
{"ts":"2026-04-11T02:14:04Z","level":"info","msg":"retry","attempt":2}
not valid json
{"ts":"2026-04-11T02:14:06Z","level":"info","msg":"ok"}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseJSONL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that and you get four results back. Three parse cleanly into row objects, and the &lt;code&gt;not valid json&lt;/code&gt; line comes back as &lt;code&gt;{ ok: false, line: 3, ... }&lt;/code&gt; instead of blowing up the other three. A viewer takes that array, unions all the keys it sees to build columns (&lt;code&gt;ts&lt;/code&gt;, &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;msg&lt;/code&gt;, &lt;code&gt;ms&lt;/code&gt;, &lt;code&gt;attempt&lt;/code&gt;), and paints a table. Now &lt;code&gt;level&lt;/code&gt; is a column you can filter, not a string you have to grep for.&lt;/p&gt;

&lt;p&gt;You don't have to run this yourself. Paste your file into the &lt;a href="https://aidevhub.io/jsonl-viewer/" rel="noopener noreferrer"&gt;JSONL viewer&lt;/a&gt; and it does exactly this in your browser, then hands you a sortable, filterable table with the broken lines flagged in red so you can spot corruption instead of silently dropping it. That red-flag behavior is the part I use most, weirdly. Half my "bug" reports turn out to be one truncated log line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the table actually gets you
&lt;/h2&gt;

&lt;p&gt;Parsing is the boring half. The reason a table beats a wall of text is what you can do to it once it's there. I filter first, almost always. Type &lt;code&gt;error&lt;/code&gt; into the level column and 1.2 million rows collapse to the 3,000 that matter. Then I sort by timestamp to find the first one, because the first error is usually the real cause and the rest are fallout.&lt;/p&gt;

&lt;p&gt;Sorting on a numeric field (say a duration in ms) is where the table earns its keep. In an editor I'd be eyeballing numbers by hand. Here I click the &lt;code&gt;ms&lt;/code&gt; header and the slowest request floats to the top. Last week that surfaced a single 14,203 ms outlier I'd never have spotted by scrolling, and it was the whole bug.&lt;/p&gt;

&lt;p&gt;Export closes the loop. Once I've filtered down to the rows I care about, I pull them out as JSON or CSV and drop that slice into a ticket, so the person picking it up sees 40 relevant lines instead of a 340 MB file and a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  jq versus a spreadsheet versus a text editor
&lt;/h2&gt;

&lt;p&gt;Each of these has a place. Here's how I actually pick between them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Reads JSONL as-is&lt;/th&gt;
&lt;th&gt;Live filtering&lt;/th&gt;
&lt;th&gt;Big files&lt;/th&gt;
&lt;th&gt;Setup cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser JSONL viewer&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, instant&lt;/td&gt;
&lt;td&gt;Fine to ~100 MB&lt;/td&gt;
&lt;td&gt;None, it's a web page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jq on the CLI&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, re-run per query&lt;/td&gt;
&lt;td&gt;Excellent, it streams&lt;/td&gt;
&lt;td&gt;Install plus learn the syntax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import into a spreadsheet&lt;/td&gt;
&lt;td&gt;No, needs flattening&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Poor past a few MB&lt;/td&gt;
&lt;td&gt;Manual conversion step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text editor plus grep&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Text search only&lt;/td&gt;
&lt;td&gt;Bad, loads it all&lt;/td&gt;
&lt;td&gt;Already open&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The viewer wins when I'm exploring and don't yet know what I'm looking for. jq wins when I know the exact query and want it in a script or a pipe. I reach for jq inside CI, and the viewer at 2am when my brain is half offline. Different jobs, and I stopped feeling guilty about using both.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't reach for it
&lt;/h2&gt;

&lt;p&gt;I built this thing and I still won't use it for everything. A few honest limits.&lt;/p&gt;

&lt;p&gt;If your file is genuinely huge (multiple gigabytes), keep it in jq or a streaming parser. A browser tab has a memory ceiling, and loading 3 GB into a table will hang the page the same way it hung my editor. The viewer is for the range where an editor struggles but the data still fits in RAM, so call it a few hundred MB and under.&lt;/p&gt;

&lt;p&gt;If the task is automated, a viewer is the wrong shape entirely. Anything that runs on a schedule or inside a build should be jq or a small script. A human clicking a web page doesn't belong in a cron job, and you'll hate maintaining it if you try.&lt;/p&gt;

&lt;p&gt;And if you're dealing with deeply nested objects, a flat table gets awkward fast. The viewer flattens what it can and shows nested blobs as collapsed JSON, which is readable but not magic. For heavy nesting I still drop back to jq's path expressions. No tool wins every round, and pretending otherwise is how you end up with the wrong one open at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is JSONL the same thing as NDJSON?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Effectively yes. JSONL, NDJSON, and JSON Lines all name the same format: one JSON value per line, separated by &lt;code&gt;\n&lt;/code&gt;. There are pedantic edge cases around trailing newlines and empty lines, but any decent viewer handles the three names identically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does my data get uploaded anywhere?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. The parsing runs client-side in your browser, so the file never leaves your machine. That was the entire reason I built it that way. I didn't want to paste production logs into someone else's server and hope for the best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How big a file can it actually handle?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It depends on your available RAM more than anything else. I've thrown 90 MB files at it without trouble on a normal laptop. Past a few hundred MB you'll feel the tab get heavy, and that's your cue to switch to jq.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I export the filtered rows?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Once you've narrowed down to the rows you want, you can export just the visible set as JSON or CSV, which is handy for handing a teammate a clean slice instead of the raw dump.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why not just use jq for all of it?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; You can, and plenty of people do. I like jq for known queries and pipelines. The viewer is for the messier moment before that, when I don't yet know the field names or what I'm even looking for and want to poke at the data with my eyes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/jsonl-viewer/" rel="noopener noreferrer"&gt;aidevhub.io/jsonl-viewer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
