<?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: Jurij Tokarski</title>
    <description>The latest articles on DEV Community by Jurij Tokarski (@jurijtokarski).</description>
    <link>https://dev.to/jurijtokarski</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F883676%2F842c240f-62c1-41f4-ac3d-ac3e1a52a6d9.jpeg</url>
      <title>DEV Community: Jurij Tokarski</title>
      <link>https://dev.to/jurijtokarski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jurijtokarski"/>
    <language>en</language>
    <item>
      <title>Null Bytes, Dead Streams, Last Chunk</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Fri, 24 Apr 2026 08:37:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/null-bytes-dead-streams-last-chunk-4d4p</link>
      <guid>https://dev.to/jurijtokarski/null-bytes-dead-streams-last-chunk-4d4p</guid>
      <description>&lt;p&gt;Streaming LLM output to a browser means wiring together SSE, TCP, fetch, and browser lifecycle APIs that weren't designed for this combination. Each one has constraints that only surface when you integrate them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Parser That Choked
&lt;/h2&gt;

&lt;p&gt;Server-Sent Events is the natural choice for streaming. SSE supports multiple event types via the &lt;code&gt;event:&lt;/code&gt; field and handles multiline JSON by splitting across &lt;code&gt;data:&lt;/code&gt; lines. But when every chunk needs an &lt;code&gt;event:&lt;/code&gt; line, one or more &lt;code&gt;data:&lt;/code&gt; lines, and a blank line delimiter — and you're sending hundreds of small text fragments interleaved with structured tool call events — the framing adds up and the parser becomes more complex than the problem requires.&lt;/p&gt;

&lt;p&gt;A null byte as the delimiter is simpler. &lt;code&gt;\0&lt;/code&gt; is rare enough in practice — it can appear as &lt;code&gt;\u0000&lt;/code&gt; in JSON but almost never does in LLM output or natural language — that it works as a reliable record separator without escaping.&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;// Server: wrap each event&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stream&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="se"&gt;\&lt;/span&gt;&lt;span class="s1"&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="c1"&gt;// Client: split and route&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buffer&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="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&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;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&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;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffer&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;\&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// keep the incomplete trailing segment&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;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;handleEvent&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;part&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each event is a JSON object with a &lt;code&gt;type&lt;/code&gt; field — &lt;code&gt;text_chunk&lt;/code&gt;, &lt;code&gt;tool_call&lt;/code&gt;, &lt;code&gt;tool_result&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;. The client splits on null bytes, parses each segment, routes by type. Text chunks accumulate in the UI. Tool events trigger loading states or commit structured data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stream That Stopped Talking
&lt;/h2&gt;

&lt;p&gt;TCP keepalive keeps a connection open. It doesn't tell you the connection has gone silent at the application level. Occasionally — maybe once every few hundred sessions — a stream stops mid-sentence. No error event. No close event. The connection is alive, the response is still "streaming," and the user is staring at a half-finished message with a spinner that will never resolve.&lt;/p&gt;

&lt;p&gt;The LLM API hasn't errored — it just stopped sending chunks.&lt;/p&gt;

&lt;p&gt;An idle timer catches this. Reset it on every incoming chunk. Fire it if silence crosses a threshold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;idleTimer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resetIdleTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idleTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;idleTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&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;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;resetIdleTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;processChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idleTimer&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;Thirty seconds is generous for interactive chat — users notice after five. The threshold isn't the important part. The pattern is: connection-level timeouts don't catch application-level silence. You need to track it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Chunk That Vanished
&lt;/h2&gt;

&lt;p&gt;Browsers kill in-flight &lt;code&gt;fetch()&lt;/code&gt; calls during page unload. If you stream audio in chunks via POST, the final chunk — whatever is still buffered when the user stops recording or closes the tab — lives in memory until the next flush. That flush never happens. The final segment of every session is silently dropped.&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;// Killed on page close:&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v3/audio/stream_chunk&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Survives:&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v3/audio/stream_chunk&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="na"&gt;keepalive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;await&lt;/code&gt;. No &lt;code&gt;.then()&lt;/code&gt;. You can't await a response during unload — any result is swallowed. Fire and forget. The browser queues the request and completes it even after the page is gone, as long as the total payload is under ~64KB.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;navigator.sendBeacon()&lt;/code&gt; survives unload too, but it doesn't support custom headers. If your backend expects an auth header, &lt;code&gt;fetch({ keepalive: true })&lt;/code&gt; gives you the full request API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gaps Between Protocols
&lt;/h2&gt;

&lt;p&gt;Every integration has these. You wire together two or three tools that work fine on their own, but nobody tested them together — and no documentation covers the seams. The workarounds aren't published as best practices. They accumulate as know-how, one project at a time. These are three I've accumulated for LLM streaming.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
    <item>
      <title>200 OK, Data Wrong</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 21 Apr 2026 12:23:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/200-ok-data-wrong-3pk1</link>
      <guid>https://dev.to/jurijtokarski/200-ok-data-wrong-3pk1</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/jurij/p/production-bugs-that-never-threw-an-error"&gt;The Production Bugs That Never Threw an Error&lt;/a&gt; was about systems that reported success while running the wrong thing — stale tokens, cached artifacts, stripped paths. These five are different. Every one is an API that accepted valid input, returned a clean 200, and delivered the wrong output. The call worked. The result didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Image That Wasn't What I Asked For
&lt;/h2&gt;

&lt;p&gt;Imagen has a prompt rewriter enabled by default — an LLM that rewrites your prompt before generation to "add more detail and deliver higher quality images." The rewritten version is only returned in the API response if your original prompt is under 30 words. Above that threshold, you get an image generated from a prompt you never see.&lt;/p&gt;

&lt;p&gt;The image I got back was valid, well-composed, and completely wrong. The main subject was replaced by something adjacent. The response was 200. No flag, no warning, no indication that the input was rewritten. I assumed the safety filter had intervened — but the &lt;a href="https://docs.cloud.google.com/vertex-ai/generative-ai/docs/image/responsible-ai-imagen" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; says safety filters either block with an error or omit images entirely. They don't silently substitute. The prompt rewriter does.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;enhancePrompt: false&lt;/code&gt; in the request disables it. After that, the images matched the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The File Search That Found Nothing
&lt;/h2&gt;

&lt;p&gt;For retrieval I was uploading source documents via the Files API with file search enabled. One batch worked correctly. Another batch would upload without error but return no results in search.&lt;/p&gt;

&lt;p&gt;The difference was the filename. The batch that failed was uploaded with a generic name — something like &lt;code&gt;upload_1&lt;/code&gt; — with no extension. File search uses the filename to infer content type before indexing. A file without a recognized extension gets indexed as an unknown type, and the error is generic enough that it looks like a search quality issue rather than an upload problem.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;.pdf&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, or the correct extension to every filename at upload time fixed retrieval immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Transcription That Came Back as Garbage
&lt;/h2&gt;

&lt;p&gt;A voice dictation feature recorded audio in the browser and sent the blob to a Lambda Function URL behind CloudFront. The Lambda passed it to Whisper. The transcription came back — but it was nonsense. No error, no rejection, just wrong text.&lt;/p&gt;

&lt;p&gt;Lambda Function URLs base64-encode binary request bodies at the HTTP interface layer. The event includes an &lt;code&gt;isBase64Encoded&lt;/code&gt; flag, but if you treat the body as raw bytes in all cases, the buffer is silently corrupted. Whisper doesn't throw on bad audio — it produces garbage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audioBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isBase64Encoded&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any Lambda that accepts binary payloads — audio, images, PDFs — needs to check that flag before consuming the body. The cost of missing it is not an error. It's wrong output that looks like a model quality issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Search Console That Had No Traffic
&lt;/h2&gt;

&lt;p&gt;I wired up Google Search Console data fetching for a site with real traffic — I could see it in the GSC web UI. The API call went through, no errors, no 403. It returned zero rows.&lt;/p&gt;

&lt;p&gt;The site was registered as a domain property. Domain properties require &lt;code&gt;sc-domain:example.com&lt;/code&gt; as the &lt;code&gt;siteUrl&lt;/code&gt;, not &lt;code&gt;https://example.com&lt;/code&gt;. The API doesn't say "wrong format" or "property not found." It returns empty data as if the site has zero search traffic.&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;// Returns empty data, no error&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webmasters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchanalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Returns actual data&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webmasters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchanalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sc-domain:example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;sites.list()&lt;/code&gt; shows the exact format the API expects. I spent time checking date ranges and service account permissions before running that call and seeing &lt;code&gt;sc-domain:&lt;/code&gt; staring back at me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent Truncation in Structured Outputs
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;json_schema&lt;/code&gt; and &lt;code&gt;strict: true&lt;/code&gt;, OpenAI guarantees valid JSON — except when the response hits &lt;code&gt;max_output_tokens&lt;/code&gt;. When that happens, the stream ends with truncated JSON and &lt;code&gt;response.status&lt;/code&gt; set to &lt;code&gt;'incomplete'&lt;/code&gt;. This is not surfaced as an error. &lt;code&gt;response.completed&lt;/code&gt; still fires normally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response.completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;incomplete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incomplete_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;log&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="s1"&gt;Response truncated&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;reason&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;internal.error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The AI response was too long and got cut off.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;internal.finished&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="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="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;captureUsageStats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&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;This one had a bonus bug that made it harder to find. Before OpenAI supported structured output streaming, I used XML-like tags in the prompt to get parseable responses — &lt;code&gt;&amp;lt;next_action&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;message&amp;gt;&lt;/code&gt;, that kind of thing. When structured outputs shipped, I switched to &lt;code&gt;json_schema&lt;/code&gt; but left the XML parser in the catch branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&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;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;input&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;jsonError&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="nf"&gt;parseXMLResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// left in "just in case"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a truncated JSON response hit this code, &lt;code&gt;JSON.parse&lt;/code&gt; failed, the catch branch fired, and the XML parser found no tags. It returned &lt;code&gt;nextAction: null&lt;/code&gt; with the entire raw JSON string stuffed into the message field. The failure surfaced as a null-check bug three layers downstream — not as a parser problem. Dead code from a previous architecture, silently eating every truncation error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What These Five Have in Common
&lt;/h2&gt;

&lt;p&gt;Every failure surfaced downstream as something that didn't look like an API problem. Corrupted audio looked like a model quality issue. Empty GSC results looked like a permissions problem. Truncation looked like a null-check bug three layers away. The API boundary said success, and the real problem hid behind that signal.&lt;/p&gt;

&lt;p&gt;The only reliable defense is asserting on the output, not the status code — the kind of thing a &lt;a href="https://dev.to/production/code-audit"&gt;code audit&lt;/a&gt; catches systematically. Check that the image matches the prompt. Check that the buffer is actually binary. Check that the response has rows. Check that the JSON is complete. If you only verify that the call succeeded, you'll find the failure when your users do.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>llm</category>
      <category>testing</category>
    </item>
    <item>
      <title>Filling Forms No Tool Can Template</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Thu, 16 Apr 2026 10:11:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/filling-forms-no-tool-can-template-2dpg</link>
      <guid>https://dev.to/jurijtokarski/filling-forms-no-tool-can-template-2dpg</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/jurij/p/it-works-you-cant-ship-it"&gt;It Works, But You Can't Ship It&lt;/a&gt; covered the compliance wall — code execution sandboxes can fill DOCX forms, but they only exist in regions that don't match every customer's data residency policy. This post covers what I learned building the feature before that discovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Form Is Different
&lt;/h2&gt;

&lt;p&gt;Tender response forms have nothing in common with each other. One agency sends a form with merged table cells and numbered question blocks. The next sends checkboxes inside conditional formatting with section breaks in unexpected places. There is no shared structure, no recurring field names, no predictable layout.&lt;/p&gt;

&lt;p&gt;Every DOCX templating tool I evaluated — &lt;code&gt;docx-templates&lt;/code&gt;, &lt;code&gt;easy-template-x&lt;/code&gt;, &lt;code&gt;docxtemplater&lt;/code&gt; — works the same way: you prepare a template with &lt;code&gt;{variable_name}&lt;/code&gt; placeholders, pass in data, get a rendered document. That assumes you control the template. Tender forms come from government agencies. You don't control anything. You can't insert placeholders into a form you receive the day the tender opens.&lt;/p&gt;

&lt;p&gt;Filling these forms requires understanding an arbitrary document's structure, finding the insertion points, and knowing what content goes where. That's not a deterministic templating problem. It's a comprehension problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  DOCX Is a ZIP of XML
&lt;/h2&gt;

&lt;p&gt;My first &lt;a href="https://dev.to/production/poc-in-2-weeks"&gt;PoC&lt;/a&gt; tried the next obvious thing: extract the DOCX to markdown, send it to the model with draft content, get back filled markdown, convert to a new DOCX. Clean pipeline, completely useless output. The regenerated document lost every merged cell, every checkbox, every conditional format. The output was a different document that happened to contain similar text.&lt;/p&gt;

&lt;p&gt;A DOCX file is a ZIP archive of XML. &lt;code&gt;word/document.xml&lt;/code&gt; holds the content in OOXML format. The correct approach is to give the model the original binary, let it read the XML, find the insertion points, write modifications back, and save the modified ZIP. XML surgery on the original file — not regeneration.&lt;/p&gt;

&lt;p&gt;That's the only reliable way to fill DOCX forms with AI — operate on the XML, not on a lossy text conversion. And the only way to do this through an API, without deploying a separate Python service, is a code execution sandbox. Both OpenAI's &lt;code&gt;code_interpreter&lt;/code&gt; and Anthropic's code execution tool provide a sandboxed Python environment where &lt;code&gt;python-docx&lt;/code&gt; is available and the model can operate on the file directly.&lt;/p&gt;

&lt;p&gt;Once that architecture clicked, the API quirks started.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenAI: The File Goes in the Container
&lt;/h2&gt;

&lt;p&gt;My first attempt passed the uploaded DOCX as an &lt;code&gt;input_file&lt;/code&gt; content block in the user message — the pattern you'd use for images or PDFs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expected context stuffing file type to be a supported format... but got .docx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Context stuffing only supports PDFs, images, and plain text. The file has to go into the &lt;code&gt;code_interpreter&lt;/code&gt; container instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tools&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code_interpreter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;uploadedFile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message itself is plain text — you tell the model the filename so it knows what to look for in the sandbox. No file reference in the content block at all.&lt;/p&gt;

&lt;p&gt;Getting the filled file back had its own problem. The SDK exposes &lt;code&gt;client.containers.files.content&lt;/code&gt;, which looks callable. It isn't — it's a resource object. The working call is &lt;code&gt;client.containers.files.content.retrieve(containerId, fileId)&lt;/code&gt;. Neither the types nor the error message make this obvious. I found it by running &lt;code&gt;Object.getOwnPropertyNames&lt;/code&gt; on the object at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anthropic: Five Things at Once
&lt;/h2&gt;

&lt;p&gt;Claude has the same capability, but it requires five specific pieces in a single request. Miss any one and you get a cryptic failure.&lt;/p&gt;

&lt;p&gt;The file upload needs an explicit MIME type — not inferred from the extension. The API call needs two beta flags active simultaneously: &lt;code&gt;files-api-2025-04-14&lt;/code&gt; and &lt;code&gt;code-execution-2025-08-25&lt;/code&gt;. The file must be referenced as &lt;code&gt;container_upload&lt;/code&gt; in the content block — not &lt;code&gt;document&lt;/code&gt;, not &lt;code&gt;file&lt;/code&gt;. The tool declaration needs the full versioned type string &lt;code&gt;code_execution_20250825&lt;/code&gt;. And the download call needs the same beta flags passed again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;betas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;files-api-2025-04-14&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code-execution-2025-08-25&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userMessage&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;container_upload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uploaded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code_execution_20250825&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code_execution&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No single documentation page covers all five requirements together. Each piece is documented somewhere. The combination isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPT-5.1 Broke the Document
&lt;/h2&gt;

&lt;p&gt;I tested the form-filling flow across GPT-5.1, 5.2, and 5.4, on both Azure OpenAI and the public API.&lt;/p&gt;

&lt;p&gt;GPT-5.1 on Azure — our production deployment — wrote code that opened the DOCX but ignored formatting preservation entirely. Merged cells collapsed, checkboxes vanished, section breaks shifted. The output was a broken document. Same result on the public API — not an infrastructure issue, a model capability issue. GPT-5.2 was inconsistent: partially filled on one test, failed on the next. GPT-5.4 was the first in the lineup that reliably understood the OOXML structure, applied targeted modifications with &lt;code&gt;python-docx&lt;/code&gt;, and returned a valid binary with all formatting preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Claude Model Could Do It
&lt;/h2&gt;

&lt;p&gt;After the GPT results I tested Claude 4.6 — Opus, Sonnet, and Haiku — through Anthropic's code execution sandbox. Opus and Sonnet completed the task cleanly. The OOXML structure stayed intact, insertions landed in the right cells, formatting survived the round trip. Haiku was inconsistent — similar to GPT-5.2, partially filling on some runs and failing on others.&lt;/p&gt;

&lt;p&gt;The gap between the top-performing models was stark. GPT-5.1 couldn't preserve the structure at all. Claude Opus and Sonnet preserved it reliably. The model version matters more than the provider for this task. But which model you can actually deploy depends on where your customer's data is allowed to live — a &lt;a href="https://dev.to/discovery/tech-strategy"&gt;tech strategy&lt;/a&gt; decision, not a code decision. &lt;a href="https://dev.to/jurij/p/it-works-you-cant-ship-it"&gt;I didn't&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devjournal</category>
      <category>softwaredevelopment</category>
      <category>tooling</category>
    </item>
    <item>
      <title>SVG Animation Is Not DOM Animation</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Mon, 13 Apr 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/svg-animation-is-not-dom-animation-56jj</link>
      <guid>https://dev.to/jurijtokarski/svg-animation-is-not-dom-animation-56jj</guid>
      <description>&lt;p&gt;I had a bar chart race sitting in a private repo for over five years. A coding challenge from 2020 or so — built it, moved on, forgot about it. When I started building the &lt;a href="https://dev.to/toolkit"&gt;toolkit&lt;/a&gt; on varstatt.com — free browser-based dev tools — it seemed like an obvious candidate to resurrect.&lt;/p&gt;

&lt;p&gt;The new version would be React with SVG, part of a suite: bar chart race, line chart race, area chart race, bubble chart race. Same idea, four visualizations. Upload a CSV, watch the data animate.&lt;/p&gt;

&lt;p&gt;Every animation technique I reached for broke in a way I didn't expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS Transitions Do Nothing on Geometric Attributes
&lt;/h2&gt;

&lt;p&gt;First attempt on the line chart: CSS transitions on SVG elements. &lt;code&gt;transition: cx 300ms ease, cy 300ms ease&lt;/code&gt; on the &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt; dots tracking data points. Expected smooth interpolation between positions.&lt;/p&gt;

&lt;p&gt;The dots snapped. No animation. Chrome, Firefox, same result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* This does nothing useful */&lt;/span&gt;
&lt;span class="nt"&gt;circle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cx&lt;/span&gt; &lt;span class="m"&gt;300ms&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cy&lt;/span&gt; &lt;span class="m"&gt;300ms&lt;/span&gt; &lt;span class="n"&gt;ease&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;CSS transitions animate CSS properties. &lt;code&gt;cx&lt;/code&gt;, &lt;code&gt;cy&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt;, &lt;code&gt;points&lt;/code&gt; are not CSS properties — they're SVG attributes. They live in the DOM, but the browser's animation engine doesn't see them. You can change them from JavaScript and the element moves, but there's no interpolation. It jumps.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt; work because those are actual CSS properties that SVG elements happen to support. Everything that describes SVG geometry — positions, sizes, path data — sits outside that system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Animation Systems on the Same Property
&lt;/h2&gt;

&lt;p&gt;The bar chart race had horizontal bars with CSS transitions on &lt;code&gt;top&lt;/code&gt; and &lt;code&gt;width&lt;/code&gt;. I set &lt;code&gt;transition: top 1000ms ease-out, width 1000ms ease-out&lt;/code&gt; and advanced frames with &lt;code&gt;setInterval&lt;/code&gt;. That worked.&lt;/p&gt;

&lt;p&gt;Then I switched playback to &lt;code&gt;requestAnimationFrame&lt;/code&gt; for continuous interpolation — a float position updating at ~60fps instead of integer jumps every second.&lt;/p&gt;

&lt;p&gt;The bars turned jittery. Every RAF tick (~16ms) set a new &lt;code&gt;top&lt;/code&gt; value. Each value restarted the 1000ms CSS transition before the previous one completed. The browser's transition engine was fighting the RAF loop. Two animation systems controlling the same property, neither finishing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RAF updates position every ~16ms&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;BAR_HEIGHT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// CSS transition: "top 1000ms ease-out"&lt;/span&gt;
&lt;span class="c1"&gt;// Every 16ms: cancel current transition, start new 1000ms transition&lt;/span&gt;
&lt;span class="c1"&gt;// Result: jittery mess&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was to remove every CSS transition from every element that RAF touches. Bar positions, widths, SVG coordinates, label positions — all computed directly from the playback float.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RAF computes position directly — no CSS transition&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interpolatedRank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prevRank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextRank&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevRank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;frac&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;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;interpolatedRank&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;BAR_HEIGHT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// style={{ top, transition: 'none' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;CSS transitions and &lt;code&gt;requestAnimationFrame&lt;/code&gt; are competing strategies.&lt;/strong&gt; They solve the same problem differently. Layering both on the same property means neither works. I ended up with zero CSS transitions on animated properties across all four chart types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Colors That Follow Position Instead of Identity
&lt;/h2&gt;

&lt;p&gt;Bubble chart. Bubbles sorted by value each frame so the largest renders on top (correct z-order). Colors assigned by array index after sorting.&lt;/p&gt;

&lt;p&gt;Frame 1: Python is biggest, gets index 0, gets blue. Frame 2: JavaScript overtakes Python, gets index 0, gets blue. Python drops to index 1, turns orange. Every frame where the lead changes, half the bubbles swap colors.&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;// before: color by sorted position&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;items&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&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="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;palette&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="nx"&gt;palette&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: build a color map keyed by series name at parse time, before any sorting happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colorMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seriesNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;palette&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="nx"&gt;palette&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="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;map&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seriesNames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data.seriesNames&lt;/code&gt; preserves the original CSV column order. It never changes during playback. Sorting for z-order still happens, but it only affects render order, not color. Any visualization where items reorder needs visual properties assigned by identity, never by current array position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranks That Re-Sort Every Tick
&lt;/h2&gt;

&lt;p&gt;Same bar chart race. I was re-sorting bars by their interpolated value on every RAF tick. Values cross each other mid-frame constantly — Python at 11.83 overtakes Java at 11.81 for one tick, then Java is back on top the next. The bars flickered between positions 60 times a second.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;compute sort order only at whole frame boundaries&lt;/strong&gt;, store it in a pre-computed array, then interpolate rank positions as floats between frames.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frameRanks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frames&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;frame&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seriesNames&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;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}))&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ranks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&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="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="nx"&gt;ranks&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&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;ranks&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// interpolate rank as a float&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentRanks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;idx&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frac&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;nextRanks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;frac&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;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;barHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bar sliding from position 3 to position 1 moves smoothly over the full frame duration instead of jumping. No flickering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Curves That Rewrite Their Own History
&lt;/h2&gt;

&lt;p&gt;The line and area charts used Catmull-Rom splines. The animation draws a line progressively — like a pen moving across the screen. Curves looked great.&lt;/p&gt;

&lt;p&gt;The problem showed up immediately: as the animation advanced and new points entered the spline, the entire line wiggled. Segments already "drawn" shifted into new positions on every frame.&lt;/p&gt;

&lt;p&gt;Catmull-Rom computes each segment's control points from the tangent at its endpoints, and the tangent at any point depends on its neighbors. Add a new neighbor, all the tangents change. Feed completed points into the spline function as the animation progresses and every frame recalculates every segment. &lt;strong&gt;The old part of the curve is never stable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix split the work into two memos with different dependency arrays.&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;// Phase 1: compute ALL segments from full dataset, once&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stableGeometry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allPoints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frames&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;f&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="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;xScale&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="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;yScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;seriesName&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;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;precomputeSegments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allPoints&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;allPoints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segments&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Phase 2: reveal progressively, split active segment with de Casteljau&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chartData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stableGeometry&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;wholeIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&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;frac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;wholeIdx&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&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="nx"&gt;wholeIdx&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;s&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="nx"&gt;pathData&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frac&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wholeIdx&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;partial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;splitBezierAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wholeIdx&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;frac&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathData&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;d&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="nx"&gt;stableGeometry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pre-compute the full curve from all data points. Completed segments render byte-identical every frame. The active segment gets split at the exact fractional position using de Casteljau subdivision. Historical geometry never depends on current playback position.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Common Assumption
&lt;/h2&gt;

&lt;p&gt;Each problem came from expecting SVG elements to behave like DOM elements when animated. CSS transitions ignore geometric attributes. RAF and transitions fight over the same values. Array indices aren't stable identifiers when sort order changes. Spline algorithms that look local are global.&lt;/p&gt;

&lt;p&gt;The fix was the same every time: compute everything yourself, from one source of truth. Zero CSS transitions on animated properties, all positions derived from a single playback float.&lt;/p&gt;

&lt;p&gt;The four chart tools are part of the &lt;a href="https://dev.to/toolkit"&gt;varstatt.com/toolkit&lt;/a&gt; — free, browser-based, no sign-up: &lt;a href="https://dev.to/toolkit/bar-chart-race"&gt;bar chart race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/line-chart-race"&gt;line chart race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/area-chart-race"&gt;area chart race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/bubble-chart-race"&gt;bubble chart race&lt;/a&gt;. The old repo from 2020 bears no resemblance to what shipped.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>45 Tabs I Stopped Opening</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Thu, 09 Apr 2026 14:45:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/45-tabs-i-stopped-opening-34n5</link>
      <guid>https://dev.to/jurijtokarski/45-tabs-i-stopped-opening-34n5</guid>
      <description>&lt;p&gt;The JWT decoder I used to reach for sent the token to a server. I noticed because I had DevTools open for something else and saw the POST. A JWT often carries user IDs, emails, roles, expiration data. I'd been pasting production tokens into a stranger's endpoint for months.&lt;/p&gt;

&lt;p&gt;That was the first tool I built for the &lt;a href="https://dev.to/toolkit"&gt;toolkit&lt;/a&gt;. The rest followed the same pattern: I needed something, the available options were ad-heavy or required sign-up or made network calls that didn't need to happen. A Base64 encoder doesn't need a backend. Neither does a regex tester, a color converter, or a hash generator.&lt;/p&gt;

&lt;p&gt;There are 45 tools now. No sign-up, no tracking, no data collection. Most run entirely in the browser — a few like DNS Lookup and SSL Checker need a server call by nature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Catalogue
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Encoding&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/base64"&gt;Base64&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/jwt"&gt;JWT Decoder&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/image-base64"&gt;Image to Base64&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/encrypt"&gt;Encrypt / Decrypt&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/hash"&gt;Hash Generator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON &amp;amp; YAML&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/json"&gt;JSON Formatter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/json-yaml"&gt;JSON ↔ YAML&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/yaml-validate"&gt;YAML Validator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Markdown&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/markdown"&gt;Markdown Preview&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/diff"&gt;Text Diff&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/html-to-markdown"&gt;HTML ↔ Markdown&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/markdown-pdf"&gt;Markdown to PDF&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/markdown-docx"&gt;Markdown to DOCX&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/csv-editor"&gt;CSV Editor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Images&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/qr-code"&gt;QR Code&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/barcode"&gt;Barcode&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/image-convert"&gt;Image Converter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/favicon"&gt;Favicon Generator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/svg-optimizer"&gt;SVG Optimizer&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/image-placeholder"&gt;Placeholder Images&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/aspect-ratio"&gt;Aspect Ratio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/mesh-gradient"&gt;Mesh Gradient&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/css-covers"&gt;CSS Cover Art&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/color"&gt;Color Converter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/text-gradient"&gt;Text to Gradient&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Charts&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/bar-chart-race"&gt;Bar Chart Race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/line-chart-race"&gt;Line Chart Race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/bubble-chart-race"&gt;Bubble Chart Race&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/area-chart-race"&gt;Area Chart Race&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/dns-lookup"&gt;DNS Lookup&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/cors-tester"&gt;CORS Tester&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/ssl-checker"&gt;SSL Checker&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/og-preview"&gt;OG Tag Validator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/http-status"&gt;HTTP Status Codes&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/robots-txt"&gt;Robots.txt Validator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/sitemap-validator"&gt;Sitemap Validator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/user-agent"&gt;User Agent Parser&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Text&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/regex"&gt;Regex Tester&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/case-converter"&gt;Case Converter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/slug-generator"&gt;Slug Generator&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/word-counter"&gt;Word Counter&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/copy-paste-character"&gt;Copy Paste Characters&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generators&lt;/strong&gt; — &lt;a href="https://dev.to/toolkit/uuid"&gt;UUID&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/password"&gt;Password&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/crontab"&gt;Crontab&lt;/a&gt;, &lt;a href="https://dev.to/toolkit/timestamp"&gt;Unix Timestamp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most are straightforward. Three outgrew the toolkit and became standalone npm packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text to Gradient
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/toolkit/text-gradient"&gt;Text to Gradient&lt;/a&gt; tool and the &lt;a href="https://dev.to/toolkit/mesh-gradient"&gt;Mesh Gradient Generator&lt;/a&gt; both needed the same thing: a way to turn an arbitrary input into a unique, stable visual. Same input, same gradient, every time. No database, no storage.&lt;/p&gt;

&lt;p&gt;A djb2-style 32-bit hash is all it takes:&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;textHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5381&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="nx"&gt;str&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="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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&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;Everything derives from that number. &lt;code&gt;hash % palettes.length&lt;/code&gt; selects the color palette. &lt;code&gt;seededRandom(hash + layerIndex * 1000)&lt;/code&gt; generates position and opacity variation per layer. The same string always produces the same gradient — looks hand-crafted, costs nothing to store.&lt;/p&gt;

&lt;p&gt;The gradients themselves are layered &lt;code&gt;radial-gradient()&lt;/code&gt; calls. There's no &lt;code&gt;mesh-gradient()&lt;/code&gt; in CSS. What works is stacking 6-8 radial gradients positioned at organic spots — 15%, 37%, 63%, 82% — not pure corners or centers, which look algorithmic. Each one uses a &lt;code&gt;0px&lt;/code&gt; first stop for a crisp center and &lt;code&gt;transparent&lt;/code&gt; at 50% for soft falloff. The browser composites them in layer order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;background&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="nt"&gt;radial-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;ellipse&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="err"&gt;15&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="err"&gt;20&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;120&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;40&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;200&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;9&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt;&lt;span class="o"&gt;%),&lt;/span&gt;
  &lt;span class="nt"&gt;radial-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;circle&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="err"&gt;80&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="err"&gt;10&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;40&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;180&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;220&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt; &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%),&lt;/span&gt;
  &lt;span class="nt"&gt;radial-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;ellipse&lt;/span&gt; &lt;span class="nt"&gt;at&lt;/span&gt; &lt;span class="err"&gt;55&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="err"&gt;75&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;rgba&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;200&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;120&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;85&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt; &lt;span class="err"&gt;55&lt;/span&gt;&lt;span class="o"&gt;%),&lt;/span&gt;
  &lt;span class="err"&gt;#1&lt;/span&gt;&lt;span class="nt"&gt;a0a2e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For tinting — hover states, borders, soft fills — &lt;code&gt;color-mix()&lt;/code&gt; handles it without any HSL arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;background-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;color-mix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--accent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;12&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;white&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;border-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;color-mix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;srgb&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--accent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;25&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="nt"&gt;transparent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing that cost me time: making these dynamic in Tailwind. A template literal like &lt;code&gt;bg-[color-mix(in_srgb,${color}_12%,white)]&lt;/code&gt; silently produces nothing. Tailwind's compiler scans source files for complete static strings at build time. A class assembled from a variable doesn't exist as a string when the scanner runs — it gets skipped with no warning. Inline styles are the fallback for truly dynamic values.&lt;/p&gt;

&lt;p&gt;Text to Gradient is now an &lt;a href="https://www.npmjs.com/package/text-to-gradient" rel="noopener noreferrer"&gt;npm package&lt;/a&gt;. It powers the default cover images across the site when a page has no custom visual. Those covers are also animated — which is where the next package came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loopkit
&lt;/h2&gt;

&lt;p&gt;Every tool, blog post, landing page, and discovery step on varstatt.com has an animated SVG cover — all powered by &lt;a href="https://dev.to/toolkit/loopkit"&gt;Loopkit&lt;/a&gt;. I had ~35 cover designs already in JSX when I started building the engine underneath them. The first decision was whether to keep composable React components or switch to schema-driven JSON.&lt;/p&gt;

&lt;p&gt;JSON won because of output flexibility. A React component locks you into JSX. A schema is data — it can render to HTML for OG images, to SVG for exports, to CSS for emails, or to React for the live site. The core engine has no React dependency.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;       &lt;span class="c1"&gt;// full HTML with inline styles&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;      &lt;span class="c1"&gt;// React style objects&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHtml&lt;/span&gt;  &lt;span class="c1"&gt;// just the elements&lt;/span&gt;
&lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hoverCss&lt;/span&gt;   &lt;span class="c1"&gt;// raw CSS rules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Phase ordering.&lt;/strong&gt; I had the cycle structured as: animate forward, hold final frame, fade out, loop. Loop restarts were smooth, but the first &lt;code&gt;play()&lt;/code&gt; call snapped instantly from the held frame to frame 0. Moving the fade to the beginning of the cycle fixed it — every iteration, including the first, starts with a reverse interpolation from wherever the animation sits, then plays forward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hover exits.&lt;/strong&gt; &lt;code&gt;mouseenter&lt;/code&gt; called &lt;code&gt;play()&lt;/code&gt;, &lt;code&gt;mouseleave&lt;/code&gt; called &lt;code&gt;reset()&lt;/code&gt;. The reset snapped to the static frame — functional but mechanical. A &lt;code&gt;settle()&lt;/code&gt; method reads the live position and interpolates smoothly from there to the end state over a capped duration. The key: tracking &lt;code&gt;currentAnimElapsed&lt;/code&gt; during active animation is what makes settle() possible. Without it, mouseleave can only snap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stagger math.&lt;/strong&gt; In a staggered loop where each element has its own delay, the cycle duration isn't &lt;code&gt;animDuration&lt;/code&gt;. It's the time until the last element finishes, plus hold time. Using just &lt;code&gt;animDuration&lt;/code&gt; cuts off late-starting elements before they complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastFinish&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="k"&gt;for &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;el&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&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;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sequence&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;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stagger&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&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="nx"&gt;lastFinish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastFinish&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;duration&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;cycleDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastFinish&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;holdDuration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-centering all 48 schemas programmatically surfaced one more problem. The centering script computes a bounding box, then shifts coordinates to align with the canvas center. Loopkit schemas use &lt;code&gt;[from, to]&lt;/code&gt; arrays for animated values — a bar animates with &lt;code&gt;y: [247, 87]&lt;/code&gt;. The bbox script was reading &lt;code&gt;[0]&lt;/code&gt;, the start value. A bar starting at y=247 with height 180 gave a 427px bounding box on a 280px canvas. The fix was one index: read &lt;code&gt;[1]&lt;/code&gt;, the end state, because that's the visual rest position.&lt;/p&gt;

&lt;p&gt;Loopkit is under 5KB with zero dependencies. It's an &lt;a href="https://www.npmjs.com/package/loopkit" rel="noopener noreferrer"&gt;npm package&lt;/a&gt; now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Markdown Repository
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/toolkit/markdown-repository"&gt;Markdown Repository&lt;/a&gt; began as a utility function inside this site. I query &lt;code&gt;.md&lt;/code&gt; and &lt;code&gt;.mdx&lt;/code&gt; files by frontmatter — filter by tags, sort by date, paginate. The API looks like Firestore's &lt;code&gt;where&lt;/code&gt;/&lt;code&gt;orderBy&lt;/code&gt;/&lt;code&gt;limit&lt;/code&gt; chain. Once three of my projects used the same copy-pasted code, I extracted it into an &lt;a href="https://www.npmjs.com/package/markdown-repository" rel="noopener noreferrer"&gt;npm package&lt;/a&gt;. The publish pipeline — trusted publishing with OIDC, no stored tokens — turned into &lt;a href="https://dev.to/jurij/p/npm-trusted-publishing-from-github-actions"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full List
&lt;/h2&gt;

&lt;p&gt;45 tools, three npm packages. The full list is at &lt;a href="https://dev.to/toolkit"&gt;varstatt.com/toolkit&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>security</category>
      <category>showdev</category>
      <category>tooling</category>
    </item>
    <item>
      <title>npm Publish Without Tokens</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 07 Apr 2026 10:35:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/npm-publish-without-tokens-4692</link>
      <guid>https://dev.to/jurijtokarski/npm-publish-without-tokens-4692</guid>
      <description>&lt;p&gt;I published an npm package last week — &lt;a href="https://www.npmjs.com/package/markdown-repository" rel="noopener noreferrer"&gt;markdown-repository&lt;/a&gt;, a Firestore-style query builder for markdown files. The code worked. The tests passed. The release pipeline took longer to get right than the package itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old Way
&lt;/h2&gt;

&lt;p&gt;The standard npm publishing workflow uses a long-lived access token. You generate it on npmjs.com, store it as a GitHub Actions secret, and reference it in your workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, but the token never expires, has write access to your packages, and lives in plain text in your CI secrets. If it leaks — through a copied workflow file or a careless log — anyone can publish under your name.&lt;/p&gt;

&lt;p&gt;npm's granular tokens improved this slightly. You can scope them to specific packages and set a 90-day expiration. But you still have to rotate them manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trusted Publishing
&lt;/h2&gt;

&lt;p&gt;npm now supports &lt;a href="https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-trusted-publishing" rel="noopener noreferrer"&gt;trusted publishing with OIDC&lt;/a&gt;. Instead of a stored token, your GitHub Actions workflow proves its identity to npm using a short-lived OpenID Connect credential. npm verifies the credential against the workflow you've authorized, and accepts the publish.&lt;/p&gt;

&lt;p&gt;No token to store. No token to rotate. No token to leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Publish Is Manual
&lt;/h2&gt;

&lt;p&gt;Before you can configure trusted publishing, the package must already exist on the registry. npm has no "pending publisher" feature — you can't set up OIDC for a package that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;For the very first version, publish from your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm login
npm publish &lt;span class="nt"&gt;--access&lt;/span&gt; public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I spent a while debugging my workflow before realizing trusted publishing only works from the second release onward. Once the package exists on npmjs.com, go to its settings and add a trusted publisher. From that point, the workflow handles everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the Workflow
&lt;/h2&gt;

&lt;p&gt;The setup has two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On npmjs.com&lt;/strong&gt;: go to your package settings, add a trusted publisher. Specify the GitHub org/user, repository, workflow filename, and optionally an environment name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the workflow&lt;/strong&gt;: add &lt;code&gt;id-token: write&lt;/code&gt; permission and an &lt;code&gt;environment&lt;/code&gt; that matches what you configured on npm.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24.x&lt;/span&gt;
          &lt;span class="na"&gt;registry-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry.npmjs.org&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish --provenance --access public&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provenance attestation is automatic with trusted publishing. The &lt;code&gt;--provenance&lt;/code&gt; flag is redundant but makes the intent explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Misleading 404
&lt;/h2&gt;

&lt;p&gt;My first three releases failed with this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm error 404 Not Found - PUT https://registry.npmjs.org/markdown-repository
npm error 404 'markdown-repository@1.1.0' is not in this registry.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package existed. The version was correct. The OIDC token exchange succeeded — I could see the signed provenance statement in &lt;a href="https://search.sigstore.dev" rel="noopener noreferrer"&gt;Rekor's transparency log&lt;/a&gt;. Everything worked except the actual publish.&lt;/p&gt;

&lt;p&gt;The problem: &lt;strong&gt;Node 22 ships with npm 10.x. Trusted publishing requires npm 11.5.1 or later.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;npm's documentation mentions this requirement. The error message doesn't. A 404 on PUT looks like a registry problem or a package name conflict. Nothing points you toward an npm version mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Use Node 24.x in your workflow. On GitHub Actions, &lt;code&gt;node-version: 24.x&lt;/code&gt; resolves to a recent patch that includes npm 11.5.1+ — &lt;a href="https://github.com/varstatt/markdown-repository/blob/main/.github/workflows/publish-package.yaml" rel="noopener noreferrer"&gt;markdown-repository&lt;/a&gt; publishes this way without an explicit npm upgrade.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24.x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're stuck on an older Node version, upgrade npm explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g npm@latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With npm 11.5.1+, the same workflow publishes successfully. No tokens needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Environment Mismatch
&lt;/h2&gt;

&lt;p&gt;The same 404 shows up when the &lt;strong&gt;environment name&lt;/strong&gt; on npmjs.com doesn't match the &lt;code&gt;environment&lt;/code&gt; field in your workflow job. If your workflow says &lt;code&gt;environment: release&lt;/code&gt; but npm has the environment field blank (or vice versa), the OIDC claims don't match and npm rejects the publish — with a 404, not a meaningful error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Pipeline Looks Like Now
&lt;/h2&gt;

&lt;p&gt;The full workflow for &lt;a href="https://github.com/varstatt/markdown-repository" rel="noopener noreferrer"&gt;markdown-repository&lt;/a&gt; runs lint, tests, and build on every commit. On a GitHub release, it publishes to npm with provenance — no secrets configured anywhere in the repository.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>github</category>
      <category>npm</category>
      <category>security</category>
    </item>
    <item>
      <title>Three Ways the Wrong Value Won</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 31 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/three-ways-the-wrong-value-won-49o6</link>
      <guid>https://dev.to/jurijtokarski/three-ways-the-wrong-value-won-49o6</guid>
      <description>&lt;p&gt;A user created a tender and immediately couldn't edit it. Not after a day, not after some permission change — immediately. They hit "Create," the page loaded, and the edit button was grayed out.&lt;/p&gt;

&lt;p&gt;That was the first bug. It took three fixes across two projects before I understood what connected them: in each case, the value that reached the client wasn't the value I'd computed. Something else got there first — by being faster, by being stale, or by being last in the object literal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Value That Arrived Too Early
&lt;/h2&gt;

&lt;p&gt;I pulled up the tender document in Firestore. The &lt;code&gt;ai_driver&lt;/code&gt; field was missing entirely. The frontend created tenders like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenderData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;companyData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&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;New companies had no &lt;code&gt;ai_driver&lt;/code&gt; set. The conditional spread evaluated to falsy, so the field was never written. That was supposed to be fine — a Cloud Function trigger would set the default after creation.&lt;/p&gt;

&lt;p&gt;The Firestore snapshot listener had other plans. It fired before the Cloud Function, saw no &lt;code&gt;ai_driver&lt;/code&gt;, and ran this check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDiscontinuedDriver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;DISCONTINUED_AI_DRIVERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing field. Falsy. "Discontinued." Read-only. The user just watched their tender lock itself. Every single tender created by a new company since this code shipped had been born locked.&lt;/p&gt;

&lt;p&gt;The fix had two parts. The frontend writes every field it reads immediately after creation — no delegating defaults to triggers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenderData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;company_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;companyData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_AI_DRIVER&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;And the discontinuation check had to distinguish "missing" from "actively deprecated":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDiscontinuedDriver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;DISCONTINUED_AI_DRIVERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deployed both. Bug reports kept coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Value That Outlived Its Meaning
&lt;/h2&gt;

&lt;p&gt;Different users, same symptom. Tenders locked on creation. But these companies had &lt;code&gt;ai_driver&lt;/code&gt; explicitly set in Firestore — set to &lt;code&gt;assistants-api-gpt4o&lt;/code&gt;, a driver I'd discontinued months earlier.&lt;/p&gt;

&lt;p&gt;I traced it to the organization settings form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;aiDriver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assistants-api-gpt4o&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hardcoded fallback was a leftover from migration. New companies had no &lt;code&gt;ai_driver&lt;/code&gt; in Firestore, so the form loaded with a dead value nobody could see. The field wasn't even visible on the settings page — it was an internal config, not a user-facing dropdown.&lt;/p&gt;

&lt;p&gt;The form submitted its entire state on every save. A user enables a jurisdiction toggle, hits save, and the payload includes &lt;code&gt;ai_driver: "assistants-api-gpt4o"&lt;/code&gt;. The backend guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&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;Truthy string passes. The discontinued driver gets written to Firestore. Every tender created after that inherits it. The user who toggled a jurisdiction setting three weeks ago has no idea they just broke tender creation for their entire organization.&lt;/p&gt;

&lt;p&gt;I dropped the hardcoded fallback. Deployed. Reports kept coming — users had the old bundle cached. Every save from a cached session re-wrote the stale value, undoing any Firestore cleanup I ran manually.&lt;/p&gt;

&lt;p&gt;The frontend fix wasn't the real fix. The real fix was backend enum validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AIDriver&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_driver&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend rejects any value not in the current enum. Cached bundles, stale defaults, garbage input — all dropped. The frontend can send whatever it wants; the backend is the last line, and it has to act like it.&lt;/p&gt;

&lt;p&gt;That stopped the bleeding. But the pattern was already in my head when I opened a different codebase weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Value That Was Always Last
&lt;/h2&gt;

&lt;p&gt;I was reviewing a feature flag called &lt;code&gt;ai_chat_enabled&lt;/code&gt;. The backend computed it from the user's subscription plan — a careful if/else chain that looked up the plan, checked edge cases, and resolved to a boolean. Solid logic. Well-tested in isolation.&lt;/p&gt;

&lt;p&gt;Then I looked at the response builder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ai_chat_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ai_chat_enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;customerPreferences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;customerPreferences&lt;/code&gt; came from DynamoDB. It contained its own &lt;code&gt;ai_chat_enabled&lt;/code&gt; key — the raw stored preference, not the computed one. The spread came after the explicit assignment.&lt;/p&gt;

&lt;p&gt;JavaScript object literals follow last-writer-wins. The spread silently overwrote the computed value with whatever was sitting in the database. The entire plan-based computation — the lookup, the edge cases, the if/else chain — never reached the client. Not once. Not since the day this code shipped.&lt;/p&gt;

&lt;p&gt;The tests checked that the computation logic returned the right boolean. They never checked that the response builder actually used it.&lt;/p&gt;

&lt;p&gt;The fix was one line — move the spread before the explicit fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&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="nx"&gt;customerPreferences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ai_chat_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ai_chat_enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Computed values last. Raw data first. The spread provides defaults; the explicit fields override them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wrong Value Always Has a Way In
&lt;/h2&gt;

&lt;p&gt;Timing, staleness, ordering. Three mechanisms, same result: the value I intended never made it. If the frontend reads a field, the backend must validate it. If the backend computes a value, nothing downstream should be able to quietly replace it. The wrong value will always find a way in. The only defense is making sure the right value goes last.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>An Empty AI Response Corrupted Chat History</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/an-empty-ai-response-corrupted-chat-history-1ap</link>
      <guid>https://dev.to/jurijtokarski/an-empty-ai-response-corrupted-chat-history-1ap</guid>
      <description>&lt;p&gt;The spinner ran. The stream closed. The chat bubble stayed empty. No error anywhere.&lt;/p&gt;

&lt;p&gt;I was building a conversational discovery tool for founders — a multi-step Gemini-powered flow that walked people through product decisions, collected answers, and built a structured brief. Complex setup: long system prompt, tool definitions, large user messages. Genkit's &lt;code&gt;generateStream&lt;/code&gt; handling each turn.&lt;/p&gt;

&lt;p&gt;Intermittently, a user would send a message and get nothing back. No timeout, no catch block firing, no non-2xx status. Just a clean stream completion with zero content inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Logs Said When I Added Them
&lt;/h2&gt;

&lt;p&gt;Standard error handling gives you no signal here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateStream&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;for&lt;/span&gt; &lt;span class="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// exits immediately — no chunks arrive&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// response.text() returns ''&lt;/span&gt;
  &lt;span class="c1"&gt;// no exception thrown&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="c1"&gt;// never reached&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding chunk-level logging made it visible. The stream was completing, but the one chunk that arrived looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chunk #1 has no content.
Keys: [ 'index', 'role', 'content', 'custom', 'previousChunks', 'parser' ]
role: model
content.length: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;content&lt;/code&gt; property existed. It wasn't null. It was an empty array. The keys &lt;code&gt;custom&lt;/code&gt;, &lt;code&gt;previousChunks&lt;/code&gt;, and &lt;code&gt;parser&lt;/code&gt; are Genkit's internal markers for a thinking chunk. The model had spent the entire response budget on internal reasoning and had nothing left to output. HTTP 200. Genkit reported success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Ways to Get Nothing
&lt;/h2&gt;

&lt;p&gt;Gemini 2.5 Flash ships with thinking mode enabled by default. Under normal inputs that's fine. Under heavy inputs — long system prompt plus tool definitions plus a long user message — it can exhaust the entire token budget on reasoning before producing a single output token.&lt;/p&gt;

&lt;p&gt;There's a second cause that produces the same result: silent rate limiting. Rather than returning a 4xx, Gemini returns a valid, complete, empty stream. The observable symptom is identical. The detection is identical: assert that at least one content chunk arrived after the stream closes.&lt;/p&gt;

&lt;p&gt;For the thinking mode case, the fix is one line in the Genkit config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;thinkingConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;thinkingBudget&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="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;thinkingBudget: 0&lt;/code&gt; disables extended thinking. For a conversational flow where latency matters more than deep reasoning, there's no reason to let the model spend the budget on internal traces.&lt;/p&gt;

&lt;p&gt;Fix deployed. I moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Save That Made It Permanent
&lt;/h2&gt;

&lt;p&gt;What I hadn't checked: the database. Every one of those empty responses had already been saved to Firestore. An empty string is a valid string. The save ran. Nothing flagged it.&lt;/p&gt;

&lt;p&gt;The stream handler read &lt;code&gt;finalResult.text&lt;/code&gt; after &lt;code&gt;generateStream&lt;/code&gt; resolved and wrote it as the AI's message. When thinking mode ate the budget, &lt;code&gt;finalResult.text&lt;/code&gt; was &lt;code&gt;""&lt;/code&gt;. Firestore now held a record of every affected conversation — each one storing a legitimate-looking AI turn with no content.&lt;/p&gt;

&lt;h2&gt;
  
  
  History as Poison
&lt;/h2&gt;

&lt;p&gt;When those users came back and sent new messages, &lt;code&gt;getChatHistory&lt;/code&gt; pulled their messages from Firestore and formatted them for Gemini:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai&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;model&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;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;msg.content&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;, that produces &lt;code&gt;{ role: "model", content: [{ text: "" }] }&lt;/code&gt;. A valid-looking empty model turn in the middle of a real conversation. Gemini received it, interpreted it as unfinished context, entered thinking mode to reason about it, exhausted the budget, returned nothing — which got saved as another empty message, which poisoned the next turn.&lt;/p&gt;

&lt;p&gt;The conversation was permanently, silently broken. No exception at any layer. No signal the user could act on. Just a chat that would never respond again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix That Requires Two Places
&lt;/h2&gt;

&lt;p&gt;Fixing only the stream detection isn't enough — the database is already corrupted. Fixing only the history filter isn't enough — new empty responses can still arrive and be saved. Both defenses are required.&lt;/p&gt;

&lt;p&gt;Never write an empty AI message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;accumulatedText&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;finalResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="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;finalText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveAIMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[StreamHandler] Skipping empty AI message save&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And filter empty turns before sending history to the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;msg&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="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;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai&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;model&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;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss either one and the loop can restart. The stream guard stops new corruption. The history filter handles the records already in the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Retry That Made It Worse
&lt;/h2&gt;

&lt;p&gt;The first instinct after detecting an empty stream was to retry. The naive retry called the same send function — which re-inserted the user's message into the messages array. The model received the question twice. On an already-stressed conversation with heavy context, this accelerated the problem rather than resolving it.&lt;/p&gt;

&lt;p&gt;The fix is an &lt;code&gt;isRetry&lt;/code&gt; flag that skips message insertion on retry calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;streamMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isRetry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isRetry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setChatMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userMsgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aiMsgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setChatMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&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;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;aiMsgId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aiMsgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;streamAIResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user message stays in history exactly once. Without this, retry logic breaks an already-broken conversation faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Every Layer Said "Success"
&lt;/h2&gt;

&lt;p&gt;What made this hard to debug: every layer reported success. HTTP 200, no caught exceptions, valid Firestore writes, clean history formatting. The failure was in the semantics, not the mechanics. An empty model turn is not a successful model turn — and asserting that distinction at each boundary is the only thing that stops the loop.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>javascript</category>
      <category>llm</category>
    </item>
    <item>
      <title>Software Engineering Principles for Startups</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Mon, 23 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/software-engineering-principles-for-startups-3215</link>
      <guid>https://dev.to/jurijtokarski/software-engineering-principles-for-startups-3215</guid>
      <description>&lt;p&gt;Most software engineering principles are written for teams of 50. Agile ceremonies, sprint retrospectives, quarterly planning — built for organizations, not for founders shipping products.&lt;/p&gt;

&lt;p&gt;I run a solo development studio. I ship to production every week, manage multiple client projects simultaneously, and maintain everything I build. Over the years I wrote down the principles that make this work. There are &lt;a href="https://varstatt.com/principles" rel="noopener noreferrer"&gt;33 of them&lt;/a&gt;, organized across five areas: philosophy, discovery, delivery, partnership, and diligence.&lt;/p&gt;

&lt;p&gt;Here's what actually matters when you're building software for startups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With What's Worth Building
&lt;/h2&gt;

&lt;p&gt;The most expensive software is software that shouldn't exist. Before writing any code, I run every project through a simple filter: &lt;a href="https://varstatt.com/principles/discovery/worth-building" rel="noopener noreferrer"&gt;is this worth building?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most ideas aren't. Not because they're bad ideas — but because they solve the wrong problem, or solve it at the wrong time, or solve it for a market that doesn't care enough to pay.&lt;/p&gt;

&lt;p&gt;When something passes that filter, the next step is &lt;a href="https://varstatt.com/principles/discovery/find-the-core" rel="noopener noreferrer"&gt;finding the core&lt;/a&gt; — the one capability that makes this product exist. Not the feature list. Not the competitor parity matrix. The single thing that, if it doesn't work, means nothing else matters.&lt;/p&gt;

&lt;p&gt;Jane's booking app needed staff-to-service matching that handled real salon complexity. Everything else — payment processing, notifications, calendar sync — is infrastructure you can buy. The core is the only part worth building custom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the Budget, Flex the Scope
&lt;/h2&gt;

&lt;p&gt;Startups don't have unlimited time or money. The traditional approach — estimate everything, add buffer, hope it fits — doesn't work because estimates are wrong.&lt;/p&gt;

&lt;p&gt;I use &lt;a href="https://varstatt.com/principles/discovery/appetite-not-estimates" rel="noopener noreferrer"&gt;appetite, not estimates&lt;/a&gt;. You decide how much time a problem is worth — two weeks, six weeks — and that's your constraint. Then &lt;a href="https://varstatt.com/principles/discovery/scope-shaping" rel="noopener noreferrer"&gt;scope shaping&lt;/a&gt; fits what you build inside that box.&lt;/p&gt;

&lt;p&gt;This sounds backwards but it changes everything. Instead of "how long will this take?" the question becomes "what's the best version we can ship in three weeks?" That question has a useful answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship Continuously, Not Eventually
&lt;/h2&gt;

&lt;p&gt;Startup velocity comes from short feedback loops. Every principle in my &lt;a href="https://varstatt.com/principles/delivery" rel="noopener noreferrer"&gt;delivery system&lt;/a&gt; optimizes for one thing: getting working software in front of users faster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/delivery/wip-one" rel="noopener noreferrer"&gt;WIP One&lt;/a&gt; means one task in progress at a time. Finish it, deploy it, move on. Context switching kills solo developers faster than bad architecture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/delivery/production-is-done" rel="noopener noreferrer"&gt;Production is done&lt;/a&gt; means nothing counts until it's live. Not "done on my machine." Not "ready for review." Live in production with monitoring in place. This sounds obvious but most projects have weeks of "almost done" work that never ships.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/delivery/continuous-flow" rel="noopener noreferrer"&gt;Continuous flow&lt;/a&gt; replaces sprints with a priority queue. No sprint planning, no velocity tracking, no ceremony. Just: what's most important right now? Do that. Deploy it.&lt;/p&gt;

&lt;p&gt;For startup teams, this means you can change direction on Monday and ship the new thing by Wednesday. No "we'll add it to next sprint."&lt;/p&gt;

&lt;h2&gt;
  
  
  Software Development Is a Cost, Not a Craft
&lt;/h2&gt;

&lt;p&gt;This is the one that makes developers uncomfortable: &lt;a href="https://varstatt.com/principles/philosophy/business-cost" rel="noopener noreferrer"&gt;software development is a business cost&lt;/a&gt;. It's an operational expense, like rent or hosting.&lt;/p&gt;

&lt;p&gt;That doesn't mean quality doesn't matter. It means quality serves the business, not the developer's ego. The &lt;a href="https://varstatt.com/principles/delivery/scout-rule" rel="noopener noreferrer"&gt;scout rule&lt;/a&gt; — leave the codebase better than you found it — keeps quality high without separate "refactoring sprints" that never get prioritized.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/philosophy/consolidation" rel="noopener noreferrer"&gt;Consolidation&lt;/a&gt; means fewer tools, fewer vendors, fewer moving parts. Every additional service is another bill, another dashboard, another thing that breaks at 2 AM. For startups, simplicity is a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the Boring Parts Last
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/discovery/context-over-purity" rel="noopener noreferrer"&gt;Context over purity&lt;/a&gt; means making pragmatic decisions, not architecturally perfect ones. Use the default stack. Buy what you can. Build only what's core.&lt;/p&gt;

&lt;p&gt;I keep a &lt;a href="https://varstatt.com/principles/delivery/default-stack" rel="noopener noreferrer"&gt;default stack&lt;/a&gt; and use it for everything unless there's a specific reason not to. Deep expertise in familiar tools beats starting fresh with the "best" technology for each project.&lt;/p&gt;

&lt;p&gt;When a client asks "should we use microservices?" the answer is almost always no. Not because microservices are bad — because for a startup, a monolith you ship in three weeks beats a distributed system you ship in three months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transparency Over Everything
&lt;/h2&gt;

&lt;p&gt;Startup partnerships fail on misaligned expectations, not technical problems. Every &lt;a href="https://varstatt.com/principles/partnership" rel="noopener noreferrer"&gt;partnership principle&lt;/a&gt; I follow addresses this directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/partnership/transparency" rel="noopener noreferrer"&gt;Transparency&lt;/a&gt; means full visibility into progress, problems, and decisions. No weekly status reports that hide bad news. When something goes wrong — and it will — the client knows the same day.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/partnership/weekly-accountability" rel="noopener noreferrer"&gt;Weekly accountability&lt;/a&gt; creates a billing cycle that forces honest conversations. If the week didn't produce visible progress, that's a problem we discuss before the next week starts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/partnership/exit-freedom" rel="noopener noreferrer"&gt;Exit freedom&lt;/a&gt; means clients can leave at any time. No contracts, no lock-in, no hard feelings. If the work isn't valuable, you should be able to stop paying for it immediately. This keeps me accountable in a way that six-month contracts never could.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maintenance Is Not a Phase
&lt;/h2&gt;

&lt;p&gt;The biggest lie in software development: "We'll build it, launch it, then maintain it." As if building and maintaining are separate activities.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://varstatt.com/principles/diligence/no-split" rel="noopener noreferrer"&gt;No split&lt;/a&gt; means development and maintenance happen continuously. Every feature I ship includes monitoring. Every deployment includes the ability to roll back. &lt;a href="https://varstatt.com/principles/delivery/quality-gates" rel="noopener noreferrer"&gt;Quality gates&lt;/a&gt; and &lt;a href="https://varstatt.com/principles/delivery/feature-flags" rel="noopener noreferrer"&gt;feature flags&lt;/a&gt; make it safe to fail and fast to fix.&lt;/p&gt;

&lt;p&gt;For startups, this means you don't need a separate "operations team" from day one. The development process IS the operations process. Ship code, watch it run, fix what breaks, improve what works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full System
&lt;/h2&gt;

&lt;p&gt;These principles aren't independent tips — they form a system. Discovery principles prevent you from building the wrong thing. Delivery principles get the right thing shipped fast. Partnership principles keep everyone aligned. Diligence principles make sure it keeps working.&lt;/p&gt;

&lt;p&gt;I documented all &lt;a href="https://varstatt.com/principles" rel="noopener noreferrer"&gt;33 principles&lt;/a&gt; as a reference — not as rules to follow blindly, but as a starting point for founders who want their engineering process to actually work.&lt;/p&gt;

&lt;p&gt;The best engineering principles for your startup are the ones that let you ship every week. Everything else is overhead.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live: &lt;a href="https://varstatt.com/principles" rel="noopener noreferrer"&gt;varstatt.com/principles&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>productivity</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>startup</category>
    </item>
    <item>
      <title>Why Scrum Fails In Small Teams</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/why-scrum-fails-in-small-teams-25a6</link>
      <guid>https://dev.to/jurijtokarski/why-scrum-fails-in-small-teams-25a6</guid>
      <description>&lt;p&gt;A few years ago, my development team of three was sitting through a 90-minute sprint planning ceremony. The feature we planned took two days to build.&lt;/p&gt;

&lt;p&gt;We spent more time estimating and discussing the work than doing it. I was the team lead, and this was the moment I started questioning what we were actually doing here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scrum Solved a Real Problem — Then Became One
&lt;/h2&gt;

&lt;p&gt;Scrum is a project management framework built around fixed-length iterations called sprints — usually two weeks. Each sprint has a planning ceremony, daily standups, a review, and a retrospective. There's a product owner who manages the backlog, a scrum master who facilitates the process, and a development team that executes.&lt;/p&gt;

&lt;p&gt;It was created in the 1990s to bring structure to software projects that were failing under waterfall — the old approach of planning everything upfront, building for months, and hoping the result matched reality. Scrum introduced short feedback cycles. Ship something every two weeks. Inspect and adapt. That was genuinely better than what came before.&lt;/p&gt;

&lt;p&gt;The agile manifesto that underpins scrum development prioritizes individuals over processes, working software over documentation, customer collaboration over contracts, and responding to change over following a plan. Good principles. The problem is what the industry built on top of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sprint Boundaries Are Artificial
&lt;/h2&gt;

&lt;p&gt;Tasks don't fit neatly into two-week boxes. Some take three days. Some take twelve. Forcing them into fixed time boundaries creates two failure modes: you either pad estimates to fill the sprint, or you rush to hit an arbitrary deadline that has nothing to do with the actual complexity.&lt;/p&gt;

&lt;p&gt;When a &lt;a href="https://dev.to/principles/partnership/priorities-not-scope"&gt;priority shifts mid-sprint&lt;/a&gt;, scrum says wait until the next planning ceremony. In a small team, that's absurd. The client calls, explains why Feature B is now urgent, and you should be able to switch today — not in nine days when the sprint ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Velocity Tracking Becomes Theater
&lt;/h2&gt;

&lt;p&gt;Story points were meant to help teams estimate work. In practice, they become a performance metric. Teams optimize for point throughput instead of actual value delivered. A refactoring task that prevents six months of tech debt gets 2 points. A trivial UI change that the PM can demo gets 8.&lt;/p&gt;

&lt;p&gt;When one person does the work, velocity tracking is particularly absurd. You already know your throughput. You lived it yesterday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ceremonies Replace Communication
&lt;/h2&gt;

&lt;p&gt;Daily standups. Sprint planning. Sprint review. Sprint retrospective. Backlog grooming. For a team of fifteen with cross-functional dependencies, these rituals serve a real purpose — they force information sharing that wouldn't happen naturally.&lt;/p&gt;

&lt;p&gt;For a team of three? Or a solo developer working with a client? These meetings replace the actual communication they were designed to facilitate. You don't need a standup when you can send an async update after each work session. You don't need sprint planning when the priority queue is a shared list that either side can reorder at any time.&lt;/p&gt;

&lt;p&gt;When the framework produces more Jira tickets, confluence pages, and status updates than actual shipped code, something has gone wrong. The best process is invisible — it stays out of the way while work gets done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Time I Switched to Kanban, Delivery Rocketed
&lt;/h2&gt;

&lt;p&gt;I've led dev teams twice. Both times we started with scrum because that's what the organization used. Both times we shifted toward kanban. And both times the same thing happened: delivery rocketed and people became happier.&lt;/p&gt;

&lt;p&gt;The only meeting that survived was a real daily standup — five minutes to talk about blockers and maybe share plans. That's it. The entire status was visible on the Jira board. Anyone could look at it anytime. No ceremony needed to extract information that was already public.&lt;/p&gt;

&lt;p&gt;I've shipped software since 2011. Now I run my own practice based on &lt;a href="https://dev.to/principles/delivery/continuous-flow"&gt;continuous flow&lt;/a&gt; — Kanban, not Scrum. Here's how it works:&lt;/p&gt;

&lt;h2&gt;
  
  
  A Priority Queue, Not a Sprint Backlog
&lt;/h2&gt;

&lt;p&gt;The client maintains a ranked list. The top item is the highest priority. I work top-down: finish what's in front, then pull the next thing. Priorities shift? The client reorders the list. No replanning ceremony. No negotiating what fits in the sprint. The developer is always working on what matters most right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Thing at a Time, Then Ship It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/principles/delivery/wip-one"&gt;One task at a time&lt;/a&gt;. Finish it. Deploy it. Then move on. This forces honest prioritization and kills context switching. It prevents the trap of being "90% done on five things" while nothing is actually working.&lt;/p&gt;

&lt;p&gt;Code review isn't done. QA passed isn't done. Merged isn't done. &lt;a href="https://dev.to/principles/delivery/production-is-done"&gt;Working in production is done&lt;/a&gt;. This changes how you think about deployment. If deploying is hard, it gets avoided. If it's easy, it happens constantly. Feature flags handle incomplete work — deploy behind the flag, keep building, flip it when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async Updates Beat Standups
&lt;/h2&gt;

&lt;p&gt;Updates go out after each work session — not at end of day, not at a standup, but when the work is actually done. Meetings happen only for decisions that genuinely need real-time discussion. Everything else is written. This keeps calendars empty and &lt;a href="https://dev.to/principles/partnership/async-first"&gt;focus time protected&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For significant features, I think in six-week cycles — long enough to deliver something end-to-end valuable, short enough to stay honest. A cycle isn't a deadline. It's a planning horizon. "In six weeks, we expect X to be working." The cycle serves orientation, not ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  For Big Orgs, Scrum Is Still Revolutionary
&lt;/h2&gt;

&lt;p&gt;I'm not anti-process. I'm anti-unnecessary-process.&lt;/p&gt;

&lt;p&gt;For old-school corporations that have been running waterfall for decades, scrum is genuinely revolutionary. It introduces feedback loops, iterative delivery, and customer involvement where none existed before. That's a massive upgrade. If scrum is moving your 200-person org from annual releases to biweekly ones — keep going. That's real progress.&lt;/p&gt;

&lt;p&gt;Scrum works when you have large teams with cross-functional dependencies, regulated environments where audit trails are compliance requirements, organizations that need guardrails to prevent chaos, or teams coming from waterfall who need a stepping stone.&lt;/p&gt;

&lt;p&gt;But your dev team of four is probably shooting itself in the foot with this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small Is a Strength, Not a Problem to Fix
&lt;/h2&gt;

&lt;p&gt;Here's what I see constantly: small teams and startups adopting processes designed for organizations ten times their size. Scrum is one of those processes. So are SAFe, detailed PRDs, elaborate RACI matrices, and weekly all-hands with thirty-slide decks.&lt;/p&gt;

&lt;p&gt;It comes from the same instinct — wanting to look and feel like a "real" company. But it's backwards. Being small is not a weakness to compensate for. It's an advantage to exploit.&lt;/p&gt;

&lt;p&gt;A team of four can make a decision in a Slack thread that would take a 40-person team two sprint ceremonies and a steering committee. You can deploy a hotfix in twenty minutes while a large org is still scheduling the incident review. You can pivot your roadmap over lunch.&lt;/p&gt;

&lt;p&gt;My advice: use the strength you actually have. You're small, so act quick. Don't import the overhead of organizations that would kill to have your agility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Best Process Disappears
&lt;/h2&gt;

&lt;p&gt;The agile manifesto got it right: individuals and interactions over processes and tools. Somewhere along the way, the industry built an entire certification industry, a tooling ecosystem, and a consulting practice around processes and tools.&lt;/p&gt;

&lt;p&gt;The best development process is the one you don't notice. Work comes in, gets prioritized, gets built, gets shipped. No theater. No rituals that exist to feel productive rather than be productive.&lt;/p&gt;

&lt;p&gt;Build it. Deploy it. Get feedback. Pull the next priority.&lt;/p&gt;

</description>
      <category>agile</category>
      <category>discuss</category>
      <category>management</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Three Bugs That Were Actually My Prompts</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Thu, 19 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/three-bugs-that-were-actually-my-prompts-3bc7</link>
      <guid>https://dev.to/jurijtokarski/three-bugs-that-were-actually-my-prompts-3bc7</guid>
      <description>&lt;p&gt;Three debugging sessions. Three different features. Every investigation eventually landed in the same place: my own prompt files.&lt;/p&gt;

&lt;p&gt;The AI wasn't broken. I was a contradictory author.&lt;/p&gt;

&lt;h2&gt;
  
  
  The STRICT Rule That Was Overriding Itself
&lt;/h2&gt;

&lt;p&gt;I built a structured interview tool — the kind that walks a founder through their idea one question at a time. The system prompt had this near the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STRICT: Ask only ONE question per message. Never bundle questions.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users kept getting messages like "How will you make money? What are the major costs to build and run this?" I read the prompt again. Rule was right there. Added emphasis. Still happened. Moved it higher. Still happened.&lt;/p&gt;

&lt;p&gt;Then I read the interview flow section — the part describing what topics to cover across the session. Step 4 read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**Revenue Streams + Cost Structure**&lt;/span&gt; — How will you make money?
What are the major costs to build and run this?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model wasn't defying the STRICT rule. It was following the flow description, which listed two topics as a single step and framed them as two inline questions. That structure implicitly granted permission to bundle. The more specific instruction — a concrete flow item with actual question text — overrode the more abstract one.&lt;/p&gt;

&lt;p&gt;The fix was two things. Unbundle every flow item into separate steps. And add a concrete bad example directly inside the STRICT rule — not just the prohibition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STRICT: Ask only ONE question per message. Never bundle questions.
Example of what NOT to do: "How will you make money? What are your costs?"
is TWO questions — send one, wait for the answer, then ask the next.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Abstract rules lose to specific structural descriptions. The model resolves contradictions by specificity, not by which rule came first or which one you emphasized. If your flow section describes two questions in the same bullet, that description is an instruction — regardless of what you wrote elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tool That Read the Prohibition
&lt;/h2&gt;

&lt;p&gt;After a discovery session, users could request a full report by email. The tool was registered. The backend handler existed. Users clicked the button. The AI said it couldn't send emails.&lt;/p&gt;

&lt;p&gt;I checked tool registration — correct. Checked the API call — correct. Checked the backend handler — correct. Everything looked wired up properly at every technical layer.&lt;/p&gt;

&lt;p&gt;The issue was in a place I hadn't thought to look. I grepped the prompt files for "report":&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"report"&lt;/span&gt; mod/discovery/steps/&lt;span class="k"&gt;*&lt;/span&gt;/prompt.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single step prompt had lines like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do NOT offer to send a report.
Do NOT mention sending a report.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd written those prohibitions months earlier during a different phase of the project. The tool didn't exist yet when I wrote them. By the time it did, I'd forgotten those lines were there.&lt;/p&gt;

&lt;p&gt;The model wasn't defective. It was obedient to instructions I'd authored and then lost track of. Ten minutes of grepping would have found this immediately. Instead I spent days checking tool registration and API calls.&lt;/p&gt;

&lt;p&gt;Before investigating code when an AI-powered feature does nothing, grep your prompt files for explicit prohibitions against the behavior you're expecting. Search for "do not" and "don't" across your entire prompt corpus against the relevant action. It takes ten seconds and it would have saved me days on this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Precondition That Lived Only in Prose
&lt;/h2&gt;

&lt;p&gt;After fixing the prohibitions, a new problem surfaced. The model was supposed to ask for the user's email before calling &lt;code&gt;send_report&lt;/code&gt;. The prompt said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ALWAYS ask for the user's email before calling send_report.
Never call send_report without confirmed contact details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In testing, the tool got called with &lt;code&gt;founder@example.com&lt;/code&gt;. A placeholder the model had generated rather than asking for a real address. The instruction was clear. The model treated it as a suggestion.&lt;/p&gt;

&lt;p&gt;I made the prompt stronger. Same result — it would comply sometimes, skip the step other times, depending on how the conversation had flowed. Prompt-only enforcement of a precondition is probabilistic.&lt;/p&gt;

&lt;p&gt;The fix was to move validation into the tool handler itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLACEHOLDER_DOMAINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;placeholder.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;domain&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No email provided. Ask the user for their email address before calling this tool.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PLACEHOLDER_DOMAINS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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;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;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" looks like a placeholder. Ask the user for their real email address.`&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the validation returns errors instead of throwing them. A thrown exception terminates the tool call with a runtime error the model can't act on. A returned error lands back in the model's context as a tool result — the model reads it, understands what went wrong, and retries:&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;// This crashes. The model gets a runtime error and no useful signal.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_email is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// This works. The model reads the error and asks for the real address.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user_email&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_email is required. Ask the user for their email address, then call this tool again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, &lt;code&gt;required&lt;/code&gt; in a tool schema is a hint to the model, not a runtime guarantee. Models will omit required fields — sometimes because the value wasn't extracted yet, sometimes for reasons that aren't obvious from the logs. Treat every parameter as potentially absent at the handler boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALWAYS X&lt;/code&gt; in a prompt is a suggestion. Enforcing X belongs in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt Is the Program
&lt;/h2&gt;

&lt;p&gt;All three bugs came from the same misread of what a system prompt is. I was treating it as documentation — a description of intended behavior that the real system (the code) would enforce. For an LLM-powered feature, that's backwards.&lt;/p&gt;

&lt;p&gt;The system prompt isn't documentation. It's source code executed by a natural-language interpreter. Contradictions in it don't fail to compile — they resolve according to specificity and proximity rules you never wrote down. Prohibitions execute. Structure is semantics. A flow description with two inline questions is an instruction to ask two questions, regardless of the STRICT rule above it.&lt;/p&gt;

&lt;p&gt;The debugging instinct to check the API, the tool registration, the network logs — all of that is valid. But it should come after you've read your own prompts as a hostile reader looking for contradictions, prohibitions, and preconditions that only exist in prose.&lt;/p&gt;

&lt;p&gt;The model is rarely the bug. Read your prompts first.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devjournal</category>
      <category>llm</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Nobody Finishes a 15-Minute AI Interview</title>
      <dc:creator>Jurij Tokarski</dc:creator>
      <pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/jurijtokarski/nobody-finishes-a-15-minute-ai-interview-2paf</link>
      <guid>https://dev.to/jurijtokarski/nobody-finishes-a-15-minute-ai-interview-2paf</guid>
      <description>&lt;p&gt;Last year I launched an AI-powered discovery tool for software founders. The idea was simple: instead of paying for a product consultant, sit through a 15-minute AI interview and get a comprehensive development roadmap. Business model, market sizing, personas, competitive analysis, PRD, tech stack, budget, action plan — all in one session, delivered as a PDF report.&lt;/p&gt;

&lt;p&gt;The output was genuinely useful. Founders who completed it got something they could hand to a developer and start building from.&lt;/p&gt;

&lt;p&gt;But most founders didn't complete it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Sessions Died
&lt;/h2&gt;

&lt;p&gt;I didn't need sophisticated analytics to see the pattern. Founders would start, get three or four exchanges in, and disappear. Not because the questions were wrong. Because they'd hit a question they couldn't answer yet.&lt;/p&gt;

&lt;p&gt;"What's your monetization model?" at minute six, right after they'd just gotten excited describing the product idea. Or a market sizing question when they hadn't done that research. The session demanded answers in a fixed order. Real founder thinking doesn't work that way.&lt;/p&gt;

&lt;p&gt;I spent weeks trying to fix the session — better prompts, shorter flows, smarter branching. None of it changed the completion rate. I was solving the wrong problem: "how do I get founders to finish a 15-minute interview" instead of "what does a founder actually need, when they need it."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Insight Came From SEO
&lt;/h2&gt;

&lt;p&gt;While researching keywords for content, I noticed something. "Competitive analysis template for startups" — thousands of monthly searches. "TAM SAM SOM calculator" — same. "PRD generator" — same. Each stage of the founder journey had its own search intent, its own moment of urgency.&lt;/p&gt;

&lt;p&gt;I had been thinking about building a standalone tool around one of these keywords. Then it struck me: my discovery tool already does all of this and more. But a founder searching for "lean canvas generator" doesn't think of it as part of a 15-minute discovery interview. They want the canvas. Right now.&lt;/p&gt;

&lt;p&gt;The monolithic tool was doing eight things well, packaged in a way that required commitment to all eight. The fix wasn't better prompting. It was decomposition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight Tools, Eight Deliverables
&lt;/h2&gt;

&lt;p&gt;The rebuild started with twelve steps, got trimmed to ten, and settled at eight. One per stage of the founder journey:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/business-model-canvas"&gt;Business Model Canvas&lt;/a&gt; — lean canvas with revenue streams, cost structure, key partners&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/competitive-analysis"&gt;Competitive Analysis&lt;/a&gt; — positioning matrix, differentiation signals, competitor tech indicators&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/market-sizing"&gt;Market Sizing&lt;/a&gt; — TAM/SAM/SOM with growth assumptions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/user-personas"&gt;User Personas&lt;/a&gt; — typed persona objects with platform preferences and jobs-to-be-done&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/feature-prioritization"&gt;Feature Prioritization&lt;/a&gt; — domain classification (core / supporting / generic)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/tech-strategy"&gt;Tech Strategy&lt;/a&gt; — build-vs-buy decisions mapped to domain classification, specific stack recommendations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/product-requirements"&gt;Project Requirements&lt;/a&gt; — scoped feature list, acceptance criteria, out-of-scope boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/discovery/build-cost-plan"&gt;Build Cost &amp;amp; Plan&lt;/a&gt; — weekly estimate with a concrete action plan attached&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last two were originally four separate tools: Build vs Buy, Tech Stack Advisor, MVP Cost Estimator, and Action Plan. I merged them in pairs. "Should I build auth?" and "which auth provider?" aren't sequential questions — they're the same question. A cost estimate without an action plan is just a number that makes founders anxious. Eight made more sense than ten or twelve.&lt;/p&gt;

&lt;p&gt;Each tool is fully self-contained. It works with no prior context, no prior steps. But designed to hand off cleanly if the founder continues.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Each tool gets its own SEO landing page — keyword-targeted hero, explanation copy, FAQ, and an input form, all server-rendered. The page doubles as the app: before generation it's a landing page Google can crawl, after the founder starts it becomes the chat interface. One URL, two render states.&lt;/p&gt;

&lt;p&gt;The chat itself is a streaming conversation with a constrained AI model. Each tool has its own system prompt scoped to the decisions that step owns — Feature Prioritization scores by business value only, no effort or cost questions (those belong to later steps). The AI drives the conversation, but the scope is narrow: ask the right questions for this deliverable, produce a typed artifact, stop.&lt;/p&gt;

&lt;p&gt;Three server-side tools do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;update_artifact&lt;/strong&gt; — incrementally builds the step's structured output as the conversation progresses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;complete_step&lt;/strong&gt; — finalizes the artifact, captures analysis and summary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;send_report&lt;/strong&gt; — collects all completed artifacts, generates a consolidated PDF, delivers via email&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The artifact panel shows the structured output updating in real time as the conversation progresses — the founder sees their canvas or competitive matrix forming, not just chat bubbles.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Context Moves Between Tools
&lt;/h2&gt;

&lt;p&gt;Each tool produces a typed artifact. The Business Model Canvas produces an object with &lt;code&gt;key_partners&lt;/code&gt;, &lt;code&gt;revenue_streams&lt;/code&gt;, &lt;code&gt;cost_structure&lt;/code&gt;. User Personas produces an array of persona objects. Feature Prioritization produces a classification map.&lt;/p&gt;

&lt;p&gt;When a founder continues to the next tool, those artifacts get injected into the new tool's system prompt as structured JSON. Chat history doesn't cross tool boundaries — the back-and-forth of step one is noise inside step six. What crosses is the concluded output.&lt;/p&gt;

&lt;p&gt;Each tool ends with two inline options rendered as suggestion pills on the last AI message: &lt;strong&gt;Continue to [next tool]&lt;/strong&gt; or &lt;strong&gt;Send report via email&lt;/strong&gt;. If the founder requests the report, all completed artifacts get compiled into a PDF and delivered to their inbox. If they continue, the next tool opens with context already loaded. Both outcomes are first-class. Stopping after step two means you have a competitive analysis report — that's a complete deliverable, not an abandoned session.&lt;/p&gt;

&lt;p&gt;Email capture happens at the moment a founder requests their report — after they've gotten value, not before they've seen anything. That single change converted capture from a gate into an offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt Engineering That Wasn't
&lt;/h2&gt;

&lt;p&gt;Early in the build, I added a line to each tool's system prompt: "Use prior context if available to inform your analysis." Seemed reasonable.&lt;/p&gt;

&lt;p&gt;It didn't work. The model would occasionally reference something from an earlier step, but inconsistently and shallowly. Feature Prioritization wasn't connecting domain classifications to the Tech Strategy decisions that depended on them. I spent two hours trying different phrasings before accepting the problem wasn't the wording.&lt;/p&gt;

&lt;p&gt;The fix was specificity. Not "use prior context" — enumerate every upstream artifact by name, every relevant field, and exactly how it should influence the current step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Prior Step Context&lt;/span&gt;

If the following steps are complete, use their outputs as described:
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Feature Prioritization**&lt;/span&gt; — use &lt;span class="sb"&gt;`domain_classification`&lt;/span&gt; (core / supporting / generic)
  to anchor build-vs-buy decisions. Core = build custom. Generic = always buy.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**User Personas**&lt;/span&gt; — use &lt;span class="sb"&gt;`technical_proficiency`&lt;/span&gt; and &lt;span class="sb"&gt;`platform_preferences`&lt;/span&gt;
  to shape deployment and integration decisions.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Market Sizing**&lt;/span&gt; — use TAM/SAM/SOM scale to calibrate infrastructure complexity.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model follows explicit field references. It ignores vague instructions to "use context." The more precisely you enumerate the step name, the field name, and how to apply it — the more consistently the output reflects what prior steps actually found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build Around the Deliverable
&lt;/h2&gt;

&lt;p&gt;The session format is an inherited assumption from chat UIs. It made sense for general-purpose assistants. It doesn't make sense for a process that unfolds across days or weeks, where each stage has its own mental context and its own moment of urgency.&lt;/p&gt;

&lt;p&gt;Decomposing the monolithic tool changed everything downstream. Eight tools means eight landing pages means eight keywords. Each tool is a complete product for someone who needs just that one thing. The full journey still exists for founders who want it — they just don't have to commit to it upfront.&lt;/p&gt;

&lt;p&gt;If your AI tool covers something that spans multiple sittings and mental states, the deliverable is the right unit to build around. Not the conversation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live: &lt;a href="https://varstatt.com/discovery" rel="noopener noreferrer"&gt;varstatt.com/discovery&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
    </item>
  </channel>
</rss>
