<?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: Ferhat Atagün</title>
    <description>The latest articles on DEV Community by Ferhat Atagün (@ferhatatagun).</description>
    <link>https://dev.to/ferhatatagun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3968879%2Fd5dc6bce-be71-47fa-a658-dbe02d4d37d5.png</url>
      <title>DEV Community: Ferhat Atagün</title>
      <link>https://dev.to/ferhatatagun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ferhatatagun"/>
    <language>en</language>
    <item>
      <title>The failures that don't throw</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Tue, 01 Sep 2026 15:24:22 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/the-failures-that-dont-throw-2gkg</link>
      <guid>https://dev.to/ferhatatagun/the-failures-that-dont-throw-2gkg</guid>
      <description>&lt;p&gt;You ask a model for &lt;code&gt;{"risk": "high" | "medium" | "low", "score": 0-100}&lt;/code&gt;. You run it, you get exactly that, you wire it up and ship.&lt;/p&gt;

&lt;p&gt;Six weeks later somebody notices that a handful of claims were auto-approved that obviously shouldn't have been. Nothing errored. There's no exception in the logs, no failed request, no alert. The rows just have the wrong value in them, and they've had it for six weeks.&lt;/p&gt;

&lt;p&gt;What happened is that one call in forty came back with &lt;code&gt;"HIGH"&lt;/code&gt; instead of &lt;code&gt;"high"&lt;/code&gt;, and your code did this:&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;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&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="nf"&gt;escalate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;medium&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="nf"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&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="nf"&gt;autoApprove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;default&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;autoApprove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claim&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 default branch is almost always the permissive one, because that's what keeps the queue moving. So the model said &lt;em&gt;maximum risk&lt;/em&gt; and your system read it as &lt;em&gt;approve automatically&lt;/em&gt;. The failure didn't degrade the decision. It inverted it.&lt;/p&gt;

&lt;p&gt;That's the class of bug &lt;a href="https://guard-lab.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;guard-lab&lt;/strong&gt;&lt;/a&gt; exists to find: define a schema, run a prompt against it N times, and look at the distribution of ways it breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schema failures are distributional. One clean run tells you nothing, and five tell you almost nothing.&lt;/li&gt;
&lt;li&gt;A single "94% compliant" number is worse than no number, because the interesting question isn't how many failed — it's what your code does about each one.&lt;/li&gt;
&lt;li&gt;Three buckets: &lt;strong&gt;crash&lt;/strong&gt; (throws, already in your error rate), &lt;strong&gt;recoverable&lt;/strong&gt; (right payload, wrapped), and &lt;strong&gt;silent&lt;/strong&gt; (parses, is accepted, is wrong). Only the third is invisible, and it's the only one that needs a tool.&lt;/li&gt;
&lt;li&gt;Zero failures in fifty runs is not a zero failure rate. At n = 50 the data is still consistent with about 7%. Ruling out 1% takes 381 clean runs.&lt;/li&gt;
&lt;li&gt;Constrained decoding removes most of this and is the right first move. It does not tell you your rate, and it constrains shape rather than sense.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Counting failures is the wrong instrument
&lt;/h2&gt;

&lt;p&gt;The obvious way to build this is a compliance percentage. Run it fifty times, count how many matched, print 94%.&lt;/p&gt;

&lt;p&gt;I built that first and it was useless within an hour, for a reason that took me longer to articulate than it should have: &lt;strong&gt;the failures are not equally dangerous, and a single number averages over the only distinction that matters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Consider two runs that both report 94%.&lt;/p&gt;

&lt;p&gt;In the first, the 6% is markdown fences — the model wrapped the object in &lt;code&gt;&lt;/code&gt;`&lt;code&gt;json&lt;/code&gt;. Your parser either strips fences or it doesn't. If it does, that 6% costs you nothing at all. If it doesn't, it costs you 6% of requests, loudly, on day one, and you fix it before lunch.&lt;/p&gt;

&lt;p&gt;In the second, the 6% is enum drift and numbers arriving as strings. Nothing throws. Every one of those responses is accepted by your code, written to your database, and rendered on someone's screen. You will find out when a customer does.&lt;/p&gt;

&lt;p&gt;Same number. Completely different situation. One is a morning; the other is the six weeks at the top of this post.&lt;/p&gt;

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

&lt;p&gt;So guard-lab sorts failures by what your code does about them rather than by what went wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash&lt;/strong&gt; — the output doesn't parse. &lt;code&gt;JSON.parse&lt;/code&gt; throws, the request fails, and it appears in an error rate you're already watching. Genuinely fine. Loud failures get fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recoverable&lt;/strong&gt; — the payload is correct but wrapped. A markdown fence, a sentence of preamble, a helpful trailing note. Free if you strip it, fatal if you assume the body is only JSON. Either way you find out immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent&lt;/strong&gt; — the output parses, your code accepts it, and the value is wrong.&lt;/p&gt;

&lt;p&gt;The third bucket is the whole point, and it's bigger than it looks. It isn't only enum drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A missing field&lt;/strong&gt; reads as &lt;code&gt;undefined&lt;/code&gt;. Templates render blank, comparisons quietly go false, nothing throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;"score": "85"&lt;/code&gt;&lt;/strong&gt; as a string gives you &lt;code&gt;NaN&lt;/code&gt; on arithmetic, or &lt;code&gt;"8510"&lt;/code&gt; on &lt;code&gt;+ 10&lt;/code&gt;. No error either way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An invented enum&lt;/strong&gt; — &lt;code&gt;"very high"&lt;/code&gt; — passes every check you wrote for the values you expected and hits the default branch as if it were routine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;"score": 150&lt;/code&gt;&lt;/strong&gt; on a 0–100 field is a valid number in an invalid position. It renders, it charts, it misleads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;null&lt;/code&gt; where a value was required&lt;/strong&gt; passes an existence check and fails a type check somewhere far away, at a distance from the cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is accepted by JavaScript without complaint. None of them will ever appear in your error rate. That's not a gap in your monitoring — it's a category your monitoring cannot see, because from the outside nothing went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero failures is not a zero failure rate
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't expect to end up caring most about.&lt;/p&gt;

&lt;p&gt;You run the tool, fifty times, and everything passes. Green across the board. That reads as proof, and it isn't one.&lt;/p&gt;

&lt;p&gt;Fifty clean runs is a sample. The true failure rate is whatever it is, and your sample is consistent with a range of values — at n = 50 with zero observed failures, the 95% Wilson upper bound is &lt;strong&gt;7.1%&lt;/strong&gt;. At a thousand calls a day, that's up to seventy-one bad records daily that your test run had no power to detect.&lt;/p&gt;

&lt;p&gt;So guard-lab never shows a bare zero. A clean sweep gets an interval and a sentence saying what it does and doesn't rule out, because the reassuring version of that screen is the single most misleading thing the tool could do. The whole reason someone runs it is to stop trusting a sample of one; handing them a sample of fifty dressed up as certainty would just move the same mistake somewhere more expensive.&lt;/p&gt;

&lt;p&gt;The deflating corollary: ruling out a 1% failure rate takes &lt;strong&gt;381 clean runs&lt;/strong&gt;. Which is a real number that real teams will not run, and knowing that is still better than believing five runs settled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The strongest version of the counter-argument
&lt;/h2&gt;

&lt;p&gt;Let me argue the other side properly, because there's a good objection here and the weak version isn't worth knocking down.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a solved problem. Use structured outputs.&lt;/em&gt; Tool use with a JSON schema, or a constrained-decoding mode, forces the model's output to conform at the token level. Fences, prose, unparseable output, missing fields, wrong types — most of the taxonomy above simply cannot happen.&lt;/p&gt;

&lt;p&gt;That's correct, and it should be your first move. If you can use constrained decoding, use it; a validator downstream of an unconstrained prompt is a worse design than not needing one.&lt;/p&gt;

&lt;p&gt;Two things survive it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It constrains shape, not sense.&lt;/strong&gt; A schema that says &lt;code&gt;"risk"&lt;/code&gt; is one of three strings will get you one of three strings. It will not tell you whether the model picked the right one, and it won't catch a range violation you didn't encode — most people write &lt;code&gt;"type": "number"&lt;/code&gt; and not the 0–100 bound, so &lt;code&gt;150&lt;/code&gt; passes. Constrained decoding turns a syntax problem into a semantics problem. That's a large improvement and not a solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plenty of production code isn't in a position to use it.&lt;/strong&gt; A prompt template inside a vendor product. An internal gateway that normalises requests and drops the tool-use block. An older endpoint someone integrated in 2024 and nobody has budget to revisit. A model behind a proxy that only forwards &lt;code&gt;messages&lt;/code&gt;. These are not exotic; they're most of what an engineer walking into someone else's environment actually finds.&lt;/p&gt;

&lt;p&gt;And underneath both: &lt;strong&gt;constrained decoding doesn't give you a rate.&lt;/strong&gt; It reduces the failure probability, plausibly by a lot, to some number you still don't know. If the answer to "how often does this break" is "less than before", you haven't measured anything — you've just moved the number somewhere you can't see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs I shipped into it
&lt;/h2&gt;

&lt;p&gt;The useful part of building this was that I committed, twice, exactly the kind of error the tool is for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first was a silent failure inside the silent-failure detector.&lt;/strong&gt; To be generous about what counts as recoverable, the parser salvages JSON out of surrounding prose by taking the widest span between the first &lt;code&gt;{&lt;/code&gt; and the last &lt;code&gt;}&lt;/code&gt; — which is roughly what defensive production code does. Then a test case handed it &lt;code&gt;[{...}, {...}]&lt;/code&gt;: a model returning an array of results instead of one object.&lt;/p&gt;

&lt;p&gt;The salvage worked. It pulled the first object out, reported a clean recovery, and silently discarded every element after it.&lt;/p&gt;

&lt;p&gt;That is precisely the failure mode in the post you're reading — parses, is accepted, is wrong — committed inside the thing built to catch it. The fix is to parse the response as-is first, exactly as real code would, and only fall back to salvage when that fails. Valid JSON of the wrong shape now reaches the caller intact and gets judged as a crash, because that's what it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second was a statistical self-contradiction.&lt;/strong&gt; For "how many clean runs would it take to rule out a 1% failure rate" I used the rule of three: zero events in n trials bounds the rate near 3/n, so 300 runs. Textbook, correct, widely used.&lt;/p&gt;

&lt;p&gt;Except everywhere else the tool reports a Wilson interval, and Wilson is slightly more conservative at zero. Run 300 clean and it would have told you &lt;strong&gt;1.26%&lt;/strong&gt; — after promising 300 would get you to 1%. Two individually defensible methods, disagreeing, in a tool whose entire pitch is that you should trust its numbers.&lt;/p&gt;

&lt;p&gt;It now inverts the same interval it displays. The answer is 381 rather than 300: less satisfying, and consistent with what the tool will say to you afterwards.&lt;/p&gt;

&lt;p&gt;Neither bug was caught by using the app. Both were caught by tests written against reference values — seventeen classifier cases, and Wilson checked against published intervals. Which is its own small argument for &lt;a href="https://ferhatatagun.com/blog/the-eval-is-the-deliverable" rel="noopener noreferrer"&gt;the thing I wrote about evals two posts ago&lt;/a&gt;: looking at the screen and seeing plausible numbers is not verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't tell you
&lt;/h2&gt;

&lt;p&gt;Anything about whether the answers are &lt;em&gt;good&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A response can satisfy every field in the schema — correct enum, in-range score, all fields present — and be completely wrong about the claim. guard-lab measures whether the contract holds. Whether the judgement inside the contract is any good is the other kind of eval, the one that needs ground truth, and this doesn't touch it.&lt;/p&gt;

&lt;p&gt;Contract compliance is the floor. It's just that most teams have never measured the floor, and it turns out not to be where they assumed it was.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://guard-lab.vercel.app" rel="noopener noreferrer"&gt;guard-lab&lt;/a&gt;&lt;/strong&gt; is free, open source, and browser-only — BYOK, and the key never leaves your tab because there's no backend to send it to. Source on &lt;a href="https://github.com/ferhatatagun/guard-lab" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It's the sixth in a set of tools built under one constraint: they have to work in a room where you can't install anything and the data can't leave. &lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The rest are here.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>webdev</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The second customer tells you what you actually built</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Tue, 01 Sep 2026 15:18:25 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/the-second-customer-tells-you-what-you-actually-built-fe4</link>
      <guid>https://dev.to/ferhatatagun/the-second-customer-tells-you-what-you-actually-built-fe4</guid>
      <description>&lt;p&gt;There's a sentence that gets repeated about forward-deployed work, usually attributed to Palantir's model, and it sounds like a slogan until you've been on the wrong side of it: &lt;strong&gt;one customer, many capabilities&lt;/strong&gt; — as opposed to the SaaS shape, which is many customers, one capability.&lt;/p&gt;

&lt;p&gt;The SaaS shape is well understood. Find one thing a lot of people need, build it once, sell it repeatedly, and every additional customer costs you almost nothing. The whole discipline of product management is built around identifying that one thing.&lt;/p&gt;

&lt;p&gt;Forward-deployed work runs the other way. You embed with one organisation and build whatever that organisation actually needs, which turns out to be eleven things, most of which nobody outside the building would recognise as a product. And then somebody asks the question that decides whether you're a software company or a consultancy in a company's clothing: &lt;strong&gt;which of those eleven is a capability, and which is bespoke work you'll never sell twice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Getting that wrong in either direction is fatal, and the two failures look nothing alike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FDE work inverts the SaaS shape: depth with one customer instead of breadth across many. That produces capabilities faster than product discovery does, and no way to tell which ones generalise.&lt;/li&gt;
&lt;li&gt;Generalise too early and you ship a configurable abstraction fitted to a sample size of one. It has settings for the axis that varied and hardcodes the one that didn't.&lt;/li&gt;
&lt;li&gt;Generalise never and every deployment is bespoke. Margins go, the second engagement is as expensive as the first, and you're a consultancy that files its revenue under "platform".&lt;/li&gt;
&lt;li&gt;Two customers asking for the same feature is not the signal. Two customers arriving at the same feature &lt;strong&gt;for the same underlying reason&lt;/strong&gt; is. Same request, different causes, is a coincidence that will diverge the moment you build it.&lt;/li&gt;
&lt;li&gt;The most reliable evidence isn't the request. It's the workaround: what both teams already built by hand before anyone asked you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two failures
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Generalising too early&lt;/strong&gt; is the one engineers do, because abstraction feels like craftsmanship. You've solved a problem for one customer, you can see the shape of it, and the shape looks reusable. So you pull the specifics out into configuration and ship a general version.&lt;/p&gt;

&lt;p&gt;The thing you can't see from inside a single deployment is which axis actually varies. You had one data source, so you made the data source pluggable. You had one approval flow, so you left the approval flow hardcoded. Customer two arrives with the same data source and a completely different approval flow, and now you have an abstraction that is flexible in the direction nothing moves and rigid in the direction everything does. Both customers get a worse system than a bespoke build, and the abstraction is now load-bearing so removing it is a project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never generalising&lt;/strong&gt; is quieter and takes longer to hurt. Every engagement is a fresh build. Each one goes fine. The engineers get very good and the delivery gets very reliable, and the cost of the tenth deployment is roughly the cost of the first. That's a viable business — it's just consultancy, and it's usually being valued and staffed as though it were a product, which is where it comes apart. The gap shows up as margin, then as hiring, then as an inability to explain to anyone what the company sells.&lt;/p&gt;

&lt;p&gt;Both failures come from the same missing thing, which is a test for when a capability is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request is not the signal
&lt;/h2&gt;

&lt;p&gt;The intuitive test is repetition. Two customers ask for the same feature, so build it properly.&lt;/p&gt;

&lt;p&gt;I don't think that holds, and the reason is the more interesting half of this.&lt;/p&gt;

&lt;p&gt;Two organisations can ask for the same thing for entirely unrelated reasons. Both want an export to CSV: one because their analysts live in Excel, the other because their compliance team needs an immutable artifact for an audit trail. Same request, same three words in the ticket. Build the general version and you'll discover the first one wants live data and column reordering, and the second needs a frozen file with a checksum and a retention policy. The feature that satisfies both is two features with a shared name.&lt;/p&gt;

&lt;p&gt;What actually predicts generalisation isn't the request converging. It's the &lt;em&gt;constraint&lt;/em&gt; converging.&lt;/p&gt;

&lt;p&gt;This is the same argument I made &lt;a href="https://ferhatatagun.com/blog/accidental-fde-field-kit" rel="noopener noreferrer"&gt;two posts ago about my own tools&lt;/a&gt;, from the other end. I built five things browser-only and BYOK out of what felt like personal preference, and later noticed those are precisely the constraints of working inside someone else's regulated environment — nothing to install, no data crossing the boundary, nothing for a security team to threat-model. I hadn't copied that from anywhere. Two problems with the same &lt;em&gt;shape&lt;/em&gt; produce the same engineering decisions independently, without the people involved ever talking.&lt;/p&gt;

&lt;p&gt;That independence is the whole signal. When two customers land on the same design because their situations share a structural constraint — a boundary the data can't cross, a regulator who needs the reasoning preserved, a workflow that has to survive its owner going on leave — the thing you build for both is genuinely one thing. When they land on the same request through different constraints, you're looking at a naming collision.&lt;/p&gt;

&lt;p&gt;So the question to ask about a repeated request isn't &lt;em&gt;how many customers want this&lt;/em&gt;. It's &lt;strong&gt;what makes them want it, and is it the same thing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Look at the workaround, not the ask
&lt;/h2&gt;

&lt;p&gt;The most useful evidence I've found isn't in what customers ask for at all. It's in what they've already built by hand.&lt;/p&gt;

&lt;p&gt;Every organisation running a real workflow has accumulated a layer of manual compensation: a spreadsheet someone maintains, a Slack channel that functions as a queue, a person who checks a thing every morning at nine. Those exist because something in the system doesn't do a job that needs doing, and somebody cared enough to fill the gap with their own time.&lt;/p&gt;

&lt;p&gt;Two customers who've independently built the same workaround are telling you something much stronger than two customers asking for the same feature. A request can be aspirational, or repeated back to you from a conference talk, or a stakeholder's preference. A workaround is &lt;em&gt;paid for&lt;/em&gt;. Somebody spends an hour a day on it. Nobody sustains that for a nice-to-have.&lt;/p&gt;

&lt;p&gt;And workarounds are legible about the underlying constraint in a way requests aren't. The morning check exists because the process is asynchronous and nobody trusts it to fail loudly. The spreadsheet exists because the system's model of the work is one field short of reality. That's the constraint, stated in a form you can compare across customers — and comparing constraints is the thing the feature-request list can't do for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The FDE is the sensor, and the reporting line is the problem
&lt;/h2&gt;

&lt;p&gt;If this is right, the person embedded in the deployment is the highest-bandwidth product-discovery instrument the company has. They're not reading a survey. They're watching the workaround get used, at nine in the morning, by the person who built it.&lt;/p&gt;

&lt;p&gt;Which raises a structural question that I think is underrated: &lt;strong&gt;who reads what that person learns?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In most arrangements, the forward-deployed engineer reports through delivery. Delivery is measured on shipping this deployment, on time. The observation that customer three has independently rebuilt the same spreadsheet as customer one is not a delivery artifact. There's usually no field for it, no forum where it lands, and no incentive for a busy engineer to write it up.&lt;/p&gt;

&lt;p&gt;So it stays in one person's head, which — as &lt;a href="https://ferhatatagun.com/blog/done-when-they-can-change-it" rel="noopener noreferrer"&gt;the previous post argued about customer handover&lt;/a&gt; — is the same as it not existing. The failure mode is identical, just pointed inward: context accumulates in an individual, doesn't get bled out to the organisation, and leaves when they do. A company can lose its product roadmap this way without ever noticing it had one.&lt;/p&gt;

&lt;p&gt;The fix isn't complicated, it's just nobody's job: a standing, low-ceremony way for embedded engineers to report &lt;em&gt;constraints&lt;/em&gt; rather than &lt;em&gt;requests&lt;/em&gt;. Not "customer wants export" but "third customer this year whose data can't leave the boundary, all three built a manual extract". Three of those in a row is a product decision that made itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the series has actually been about
&lt;/h2&gt;

&lt;p&gt;Five posts, and the pattern underneath them is more consistent than I expected when I started.&lt;/p&gt;

&lt;p&gt;Pilots fail at the interface, not the model. The tools that survive are the ones with an articulated constraint. The deliverable is the eval, because the eval is what lets someone else change the thing. The handover works when a team can make a change and know it was safe. And the capability generalises when two customers reach it through the same constraint rather than the same words.&lt;/p&gt;

&lt;p&gt;Every one of those is the same claim in a different setting: &lt;strong&gt;the hard part is the transfer, not the build.&lt;/strong&gt; From a model to a person, from a prototype to a workflow, from you to the team that stays, from one customer to the next. The model was never the bottleneck, and it's less of one every quarter.&lt;/p&gt;

&lt;p&gt;Which is, I think, why the role exists at all, and why it's being hired for eight times as often as it was — not because the technology got harder, but because the transfer never got easier, and it turns out that was always the expensive part.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part five, and the last, of a series on the last mile of enterprise AI.&lt;br&gt;
Part one: &lt;a href="https://ferhatatagun.com/blog/nobodys-model-failed" rel="noopener noreferrer"&gt;Nobody's model failed. The interface did.&lt;/a&gt; ·&lt;br&gt;
Part two: &lt;a href="https://ferhatatagun.com/blog/accidental-fde-field-kit" rel="noopener noreferrer"&gt;I accidentally built a forward-deployed engineer's field kit&lt;/a&gt; ·&lt;br&gt;
Part three: &lt;a href="https://ferhatatagun.com/blog/the-eval-is-the-deliverable" rel="noopener noreferrer"&gt;The deliverable isn't the prompt. It's the eval.&lt;/a&gt; ·&lt;br&gt;
Part four: &lt;a href="https://ferhatatagun.com/blog/done-when-they-can-change-it" rel="noopener noreferrer"&gt;You're not done when it works. You're done when they can change it.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>startup</category>
      <category>career</category>
    </item>
    <item>
      <title>You're not done when it works. You're done when they can change it.</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Tue, 01 Sep 2026 15:12:49 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/youre-not-done-when-it-works-youre-done-when-they-can-change-it-2i2g</link>
      <guid>https://dev.to/ferhatatagun/youre-not-done-when-it-works-youre-done-when-they-can-change-it-2i2g</guid>
      <description>&lt;p&gt;The deployment I think about most didn't fail. It passed UAT, it went live, the customer was pleased, and there was a genuinely nice email at the end of it.&lt;/p&gt;

&lt;p&gt;Six months later it was still running and nobody had touched it. Not once. The prompt was byte-identical to the one I'd left. A model version had been deprecated underneath it and the team had pinned the old one rather than test the new. Someone had asked for a small change to the output format and been told it wasn't worth the risk.&lt;/p&gt;

&lt;p&gt;Nothing broke. That's the part that took me a while to understand. &lt;strong&gt;The system didn't die when it stopped working. It died when it stopped being changed&lt;/strong&gt; — and it had been dead for about five of those six months while continuing to return responses.&lt;/p&gt;

&lt;p&gt;That's the failure mode nobody writes a post-mortem for, because there's no incident. There's just a thing in the corner that everyone routes around, until a reorg or a contract renewal quietly removes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A deployment's real end date is the last time someone modified it with confidence, not the last time it responded to a request.&lt;/li&gt;
&lt;li&gt;Documentation describes a &lt;em&gt;state&lt;/em&gt;. Ownership requires being able to make a &lt;em&gt;change&lt;/em&gt; and know it was safe. Those are different deliverables and only one of them survives contact with the first edit.&lt;/li&gt;
&lt;li&gt;Three questions decide whether the work outlives you: can they change it, can they tell if the change worked, and do they know why it's like that. Most handovers answer only the first.&lt;/li&gt;
&lt;li&gt;The &lt;em&gt;why&lt;/em&gt; decays fastest and is written down least. Code records what, git records when, and the reason a strange clause exists lives in one person's memory until it looks like noise and gets deleted.&lt;/li&gt;
&lt;li&gt;As a forward-deployed engineer you are the bus factor by design. The job is to make yourself unnecessary on a schedule, and feeling indispensable is the signal that it's going wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The documentation trap
&lt;/h2&gt;

&lt;p&gt;The instinct at the end of an engagement is to write everything down. Architecture diagram, sequence of calls, deployment runbook, a wiki page per component. It feels like diligence, and it produces an artifact you can point at in the final meeting.&lt;/p&gt;

&lt;p&gt;It also goes stale the first time someone makes a change — and if nobody makes a change, it didn't matter that you wrote it.&lt;/p&gt;

&lt;p&gt;The uncomfortable version: a thorough document can make things &lt;em&gt;worse&lt;/em&gt;, because it substitutes for the thing that would actually have helped. The team reads it, understands the shape of the system, and still doesn't touch it, because understanding the architecture was never what was stopping them. What was stopping them is that they had no way to make a change and find out whether they'd broken something.&lt;/p&gt;

&lt;p&gt;Documentation answers "what is this". Ownership needs an answer to "what happens if I change this". No amount of the first produces the second.&lt;/p&gt;

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

&lt;p&gt;Whether a deployment survives handover comes down to three things, and they get progressively less likely to be addressed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Can they change it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The mechanical one. Do they have the credentials, the repo access, the deploy path, the ability to roll back. This is the question every handover checklist covers, and it's genuinely necessary.&lt;/p&gt;

&lt;p&gt;It's also the easiest one, and clearing it feels like progress in a way that's slightly misleading — because a team with full production access and no confidence changes nothing, which is observationally identical to a team with no access at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Can they tell if the change worked?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is &lt;a href="https://ferhatatagun.com/blog/the-eval-is-the-deliverable" rel="noopener noreferrer"&gt;the previous post's whole argument&lt;/a&gt;, and it's the hinge.&lt;/p&gt;

&lt;p&gt;The frozen snapshot — a set of real inputs and the decisions the current system makes on them — isn't primarily a quality tool. Its real function is &lt;em&gt;permission&lt;/em&gt;. It's the thing that converts "I think this prompt edit is fine" into "twelve of the fourteen cases are unchanged and here are the two that moved". The first sentence gets a change reverted in review. The second gets it merged.&lt;/p&gt;

&lt;p&gt;A team without that will not edit the prompt. They'll be right not to. Editing a prompt with no way to check the blast radius is not caution, it's gambling with someone else's workflow, and sensible people decline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Do they know why it's like that?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The one that almost never gets handled, and the one that quietly determines everything.&lt;/p&gt;

&lt;p&gt;Every system that has met reality has clauses in it that look wrong. A sentence in the prompt insisting on a format that seems redundant. A retry with an oddly specific backoff. A field that gets stripped before it's shown. Each of those exists because something happened — a specific failure, on a specific day, with a specific customer record that broke a specific assumption.&lt;/p&gt;

&lt;p&gt;Six months later, whoever inherits it sees an ugly clause with no explanation and does the reasonable thing: cleans it up. And the original failure comes back, except now nobody in the room remembers the first one, so it presents as a new bug.&lt;/p&gt;

&lt;p&gt;Code records &lt;em&gt;what&lt;/em&gt;. Git records &lt;em&gt;when&lt;/em&gt; and, if you're lucky, a commit message records a compressed version of &lt;em&gt;why&lt;/em&gt;. Nothing records the reasoning at the place where someone will encounter the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attach the why to the thing, not beside it
&lt;/h2&gt;

&lt;p&gt;The practice that has actually worked for me is unglamorous: &lt;strong&gt;put the reason where the decision is, not in a document about the decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For prompts, that means a comment block in the prompt file itself, naming the failure that produced each non-obvious instruction. Not "be concise" — &lt;em&gt;"be concise: the 2,400-word answers in week two were being pasted into a field with a 500-character limit downstream, which truncated mid-sentence and looked like a model failure."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For behaviour, it means a test named after the incident rather than the function. A case called &lt;code&gt;keeps_the_account_number_when_the_name_is_missing&lt;/code&gt; is a sentence about the business. A case called &lt;code&gt;test_parse_edge_case_3&lt;/code&gt; is a sentence about nothing, and it's the one that gets deleted when it goes red.&lt;/p&gt;

&lt;p&gt;This is the same move as the frozen eval set, applied to reasoning instead of behaviour. Both work because they're attached to something that runs. A wiki page can go stale silently; a named test goes red loudly, and the name tells the person staring at it what they're about to break.&lt;/p&gt;

&lt;p&gt;The test is: someone who wasn't there reads the clause and can decide, on their own, whether the reason still applies. That's the whole bar. Not "they understand the system" — &lt;em&gt;they can safely disagree with me.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  You are the bus factor, on purpose
&lt;/h2&gt;

&lt;p&gt;There's an awkward thing about this role that's worth saying plainly.&lt;/p&gt;

&lt;p&gt;A forward-deployed engineer is, structurally, a single point of failure introduced deliberately. You're embedded because the customer's team can't yet do the thing. Every week you're there, you accumulate context that exists nowhere else — which workflow diverges from its documentation, which stakeholder's objection is real and which is positioning, why the second data source can't be trusted on Mondays.&lt;/p&gt;

&lt;p&gt;That context is what makes you effective. It's also, if you don't actively bleed it out, the reason the deployment dies when you leave.&lt;/p&gt;

&lt;p&gt;And the incentives run the wrong way. Being the person who understands the system feels like doing well. Getting the call because nobody else can debug it feels like value. It reads as indispensable, and indispensable feels like success right up until it's the postmortem.&lt;/p&gt;

&lt;p&gt;The metric that actually matters is uncomfortable to optimise for: &lt;strong&gt;how quickly you become unnecessary.&lt;/strong&gt; Not how much you shipped. How fast the people who stay stopped needing you in the room.&lt;/p&gt;

&lt;p&gt;Which is a strange thing to be measured on, and I don't think most engagements measure it at all — they measure delivery, and delivery is the part that happens while you're still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually leave
&lt;/h2&gt;

&lt;p&gt;Concretely, stripped of the parts that sound good and don't survive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The frozen set&lt;/strong&gt;, in the repo, with a one-command way to run it. Not a notebook, not a process someone has to remember. &lt;code&gt;npm run eval&lt;/code&gt; or the equivalent, output that a non-author can read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A decisions file&lt;/strong&gt;, but scoped hard: only decisions where the obvious choice was rejected, each one naming the thing that made it obvious-but-wrong. Ten entries that matter, not sixty that document the defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reasons inline&lt;/strong&gt; — in the prompt, in the test names, next to the retry constant. Anywhere someone will be standing when they consider changing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One change they make and I don't.&lt;/strong&gt; Sitting there while someone else edits the prompt, runs the eval, reads the diff, and ships it. Watching a team do it once is the only handover step I've never seen substituted successfully. Everything else can be faked with a good document; this one can't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the whole thing, really. Everything before it is preparation for the moment where someone other than you changes the system and nothing bad happens. If that moment doesn't occur before you leave, you have no evidence it can.&lt;/p&gt;

&lt;p&gt;And "no evidence it can" is, in practice, the same as "it won't" — which brings the six-month clock back around, and the system that's already dead while it's still answering.&lt;/p&gt;




&lt;p&gt;The uncomfortable implication of all of this is that the work isn't really about the model, or the prompt, or even the interface — it's about whether one team can hand a capability to another. Which is a much older problem than anything in this field, and the subject of the last post in this series.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part four of a series on the last mile of enterprise AI.&lt;br&gt;
Part one: &lt;a href="https://ferhatatagun.com/blog/nobodys-model-failed" rel="noopener noreferrer"&gt;Nobody's model failed. The interface did.&lt;/a&gt; ·&lt;br&gt;
Part two: &lt;a href="https://ferhatatagun.com/blog/accidental-fde-field-kit" rel="noopener noreferrer"&gt;I accidentally built a forward-deployed engineer's field kit&lt;/a&gt; ·&lt;br&gt;
Part three: &lt;a href="https://ferhatatagun.com/blog/the-eval-is-the-deliverable" rel="noopener noreferrer"&gt;The deliverable isn't the prompt. It's the eval.&lt;/a&gt; ·&lt;br&gt;
Part five: &lt;a href="https://ferhatatagun.com/blog/the-second-customer" rel="noopener noreferrer"&gt;The second customer tells you what you actually built&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>career</category>
      <category>webdev</category>
    </item>
    <item>
      <title>"That library will hurt performance" is not a number</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Tue, 01 Sep 2026 10:07:12 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/that-library-will-hurt-performance-is-not-a-number-1lh5</link>
      <guid>https://dev.to/ferhatatagun/that-library-will-hurt-performance-is-not-a-number-1lh5</guid>
      <description>&lt;p&gt;Somebody in the room says the marketing team's chat widget is making the site slow. Somebody else says it drives a third of the qualified leads. Both are probably right, neither has a number, and the decision gets made by whoever sounds more confident. Two sprints later the widget is still there and the page is still slow.&lt;/p&gt;

&lt;p&gt;I've been on both sides of that conversation and I've never once seen it settled with evidence. Not because the evidence is hard to get — because the tool that would produce it doesn't exist. Every performance tool we have reports in the present tense. Lighthouse tells you what the page costs. WebPageTest tells you what the page costs, in more detail. CrUX and RUM tell you what the page costs, for real users. All of them answer "what is this page doing right now", and none of them answers the question the meeting is actually about, which is &lt;strong&gt;"what would it cost without that"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://perf-lab-topaz.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;perf-lab&lt;/strong&gt;&lt;/a&gt;. Drop in a Lighthouse JSON report, switch resources off, and watch the score move. It turns &lt;em&gt;"that library will hurt performance"&lt;/em&gt; into &lt;em&gt;"that library costs 0.4 s of LCP and 7 points"&lt;/em&gt; — which is a sentence you can put in a ticket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every performance tool is a measurement tool. The decisions teams argue about are counterfactual, and nothing measures counterfactuals.&lt;/li&gt;
&lt;li&gt;A projection is only worth arguing with if it lands on the scale the stakeholders already screenshot, so perf-lab reimplements Lighthouse's actual scoring — the log-normal curves, the published control points, the category weights. On a real report it recomputes 57 against Lighthouse's reported 57.&lt;/li&gt;
&lt;li&gt;Ranking by bytes is actively misleading. A 410 KB image that finishes loading after LCP is worth &lt;strong&gt;zero points&lt;/strong&gt;; a 124 KB render-blocking script is worth &lt;strong&gt;eleven&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Savings overlap. Four independent "+7 point" fixes do not add up to 28, and a tool that implies they do is setting up a disappointing retro.&lt;/li&gt;
&lt;li&gt;Everything is a delta against measured values, never an invented absolute — and where a number is modelled rather than measured, the UI says so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the score has to be Lighthouse's score
&lt;/h2&gt;

&lt;p&gt;The first version of this idea I sketched had its own 0–100 index. It was useless within a day.&lt;/p&gt;

&lt;p&gt;The whole point is to end an argument, and the argument is being had by people who look at Lighthouse. If my tool says "this saves you 12 points" on a scale I invented, the reply is "twelve of &lt;em&gt;what&lt;/em&gt;", and now we're arguing about my scale instead of about the chat widget. The projection has to arrive in the same units as the screenshot in the ticket.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;scoring.ts&lt;/code&gt; is a reimplementation of the real thing. Lighthouse doesn't score metrics linearly — it maps each one through a log-normal curve calibrated to two control points, the 10th percentile of real sites and the median:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;METRIC_WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;si&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tbt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CONTROL_POINTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;p10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;median&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;si&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;p10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3387&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;median&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;lcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;p10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;median&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;tbt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;p10&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;median&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;p10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;median&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&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 fall out of those numbers that are worth internalising even if you never open the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TBT is 30% of your score and CLS is 25%.&lt;/strong&gt; Together, two metrics that have nothing to do with how fast the page &lt;em&gt;looks&lt;/em&gt; are 55% of the number. LCP — the one everybody talks about — is 25%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The curve is brutal near the top and forgiving in the middle.&lt;/strong&gt; Going from 4.0 s to 3.5 s of LCP buys you very little. Going from 2.8 s to 2.4 s buys you a lot. Which means "we improved LCP by half a second" is not a fact you can price without knowing where you started.&lt;/p&gt;

&lt;p&gt;I did eventually verify the reimplementation rather than trusting it, which I should have done far earlier than I did. I ran real Lighthouse against my own site and fed the JSON straight into the parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reported: 57   recomputed: 57
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That agreement is the entire credibility of the tool. There's a &lt;code&gt;scripts/probe.ts&lt;/code&gt; in the repo whose first line of output is exactly that comparison, so if the implementation ever drifts from Lighthouse's, it fails loudly instead of quietly producing plausible numbers on the wrong scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inversion that makes byte-sorted reports lie
&lt;/h2&gt;

&lt;p&gt;Here's the demo page perf-lab ships with — a fairly ordinary marketing page, 1.1 MB, scoring 57. Sorted by what removing each resource is actually worth:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Worth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;consent-banner.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;124 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;intercom.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;168 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;framework.bundle.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;178 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gtm.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;92 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;below-fold-gallery.webp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;410 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest file on the page is worth nothing. It's a gallery image below the fold that finishes loading after LCP has already fired, so it was never on the critical path — removing it makes the page lighter and doesn't make it faster. Meanwhile the third-smallest file in that list is worth eleven points, because it blocks rendering.&lt;/p&gt;

&lt;p&gt;This is not an exotic edge case. It's the normal shape of a web page, and it's why the "largest assets" table in every performance report sends teams to optimise the wrong thing. The question was never &lt;em&gt;what is heavy&lt;/em&gt;. It's &lt;em&gt;what is heavy **and&lt;/em&gt;* on the critical path*.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that proves the point
&lt;/h2&gt;

&lt;p&gt;I want to be straight about this one, because it's the most useful thing in the post.&lt;/p&gt;

&lt;p&gt;I shipped the tool, opened it, and the demo showed &lt;code&gt;below-fold-gallery.webp&lt;/code&gt; at &lt;strong&gt;+12 points&lt;/strong&gt; — the exact inversion the tool exists to correct, in the tool, on the first screen. Removing any resource shrank the total transfer, and I was applying the transfer saving to FCP and LCP without first checking whether the resource had anything to do with them.&lt;/p&gt;

&lt;p&gt;I fixed that, and then found a second version of the same mistake hiding one layer down, in the parser:&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;beforeLcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;endTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lcp&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lcp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost every request on a page &lt;em&gt;starts&lt;/em&gt; before LCP fires. That second clause marked nearly everything as critical-path, which quietly dismantles the tool's central claim on every real report.&lt;/p&gt;

&lt;p&gt;It survived because the sample data sets &lt;code&gt;beforeLcp&lt;/code&gt; by hand, so the demo — the thing I kept looking at — never went through that code path. The only way to catch it was to run a real Lighthouse report through the parser, which I hadn't done until the tool was already live. &lt;strong&gt;The demo data that makes a tool easy to show off is the same data that stops you testing the part that matters.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Savings don't add up, and pretending they do is worse than useless
&lt;/h2&gt;

&lt;p&gt;First version ranked every resource against the untouched report. Which reads fine, until you notice it's promising each removal the full win independently — four "+7 point" fixes that sum to 28 on the slide and 11 in production, because they overlap. Once you've dropped two analytics tags, TBT has already come down, and the third tag has less left to give back.&lt;/p&gt;

&lt;p&gt;So the ranking is now marginal: it re-simulates against whatever you've already switched off. In the demo, removing Intercom drops the hero image from &lt;strong&gt;+11 to +6&lt;/strong&gt; — same file, same bytes, worth half as much once LCP has already moved.&lt;/p&gt;

&lt;p&gt;That behaviour turned out to be the most persuasive thing in the tool. Watching the list re-sort as you strip things out teaches the overlap in about fifteen seconds, and it's the sort of thing teams normally learn in a retro.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's measured and what's modelled
&lt;/h2&gt;

&lt;p&gt;A tool that projects the future can very easily become a tool that makes things up confidently. The rule I held to: &lt;strong&gt;never invent an absolute number when a measured one exists.&lt;/strong&gt; Everything works on deltas applied to the metrics Lighthouse actually recorded, so the baseline is always ground truth and only the difference is modelled.&lt;/p&gt;

&lt;p&gt;Taken straight from the report: transfer size and timing per request (&lt;code&gt;network-requests&lt;/code&gt;), parse and execute time per script (&lt;code&gt;bootup-time&lt;/code&gt;), render-blocking status &lt;em&gt;and its measured saving&lt;/em&gt; (&lt;code&gt;render-blocking-resources&lt;/code&gt;), third-party attribution (&lt;code&gt;third-party-summary&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Modelled, and therefore approximate: transfer time recovered on a throttled connection, how much of a script's execution lands inside the TBT window, and whether removing something moves LCP. Even the TBT fraction is anchored to the report rather than to an industry average — it uses the ratio &lt;em&gt;this page&lt;/em&gt; exhibited, measured TBT over total measured CPU.&lt;/p&gt;

&lt;p&gt;Every projection carries a confidence label — &lt;code&gt;measured&lt;/code&gt;, &lt;code&gt;modelled&lt;/code&gt;, or &lt;code&gt;speculative&lt;/code&gt; — that degrades as you move away from the conditions the report was captured under. Change the throttling preset and remove something Lighthouse never measured, and the tool tells you it's directional only, rather than handing you a number with false authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  It found a bug on the site it's published from
&lt;/h2&gt;

&lt;p&gt;The real Lighthouse run I'd done to verify the scoring was against my own &lt;code&gt;/tools&lt;/code&gt; page. Having gone to the trouble, I read the rest of it.&lt;/p&gt;

&lt;p&gt;Mobile score 57. LCP 7.2 s, which the curves price at roughly 23 of the 43 points I was losing. And the LCP element — named right there in the report — was the first tool card's screenshot.&lt;/p&gt;

&lt;p&gt;Every card image on that page was marked &lt;code&gt;loading="lazy"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The one image the score is measured on was the one I was deliberately deferring. Lazy-loading is good practice, I'd applied it uniformly, and applying it uniformly is exactly the mistake: the LCP element must never be lazy. First card is now &lt;code&gt;eager&lt;/code&gt; with &lt;code&gt;fetchpriority="high"&lt;/code&gt;, the rest stay lazy.&lt;/p&gt;

&lt;p&gt;The tool also flagged 238 KiB sitting in PNGs that should have been WebP. Converting them saved 620 KB.&lt;/p&gt;

&lt;p&gt;I'd been working on that page for weeks, with SEO and performance explicitly in mind, and I'd have kept not seeing it. That's the argument for the whole category, really. Measurement tells you the page is slow. You already suspected that. What changes a decision is knowing what it would cost to be otherwise — and until you can put a number on the counterfactual, the loudest person in the room is going to keep being right.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://perf-lab-topaz.vercel.app" rel="noopener noreferrer"&gt;perf-lab&lt;/a&gt;&lt;/strong&gt; is free, open source, and browser-only — the report is parsed in your tab and nothing is uploaded, which matters when the report came from a client's staging environment. Source on &lt;a href="https://github.com/ferhatatagun/perf-lab" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Generate a report with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx lighthouse https://example.com &lt;span class="nt"&gt;--only-categories&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;performance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="nt"&gt;--output-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./report.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or DevTools → Lighthouse → run → ⋮ → &lt;em&gt;Save as JSON&lt;/em&gt;. No report handy? There's sample data one click in.&lt;/p&gt;

</description>
      <category>webperf</category>
      <category>frontend</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The deliverable isn't the prompt. It's the eval.</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Wed, 12 Aug 2026 17:33:53 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/the-deliverable-isnt-the-prompt-its-the-eval-35cp</link>
      <guid>https://dev.to/ferhatatagun/the-deliverable-isnt-the-prompt-its-the-eval-35cp</guid>
      <description>&lt;p&gt;Every field guide to forward-deployed work says a version of the same thing:&lt;br&gt;
define your evaluation framework &lt;em&gt;before&lt;/em&gt; you build anything.&lt;/p&gt;

&lt;p&gt;And every engineer who has actually landed in a customer's environment has had&lt;br&gt;
the same reaction, which is: with what?&lt;/p&gt;

&lt;p&gt;On day three you don't have labelled data. You don't have domain expertise —&lt;br&gt;
the people who do are the ones you're building for, and they're busy. You have&lt;br&gt;
five examples someone showed you on a shared screen, a workflow that diverges&lt;br&gt;
from its documentation in ways nobody has written down, and a rough sense that&lt;br&gt;
the current process takes too long. Building a golden dataset off that is not a&lt;br&gt;
task. It's a fantasy.&lt;/p&gt;

&lt;p&gt;So the advice gets skipped. Not out of laziness — out of practicality. And then&lt;br&gt;
about ninety days later the system starts drifting, quietly, and nobody notices&lt;br&gt;
until the customer does.&lt;/p&gt;

&lt;p&gt;I think the advice is right and the framing is wrong. You're not being told to&lt;br&gt;
skip evals. You're being told to build the wrong kind first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard eval guidance — golden datasets, rubrics, labelled examples — is correct and close to unusable in week one, which is exactly when it's prescribed.&lt;/li&gt;
&lt;li&gt;"Eval" is doing double duty for two different questions. &lt;em&gt;Is this output good?&lt;/em&gt; needs ground truth. &lt;em&gt;Did this change?&lt;/em&gt; needs only a snapshot.&lt;/li&gt;
&lt;li&gt;The second one is buildable on day three, and it catches most of what actually kills deployments: silent drift after a prompt edit, a model version bump, a parameter change nobody logged.&lt;/li&gt;
&lt;li&gt;Don't diff prose — LLM output is nondeterministic and you'll drown in noise. Diff the &lt;strong&gt;decision&lt;/strong&gt;: the structured claim underneath the words.&lt;/li&gt;
&lt;li&gt;The regression harness then writes your golden dataset for you. Every failure it catches is a labelled example you didn't have to imagine.&lt;/li&gt;
&lt;li&gt;This is why the eval is the deliverable. When you leave, the prompt is a text file anyone can edit — and someone will. The eval is what tells them whether the edit was safe.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Two different questions wearing the same word
&lt;/h2&gt;

&lt;p&gt;Here's the distinction that unlocked this for me.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Quality eval&lt;/th&gt;
&lt;th&gt;Regression eval&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Question&lt;/td&gt;
&lt;td&gt;Is this output correct?&lt;/td&gt;
&lt;td&gt;Did this change from what we accepted?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs&lt;/td&gt;
&lt;td&gt;Ground truth, domain expertise, rubric&lt;/td&gt;
&lt;td&gt;A frozen snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buildable on day 3&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches&lt;/td&gt;
&lt;td&gt;Bad design&lt;/td&gt;
&lt;td&gt;Silent drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost to build&lt;/td&gt;
&lt;td&gt;Weeks&lt;/td&gt;
&lt;td&gt;An afternoon&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We call both of them "evals," which is how the cheap one gets postponed&lt;br&gt;
alongside the expensive one.&lt;/p&gt;

&lt;p&gt;The expensive one is genuinely expensive and genuinely later. The cheap one has&lt;br&gt;
no prerequisites at all, and skipping it is what turns a working deployment into&lt;br&gt;
a mysteriously-degraded one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you can actually build on day three
&lt;/h2&gt;

&lt;p&gt;Concretely, five steps, none of which require you to know what "good" means:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Collect real inputs.&lt;/strong&gt; Not synthetic ones. Sit with whoever does the work&lt;br&gt;
and take five to ten actual cases off their screen — including the two weird&lt;br&gt;
ones they apologise for. The weird ones are the whole point; they're where the&lt;br&gt;
system will break and where the documentation is silent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Run them and print the output.&lt;/strong&gt; No scoring, no rubric.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Get a binary from the person who owns the workflow.&lt;/strong&gt; Not "rate this 1–5."&lt;br&gt;
Just: &lt;em&gt;would you have been comfortable if the system did this on its own?&lt;/em&gt; Yes&lt;br&gt;
or no. People are fast and reliable at this and slow and unreliable at rubrics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Freeze the yeses.&lt;/strong&gt; That file is your eval. It is not a prototype of an&lt;br&gt;
eval, it is the thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Re-run it on every change.&lt;/strong&gt; Prompt edit, model bump, parameter tweak,&lt;br&gt;
context restructure. Anything.&lt;/p&gt;

&lt;p&gt;The frozen file is boring on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claim-2024-0871"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…the actual case, verbatim…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"risk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"route"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"manual-review"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"acceptedBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ops lead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"acceptedOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-04"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the runner is about twenty lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="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;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;frozen&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;output&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// structured claim only&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="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;report&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;c&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="na"&gt;was&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&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;That's it. There's no framework here, and there shouldn't be. It's a habit with&lt;br&gt;
a file attached.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diff decisions, not prose
&lt;/h2&gt;

&lt;p&gt;The obvious objection: LLM output is nondeterministic. Diffing it produces&lt;br&gt;
nothing but noise. Correct — if you diff the wrong thing.&lt;/p&gt;

&lt;p&gt;Run the same prompt twice and the prose will differ. Word order, hedging, how&lt;br&gt;
the explanation is structured. None of that is a regression. If you string-diff&lt;br&gt;
the response body, every run fails and you'll turn the harness off inside a week.&lt;/p&gt;

&lt;p&gt;So don't. Extract the &lt;strong&gt;decision&lt;/strong&gt; and diff that.&lt;/p&gt;

&lt;p&gt;Almost every useful LLM step in a production workflow reduces to a small&lt;br&gt;
structured claim buried in the prose: a category, a number, a routing choice, a&lt;br&gt;
yes/no. The prose around it is presentation. The claim is the behaviour.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// prose varies; this must not&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;route&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="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual-review&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;If the model routes claim-0871 to &lt;code&gt;manual-review&lt;/code&gt; on Monday and &lt;code&gt;auto&lt;/code&gt; on&lt;br&gt;
Friday, that is a regression regardless of how nicely it explained itself. If it&lt;br&gt;
routes it to &lt;code&gt;manual-review&lt;/code&gt; both times using completely different sentences,&lt;br&gt;
nothing happened.&lt;/p&gt;

&lt;p&gt;This also has a side benefit I didn't anticipate: forcing yourself to name the&lt;br&gt;
decision type clarifies the design. If you can't write that type down, you don't&lt;br&gt;
yet know what the model is for — and a step whose output can't be reduced to a&lt;br&gt;
claim is usually a step that should have been two steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this catches that nothing else does
&lt;/h2&gt;

&lt;p&gt;Every one of these is real, silent, and invisible without a frozen set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The prompt edit that fixes case A and breaks case D.&lt;/strong&gt; The most common one. You improve something in response to a complaint, ship it, and quietly regress a case nobody complained about &lt;em&gt;yet&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model version bump.&lt;/strong&gt; Behaviour shifts at the margins. Your happy path is fine. Your two weird cases aren't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parameter drift.&lt;/strong&gt; Somebody changes temperature during debugging and doesn't change it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Length-induced behaviour change.&lt;/strong&gt; Input grows over months; the model starts truncating or de-prioritising instructions that used to hold. I've written about &lt;a href="https://ferhatatagun.com/blog/see-the-prompt-before-you-ship-it" rel="noopener noreferrer"&gt;seeing this before you ship&lt;/a&gt; — the frozen set is how you find out it already happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A caching boundary move.&lt;/strong&gt; Someone interpolates a timestamp into what used to be a stable prefix. Cost triples. &lt;a href="https://ferhatatagun.com/blog/prompt-caching-nobody-measures" rel="noopener noreferrer"&gt;Nobody's watching that number either.&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these announce themselves. All of them show up as a diff in a&lt;br&gt;
twenty-line script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is weaker than it sounds
&lt;/h2&gt;

&lt;p&gt;I'd rather say this than have you find out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can freeze a bug.&lt;/strong&gt; If the day-three output was subtly wrong and got a&lt;br&gt;
yes, you've now enshrined it and your harness will defend it. The mitigation is&lt;br&gt;
unglamorous: revisit the frozen set once you know more, and treat early&lt;br&gt;
acceptance as provisional rather than permanent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small N misses things.&lt;/strong&gt; Ten cases will not cover a workflow with real&lt;br&gt;
variety. It's a floor, not a ceiling — but a floor you have on day three beats a&lt;br&gt;
ceiling you get in month three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It says nothing about quality.&lt;/strong&gt; A regression harness will happily confirm&lt;br&gt;
that your mediocre system is still exactly as mediocre as it was. That's a real&lt;br&gt;
limitation, and it's why this is the &lt;em&gt;first&lt;/em&gt; eval rather than the only one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upgrade path is free
&lt;/h2&gt;

&lt;p&gt;Here's the part I like.&lt;/p&gt;

&lt;p&gt;Every failure the regression harness catches is a labelled example. Case D broke&lt;br&gt;
when you fixed case A? You now have a case with a known-correct answer and a&lt;br&gt;
known failure mode, discovered from real behaviour rather than imagined during a&lt;br&gt;
planning meeting.&lt;/p&gt;

&lt;p&gt;After a month of this you're not starting a golden dataset from zero. You're&lt;br&gt;
curating one that assembled itself out of actual failures — which is a&lt;br&gt;
substantially better dataset than one written in advance by someone guessing at&lt;br&gt;
what might go wrong.&lt;/p&gt;

&lt;p&gt;The cheap eval isn't a compromise you eventually replace. It's the collection&lt;br&gt;
mechanism for the expensive one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the deliverable
&lt;/h2&gt;

&lt;p&gt;I've made a version of this argument before, for a solo developer choosing&lt;br&gt;
between two prompts — that &lt;a href="https://ferhatatagun.com/blog/stop-choosing-prompts-by-vibes" rel="noopener noreferrer"&gt;you don't remember your prompt being better, you&lt;br&gt;
just remember it being better&lt;/a&gt;.&lt;br&gt;
That post was about self-deception on a personal scale, and the stakes were an&lt;br&gt;
afternoon.&lt;/p&gt;

&lt;p&gt;The stakes change when you hand something over.&lt;/p&gt;

&lt;p&gt;When you leave, the prompt is a text file. Someone will edit it — reasonably,&lt;br&gt;
for a good reason, in response to a real complaint. That's not a failure mode,&lt;br&gt;
that's the system working. The failure mode is that they have no way to know&lt;br&gt;
what their edit cost somewhere else.&lt;/p&gt;

&lt;p&gt;A prompt without an eval is a config file nobody is allowed to touch, which&lt;br&gt;
means either it never improves or it degrades unpredictably. Both are how a&lt;br&gt;
deployment dies quietly. A prompt &lt;em&gt;with&lt;/em&gt; a frozen set is something a team can&lt;br&gt;
actually own after you're gone — and whether they can own it is the thing that&lt;br&gt;
decides if the work survives.&lt;/p&gt;

&lt;p&gt;Which is the real subject of the next post: not what you build, but what you&lt;br&gt;
leave behind.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part three of a series on the last mile of enterprise AI.&lt;br&gt;
Part one: &lt;a href="https://ferhatatagun.com/blog/nobodys-model-failed" rel="noopener noreferrer"&gt;Nobody's model failed. The interface did.&lt;/a&gt; ·&lt;br&gt;
Part two: &lt;a href="https://ferhatatagun.com/blog/accidental-fde-field-kit" rel="noopener noreferrer"&gt;I accidentally built a forward-deployed engineer's field kit&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/the-eval-is-the-deliverable" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/the-eval-is-the-deliverable&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; — browser-only, BYOK, open-source Claude API dev tools&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>webdev</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I accidentally built a forward-deployed engineer's field kit</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:48:53 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/i-accidentally-built-a-forward-deployed-engineers-field-kit-khi</link>
      <guid>https://dev.to/ferhatatagun/i-accidentally-built-a-forward-deployed-engineers-field-kit-khi</guid>
      <description>&lt;p&gt;A year ago I deleted &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; from a project and wrote about 150&lt;br&gt;
lines of TypeScript to replace it.&lt;/p&gt;

&lt;p&gt;The reason I gave myself was honest and boring. The SDK pulled &lt;code&gt;node:fs/promises&lt;/code&gt;&lt;br&gt;
in through an agent-toolset module, which broke the browser bundle. I could have&lt;br&gt;
waited for a browser-clean entry point. Instead I hand-rolled an SSE parser and&lt;br&gt;
&lt;a href="https://ferhatatagun.com/blog/browser-only-claude-streaming" rel="noopener noreferrer"&gt;wrote a post about why&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I filed that away as a bundler story.&lt;/p&gt;

&lt;p&gt;Then I read a line in a piece about tooling for forward-deployed engineers — the&lt;br&gt;
people who get sent into a customer's building to make an AI deployment actually&lt;br&gt;
work. Roughly: every framework dependency is something the customer's security&lt;br&gt;
team will ask about, and bare-metal code is easier to debug in an environment&lt;br&gt;
that isn't yours.&lt;/p&gt;

&lt;p&gt;Same decision. Same reasoning. Different discipline, different job title,&lt;br&gt;
different problem entirely — except it wasn't a different problem.&lt;/p&gt;

&lt;p&gt;I hadn't been solving a bundler issue. I'd been solving a &lt;em&gt;deployment&lt;/em&gt; problem&lt;br&gt;
without ever naming it, which is why I mistook it for taste.&lt;/p&gt;

&lt;p&gt;This post is what happened when I went back through every architectural decision&lt;br&gt;
in the five tools I've shipped and asked a question I'd never asked: what&lt;br&gt;
constraint was actually operating here?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Five tools, all browser-only, all BYOK, none with a backend. I picked those for what felt like preference and mild laziness.&lt;/li&gt;
&lt;li&gt;They are, almost exactly, the constraints of working inside someone else's regulated environment: nothing to install, no data leaving the boundary, nothing for a security team to threat-model, minimal dependency surface to defend.&lt;/li&gt;
&lt;li&gt;Constraints converge. Two disciplines solving the same &lt;em&gt;shape&lt;/em&gt; of problem land on the same engineering decisions without talking to each other.&lt;/li&gt;
&lt;li&gt;Mapped against the four categories FDE tooling usually gets split into — observability, evaluation, orchestration, guardrails — I have three. The fourth is missing, and pretending otherwise would be the least interesting thing I could do.&lt;/li&gt;
&lt;li&gt;The transferable part isn't my repos. It's that an articulated constraint is what separates a tool from a toy, and most side projects never articulate one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The decision, and the reason underneath it
&lt;/h2&gt;

&lt;p&gt;Here's the thing about the SDK call that I got wrong at the time.&lt;/p&gt;

&lt;p&gt;I framed it as: &lt;em&gt;the SDK doesn't work in my environment, so I'll write the&lt;br&gt;
minimum that does.&lt;/em&gt; Reasonable. What I actually did was reduce the tool's&lt;br&gt;
dependency surface to &lt;code&gt;fetch&lt;/code&gt; and a &lt;code&gt;TextDecoder&lt;/code&gt; — and the consequence of that,&lt;br&gt;
which I did not think about even once, is that the entire runtime is&lt;br&gt;
inspectable. There's no framework doing something clever between my code and the&lt;br&gt;
wire. When a stream terminates weirdly, I read my own parser.&lt;/p&gt;

&lt;p&gt;That property is worthless when you're debugging on your own laptop with a&lt;br&gt;
debugger attached and all the time in the world.&lt;/p&gt;

&lt;p&gt;It is the whole game when you're on a call with someone else's engineer, in&lt;br&gt;
someone else's network, and they ask why the response cut off, and the honest&lt;br&gt;
answer needs to arrive in the next ninety seconds.&lt;/p&gt;

&lt;p&gt;I didn't build it for that. But that's what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four more decisions, same shape
&lt;/h2&gt;

&lt;p&gt;Once I started looking, the pattern didn't stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BYOK — bring your own key.&lt;/strong&gt; What I told myself: I don't want to run a proxy,&lt;br&gt;
I don't want to hold anyone's credentials, and I &lt;em&gt;really&lt;/em&gt; don't want to pay for&lt;br&gt;
strangers' tokens. All true. What it actually is: the customer's key never&lt;br&gt;
leaves their browser and their prompt never touches infrastructure I control.&lt;br&gt;
There's no data-processing agreement to negotiate because there's no data&lt;br&gt;
processing. The "I'm too lazy to run a backend" version and the "this passes a&lt;br&gt;
compliance review" version are the same architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No backend at all.&lt;/strong&gt; What I told myself: static hosting is free and I don't&lt;br&gt;
want to maintain servers for a side project. What it actually is: there is no&lt;br&gt;
server to threat-model, no attack surface to document, no uptime story to tell,&lt;br&gt;
and no vendor security questionnaire that takes six weeks to clear. The thing a&lt;br&gt;
security team can approve fastest is the thing that doesn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every tool is a URL.&lt;/strong&gt; What I told myself: it's easier to share a link than to&lt;br&gt;
tell someone to clone a repo and run &lt;code&gt;npm install&lt;/code&gt;. What it actually is: zero&lt;br&gt;
install. Nothing enters the customer's machine. The people who most need a&lt;br&gt;
diagnostic tool are exactly the people who are least permitted to install one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost rendered live, not logged.&lt;/strong&gt; What I told myself: the number is&lt;br&gt;
interesting and I wanted to see it move. What it actually is: the person who&lt;br&gt;
approves the renewal can read it without asking anyone. I made &lt;a href="https://ferhatatagun.com/blog/prompt-caching-nobody-measures" rel="noopener noreferrer"&gt;an entire&lt;br&gt;
argument&lt;/a&gt; about&lt;br&gt;
prompt caching being the cheapest optimization nobody measures, and I still&lt;br&gt;
framed it as an engineering-hygiene issue. It isn't. It's a&lt;br&gt;
&lt;a href="https://ferhatatagun.com/blog/nobodys-model-failed" rel="noopener noreferrer"&gt;trust issue&lt;/a&gt;, and trust is&lt;br&gt;
what decides whether a deployment survives.&lt;/p&gt;

&lt;p&gt;Four decisions. Four reasons I gave at the time that were true but shallow. One&lt;br&gt;
constraint underneath all of them that I never said out loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  What browser-only actually costs
&lt;/h2&gt;

&lt;p&gt;I don't want to make this sound cleaner than it is, so let me argue against&lt;br&gt;
myself for a second.&lt;/p&gt;

&lt;p&gt;Browser-only is a genuinely bad choice for a lot of software. You get no&lt;br&gt;
server-side secret handling. You fight CORS constantly — and for some providers&lt;br&gt;
you simply lose, because they don't send the headers and there's nothing you can&lt;br&gt;
do about it from a tab. You have no durable storage worth the name, no scheduled&lt;br&gt;
jobs, no background processing, no way to do anything computationally serious.&lt;br&gt;
You can't build a product this way. I'm not going to pretend you can.&lt;/p&gt;

&lt;p&gt;But none of those limitations bind on a &lt;em&gt;diagnostic instrument&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A multimeter doesn't need a database. The tool that tells you why the system is&lt;br&gt;
behaving strangely is not the system. It has to be trustworthy, portable, and&lt;br&gt;
readable — and it has to work at the exact moment when everything heavier is&lt;br&gt;
unavailable to you. Every constraint I listed as a cost is irrelevant to that&lt;br&gt;
job, and two of them (no storage, no server) are the reason it can be used at&lt;br&gt;
all in a place that would reject a real deployment.&lt;/p&gt;

&lt;p&gt;The limitation and the qualification are the same fact viewed from two sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map
&lt;/h2&gt;

&lt;p&gt;FDE tooling gets described, fairly consistently, in four categories: agent&lt;br&gt;
orchestration, evaluation, guardrails, and observability. I went and put my&lt;br&gt;
things in the boxes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;&lt;a href="https://claudoscope-labs.vercel.app" rel="noopener noreferrer"&gt;claudoscope&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;X-rays a live call — token composition, cache reads and writes, cost, as the response streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability / debugging&lt;/td&gt;
&lt;td&gt;&lt;a href="https://agentreplay.vercel.app" rel="noopener noreferrer"&gt;agent-replay&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Replays a finished agent trace as a timeline instead of a wall of nested JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-flight&lt;/td&gt;
&lt;td&gt;&lt;a href="https://context-lens-sigma.vercel.app" rel="noopener noreferrer"&gt;context-lens&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Counts the prompt before you send it — window position, cost, caching boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation&lt;/td&gt;
&lt;td&gt;&lt;a href="https://prompt-lab-promptly.vercel.app" rel="noopener noreferrer"&gt;prompt-lab&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Two prompts, one input, side by side on output, latency and cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestration&lt;/td&gt;
&lt;td&gt;&lt;a href="https://tool-lab-bice.vercel.app" rel="noopener noreferrer"&gt;tool-lab&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Sandboxes the tool-use loop — define tools, mock responses, drive it by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Guardrails&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three categories covered, one pre-flight bonus, one hole.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because there's a version of this post that's a&lt;br&gt;
portfolio tour with an FDE hat on, and that version is worthless. So: I didn't&lt;br&gt;
plan this map. It's a retrofit. The tools were built one at a time over a couple&lt;br&gt;
of weekends each, and the only through-line I could have articulated at the time&lt;br&gt;
was &lt;a href="https://ferhatatagun.com/blog/four-tools-in-two-weekends" rel="noopener noreferrer"&gt;"make the Claude API&lt;br&gt;
legible"&lt;/a&gt;. The&lt;br&gt;
categories came from somebody else's discipline. The fit is real, and it's also&lt;br&gt;
an accident, and both of those things can be true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap I can't paper over
&lt;/h2&gt;

&lt;p&gt;Guardrails is missing, and it's the one that would hurt most in the field.&lt;/p&gt;

&lt;p&gt;Guardrails is the layer that keeps model output inside a shape your code can&lt;br&gt;
safely consume. You ask for &lt;code&gt;{"risk": "high"|"medium"|"low", "score": 0-100}&lt;/code&gt;.&lt;br&gt;
What actually comes back, across enough calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the JSON wrapped in a markdown fence&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"HIGH"&lt;/code&gt; instead of &lt;code&gt;"high"&lt;/code&gt; — enum drift&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"very high"&lt;/code&gt; — an enum value that doesn't exist, invented on the spot&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"score": "85"&lt;/code&gt; as a string, silently breaking arithmetic downstream&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;score&lt;/code&gt; missing entirely&lt;/li&gt;
&lt;li&gt;a sentence of prose before the JSON starts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those either crashes your parser or, worse, doesn't — and quietly&lt;br&gt;
puts a wrong category on someone's dashboard.&lt;/p&gt;

&lt;p&gt;The reason this needs an instrument rather than a unit test is that the failures&lt;br&gt;
are &lt;strong&gt;distributional&lt;/strong&gt;. You run it once, it works, you ship. Then one call in&lt;br&gt;
forty returns a hallucinated enum, and you never see it, because you looked at&lt;br&gt;
one sample. What you need is the failure &lt;em&gt;rate&lt;/em&gt;, broken down by failure type,&lt;br&gt;
across fifty runs. That's a different question than "does it work."&lt;/p&gt;

&lt;p&gt;So that's the sixth tool, and it's the honest one to build next: define a&lt;br&gt;
schema, run a prompt against it N times, show the distribution of ways it&lt;br&gt;
breaks. Same constraints as the rest — browser-only, BYOK, no backend, because&lt;br&gt;
by now those aren't a preference, they're the spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why any of this generalizes
&lt;/h2&gt;

&lt;p&gt;Strip out my repos and there's one idea left worth keeping.&lt;/p&gt;

&lt;p&gt;Most side projects have no constraint. That's why they read as toys — not&lt;br&gt;
because they're small or unfinished, but because nothing about them was &lt;em&gt;forced&lt;/em&gt;.&lt;br&gt;
Any decision could have gone the other way and nothing would have broken. A&lt;br&gt;
reader can feel that.&lt;/p&gt;

&lt;p&gt;A tool with an articulated constraint reads completely differently, even when&lt;br&gt;
it's fifty lines. "This runs in a tab because it has to work where nothing can&lt;br&gt;
be installed" is a design brief. "This is a React app" is not. The first one&lt;br&gt;
tells you what the author was up against; the second tells you what they typed.&lt;/p&gt;

&lt;p&gt;The uncomfortable part, for me, is that I had the constraint the whole time and&lt;br&gt;
couldn't name it. I shipped five things under a rule I was following&lt;br&gt;
unconsciously, and because I never said it out loud, I also couldn't tell you&lt;br&gt;
what the work was evidence &lt;em&gt;of&lt;/em&gt;. It looked like five small tools. It was a&lt;br&gt;
position.&lt;/p&gt;

&lt;p&gt;If you're a frontend engineer looking at the current AI hiring market and&lt;br&gt;
wondering how to become legible to it: you very likely already have&lt;br&gt;
constraint-shaped work sitting in your repos. The move isn't to build something&lt;br&gt;
new. It's to go back and figure out what you were actually solving for, and then&lt;br&gt;
say it in the README.&lt;/p&gt;

&lt;p&gt;Mine took a year and a stranger's sentence about security teams.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part two of a series on the last mile of enterprise AI. Part one is&lt;br&gt;
&lt;a href="https://ferhatatagun.com/blog/nobodys-model-failed" rel="noopener noreferrer"&gt;Nobody's model failed. The interface did.&lt;/a&gt;&lt;br&gt;
Part three is about the thing I keep saying and haven't yet defended properly —&lt;br&gt;
that on this kind of work, the deliverable isn't the prompt, it's the eval.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/accidental-fde-field-kit" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/accidental-fde-field-kit&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; — browser-only, BYOK, open-source Claude API dev tools&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Nobody's model failed. The interface did.</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:41:06 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/nobodys-model-failed-the-interface-did-5dh3</link>
      <guid>https://dev.to/ferhatatagun/nobodys-model-failed-the-interface-did-5dh3</guid>
      <description>&lt;p&gt;Three numbers, and they don't sit comfortably next to each other.&lt;/p&gt;

&lt;p&gt;Postings for one job title — forward deployed engineer — went up roughly&lt;br&gt;
&lt;strong&gt;800% in 2025&lt;/strong&gt;. Total comp at the frontier labs settled somewhere around&lt;br&gt;
&lt;strong&gt;$350–550K&lt;/strong&gt; for mid-to-senior. And enterprise AI pilots still fail at a rate&lt;br&gt;
that lands somewhere between &lt;strong&gt;70% and 90%&lt;/strong&gt;, depending on whose survey you&lt;br&gt;
read. One widely-cited figure has the share of companies abandoning AI&lt;br&gt;
initiatives going from 17% to 42% in a single year.&lt;/p&gt;

&lt;p&gt;If the models were the bottleneck, the fix would be a better model. Instead the&lt;br&gt;
industry's answer has been to hire humans and put them inside the customer's&lt;br&gt;
building. That is an unusual response to a technology problem. It's the response&lt;br&gt;
you get when the thing that's broken isn't the technology.&lt;/p&gt;

&lt;p&gt;So: what exactly keeps failing?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The canonical FDE success story — OpenAI at Morgan Stanley — is about 6–8 weeks of technical scaffolding followed by roughly &lt;strong&gt;four months&lt;/strong&gt; of getting advisors to actually use the thing. The reported win condition is &lt;strong&gt;98% adoption&lt;/strong&gt;, not accuracy and not latency.&lt;/li&gt;
&lt;li&gt;An adoption number is not a model metric. It's a measure of whether a human decided to trust what was on their screen. That decision is made at the interface.&lt;/li&gt;
&lt;li&gt;The three places that trust reliably dies — hidden uncertainty, invisible cost, and a system that won't show its work — are all rendering decisions, not modelling decisions.&lt;/li&gt;
&lt;li&gt;FDE roles are written and staffed as backend/ML/infra roles. Read a dozen job specs and count how many mention what the operator sees. The failure surface and the hiring surface don't line up.&lt;/li&gt;
&lt;li&gt;This isn't an argument that the backend doesn't matter. It's an argument that the backend is the six weeks, and the last mile is the four months.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The number that doesn't fit
&lt;/h2&gt;

&lt;p&gt;Every write-up of the forward-deployed model eventually reaches for the same&lt;br&gt;
case study: OpenAI's engineers embedded at Morgan Stanley, building an assistant&lt;br&gt;
for wealth advisors.&lt;/p&gt;

&lt;p&gt;The part everyone quotes is the ending — 98% adoption. The part worth staring at&lt;br&gt;
is the shape of the timeline that produced it. Roughly six to eight weeks of&lt;br&gt;
technical scaffolding: integration, data plumbing, evals. Then about four months&lt;br&gt;
of running pilots with actual advisors.&lt;/p&gt;

&lt;p&gt;Four months. After the system worked.&lt;/p&gt;

&lt;p&gt;Whatever was happening in those four months, it wasn't model training. The model&lt;br&gt;
was done. The integration was done. What remained was a group of experienced&lt;br&gt;
professionals deciding, one at a time, whether they were willing to put their&lt;br&gt;
name on an answer a machine produced.&lt;/p&gt;

&lt;p&gt;That is the last mile. And notice how it's measured: not "the model scored 0.91&lt;br&gt;
on our eval set" but "98% of them use it." A deployment that nobody opens is&lt;br&gt;
indistinguishable from a deployment that doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adoption is an interface number
&lt;/h2&gt;

&lt;p&gt;Here's the reframe I'd argue for.&lt;/p&gt;

&lt;p&gt;We treat adoption as a change-management problem — training sessions,&lt;br&gt;
champions, executive mandates. Some of that is real. But most of what determines&lt;br&gt;
whether a professional trusts a system is not a memo. It's the accumulated&lt;br&gt;
experience of using it: what it showed them, what it hid, and whether it was&lt;br&gt;
honest about the difference.&lt;/p&gt;

&lt;p&gt;A wealth advisor doesn't trust a model because someone told them its F1 score.&lt;br&gt;
They trust it because over three weeks it never confidently handed them&lt;br&gt;
something wrong without warning them first — and when it &lt;em&gt;was&lt;/em&gt; unsure, it said&lt;br&gt;
so in a way they could act on.&lt;/p&gt;

&lt;p&gt;That's not a model property. That's a rendering property. It's a decision&lt;br&gt;
somebody made about what appears on screen when the confidence is 0.51 instead&lt;br&gt;
of 0.98.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places trust dies — all of them on screen
&lt;/h2&gt;

&lt;p&gt;I'd argue there are three recurring failure modes in AI-powered interfaces, and&lt;br&gt;
none of them is a model defect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Uncertainty is flattened.&lt;/strong&gt; The model returns something it half-guessed,&lt;br&gt;
formatted identically to something it's certain about. Same font, same&lt;br&gt;
confidence, same tone. The operator has no way to distinguish. They find out the&lt;br&gt;
hard way — once — and after that they double-check everything, which means the&lt;br&gt;
system has stopped saving them time, which means they stop using it.&lt;/p&gt;

&lt;p&gt;The fix is a design decision: give low-confidence output a different affordance.&lt;br&gt;
Not a scary red banner. Something that says &lt;em&gt;check this one&lt;/em&gt; in a way a busy&lt;br&gt;
person will actually parse at a glance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cost is invisible until the invoice.&lt;/strong&gt; The people who approve the renewal&lt;br&gt;
are not the people using the tool. If the only time the budget owner sees a&lt;br&gt;
number is on the monthly bill, every conversation about the system is a&lt;br&gt;
conversation about a surprise. I've written about&lt;br&gt;
&lt;a href="https://ferhatatagun.com/blog/prompt-caching-nobody-measures" rel="noopener noreferrer"&gt;why nobody measures prompt caching&lt;/a&gt;&lt;br&gt;
and about&lt;br&gt;
&lt;a href="https://ferhatatagun.com/blog/see-the-prompt-before-you-ship-it" rel="noopener noreferrer"&gt;pre-flighting a prompt before you send it&lt;/a&gt;,&lt;br&gt;
and both of those posts were, in retrospect, about this: cost is a first-class&lt;br&gt;
piece of interface state, and treating it as an ops metric hides it from the&lt;br&gt;
person who decides whether the project survives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The system won't show its work.&lt;/strong&gt; When output is wrong and the operator&lt;br&gt;
can't see &lt;em&gt;why&lt;/em&gt;, they don't file a bug — they lose confidence in the whole&lt;br&gt;
thing, including the parts that were right. A system that can't be inspected&lt;br&gt;
can only be trusted or abandoned, and people abandon.&lt;/p&gt;

&lt;p&gt;This is why I ended up building&lt;br&gt;
&lt;a href="https://ferhatatagun.com/blog/debug-claude-agents-by-replaying-traces" rel="noopener noreferrer"&gt;trace replay for agent runs&lt;/a&gt;.&lt;br&gt;
Not because traces are interesting, but because "why did it do that" is the&lt;br&gt;
question that decides whether a deployment survives its first bad week.&lt;/p&gt;

&lt;p&gt;Every one of these three is fixed in the interface layer. None of them is fixed&lt;br&gt;
by a better model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The strongest version of the counter-argument
&lt;/h2&gt;

&lt;p&gt;Let me argue the other side properly, because the weak version is easy to knock&lt;br&gt;
down and I don't want to do that.&lt;/p&gt;

&lt;p&gt;The serious objection goes: &lt;em&gt;the last mile isn't a UI problem, it's a data&lt;br&gt;
problem.&lt;/em&gt; The customer's schema is undocumented. Half the workflow lives in a&lt;br&gt;
spreadsheet somebody's assistant maintains. The API you were promised doesn't&lt;br&gt;
exist. Nothing renders correctly because nothing is correct upstream. The head&lt;br&gt;
of OpenAI's FDE team has said more or less this — that what a customer describes&lt;br&gt;
during scoping routinely fails to match the reality of the systems on the&lt;br&gt;
ground.&lt;/p&gt;

&lt;p&gt;That is true, and it's the hardest part of the job. I'm not disputing it.&lt;/p&gt;

&lt;p&gt;But look at the timeline again. That work is the six weeks. It's necessary and&lt;br&gt;
it is absolutely not sufficient, and the evidence is that the project didn't&lt;br&gt;
succeed at week eight — it succeeded four months later, after a completely&lt;br&gt;
different kind of work.&lt;/p&gt;

&lt;p&gt;Both halves are real. My claim is narrower than "UI is what matters." It's:&lt;br&gt;
the second half is the larger half, it's the half where projects actually die,&lt;br&gt;
and it's the half almost nobody is being hired against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hiring gap
&lt;/h2&gt;

&lt;p&gt;Go read a batch of forward-deployed engineer job specs. You'll see: production&lt;br&gt;
LLM experience, advanced prompt engineering, agent frameworks — LangGraph,&lt;br&gt;
LangChain, CrewAI, DSPy — evaluation frameworks, deployment at scale,&lt;br&gt;
multi-step tool-use chains, sometimes air-gapped and bare-metal provisioning.&lt;/p&gt;

&lt;p&gt;All of it legitimate. All of it necessary.&lt;/p&gt;

&lt;p&gt;Now count the lines about what the operator sees. About how uncertainty is&lt;br&gt;
surfaced. About whether the person doing the work can tell the difference&lt;br&gt;
between a confident answer and a guess.&lt;/p&gt;

&lt;p&gt;The role was defined by people solving the integration problem, and it was&lt;br&gt;
staffed by people who are excellent at the integration problem. Meanwhile the&lt;br&gt;
number everyone reports as the win condition — adoption — is decided somewhere&lt;br&gt;
else entirely.&lt;/p&gt;

&lt;p&gt;I don't think this is a conspiracy or an oversight by unserious people. I think&lt;br&gt;
it's what happens when a role gets invented under time pressure by the&lt;br&gt;
discipline that noticed the problem first. Palantir invented this model in the&lt;br&gt;
2000s for intelligence customers who literally could not describe what they&lt;br&gt;
needed. The problem then really was mostly data and access. The problem now has&lt;br&gt;
a large human-facing component, and the job description hasn't caught up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do this week
&lt;/h2&gt;

&lt;p&gt;If you're shipping an AI feature into somebody else's workflow, three concrete&lt;br&gt;
moves, in order of how cheap they are:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Give uncertainty a visual identity.&lt;/strong&gt; Pick one signal your model already&lt;br&gt;
emits — a confidence score, a refusal, a low-agreement result from two sampled&lt;br&gt;
runs — and render it differently. One afternoon of work. It converts your&lt;br&gt;
system from "trust it or don't" into "trust it here, check it there," and that&lt;br&gt;
distinction is the entire difference between a tool people keep and a tool&lt;br&gt;
people quietly stop opening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Put cost on screen for whoever signs the renewal.&lt;/strong&gt; Not in a dashboard they&lt;br&gt;
have to remember to open. In the thing they already look at. A number that&lt;br&gt;
updates is a number that never becomes a surprise, and surprises are what kill&lt;br&gt;
renewals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make one thing inspectable.&lt;/strong&gt; Pick the single most consequential decision&lt;br&gt;
your system makes and give the operator a way to see how it got there. Not full&lt;br&gt;
observability — one path, one explanation. The first time something goes wrong,&lt;br&gt;
that path is the difference between a bug report and a lost account.&lt;/p&gt;

&lt;p&gt;None of these require touching the model. All of them move the number the FDE&lt;br&gt;
model says is the win condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes next
&lt;/h2&gt;

&lt;p&gt;If the argument holds — that the last mile is substantially an interface&lt;br&gt;
problem — then there's a follow-on question worth taking seriously: what does an&lt;br&gt;
engineer working that mile actually carry?&lt;/p&gt;

&lt;p&gt;Because the constraints are brutal and specific. You're in someone else's&lt;br&gt;
environment. You can't install anything without a six-week security review. The&lt;br&gt;
data can't leave their boundary. Every dependency you bring is a question their&lt;br&gt;
security team gets to ask. And you still need to answer "why did it do that"&lt;br&gt;
while a stakeholder watches over your shoulder.&lt;/p&gt;

&lt;p&gt;I've spent a year building tools under exactly those constraints, and until&lt;br&gt;
recently I thought I was doing it for aesthetic reasons.&lt;/p&gt;

&lt;p&gt;That's the next post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part one of a series on the last mile of enterprise AI. Part two —&lt;br&gt;
"I accidentally built a forward-deployed engineer's field kit" — is about the&lt;br&gt;
tools, and the constraint that turns out to define them.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/nobodys-model-failed" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/nobodys-model-failed&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; — browser-only, BYOK, open-source Claude API dev tools&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>career</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How I shipped a blog Google couldn't see</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Mon, 15 Jun 2026 06:57:08 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/how-i-shipped-a-blog-google-couldnt-see-2nlc</link>
      <guid>https://dev.to/ferhatatagun/how-i-shipped-a-blog-google-couldnt-see-2nlc</guid>
      <description>&lt;p&gt;Every blog post on my site looked fine. Open &lt;code&gt;/blog/something&lt;/code&gt;, the&lt;br&gt;
article was there — title, paragraphs, code blocks, the works.&lt;/p&gt;

&lt;p&gt;Then I ran &lt;code&gt;curl https://ferhatatagun.com/blog/four-tools-in-two-weekends&lt;/code&gt;&lt;br&gt;
on a hunch, and the HTML had &lt;strong&gt;zero&lt;/strong&gt; of the body text. Title in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;,&lt;br&gt;
layout chrome, a perfectly empty &lt;code&gt;&amp;lt;div class="markdown-container" /&amp;gt;&lt;/code&gt;, and&lt;br&gt;
nothing else. The article only rendered after JavaScript loaded — meaning&lt;br&gt;
the version Google indexed had no article in it.&lt;/p&gt;

&lt;p&gt;This had been the case for &lt;em&gt;every blog post on the site, for months&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The site used &lt;code&gt;adapter-static&lt;/code&gt; with &lt;code&gt;prerender = true&lt;/code&gt;, which suggests "all routes are rendered to HTML at build time." That's true for the page chrome — but not the body.&lt;/li&gt;
&lt;li&gt;The Markdown component parsed &lt;code&gt;content&lt;/code&gt; inside &lt;code&gt;onMount&lt;/code&gt;, so the HTML on disk had a skeleton and nothing else. The article materialized only after hydration.&lt;/li&gt;
&lt;li&gt;Two ways this hides from you: every browser you test in runs the JS, so the page looks fine; and the page reports a healthy 200 response, so monitoring stays green.&lt;/li&gt;
&lt;li&gt;The fix is mechanical (parse markdown at module scope, inject via &lt;code&gt;{@html}&lt;/code&gt;), but it cascaded: prerender OOMed on the worker heap, the prerender crawler followed embedded &lt;code&gt;.md&lt;/code&gt; links and 404'd, and the static adapter's fallback was clobbering the prerendered home. Each problem appeared only because the previous one was fixed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a write-up of the regression: what I missed, how it stayed hidden,&lt;br&gt;
the actual code-level fix, and the three secondary failures that the fix&lt;br&gt;
unblocked.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the page was actually serving
&lt;/h2&gt;

&lt;p&gt;The Svelte component looked harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gfmHeadingId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked-gfm-heading-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mangle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked-mangle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;createSanitizer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dompurify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Prism&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prismjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onMount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svelte&lt;/span&gt;&lt;span class="dl"&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;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;let&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;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;onMount&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;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gfmHeadingId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;mangle&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;sanitizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSanitizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&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;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sanitizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;Prism&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;highlightAllUnder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;bind:this=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"markdown-container"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole thing — parsing markdown, sanitising, highlighting — lives&lt;br&gt;
inside &lt;code&gt;onMount&lt;/code&gt;. That callback fires only in the browser, after&lt;br&gt;
hydration. During SvelteKit's prerender pass, &lt;code&gt;onMount&lt;/code&gt; never runs.&lt;br&gt;
So the HTML on disk contains exactly what's in the template: an empty&lt;br&gt;
&lt;code&gt;&amp;lt;div class="markdown-container" /&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The article body was being added &lt;em&gt;imperatively to the DOM at runtime&lt;/em&gt;.&lt;br&gt;
That's invisible to Google. It's invisible to OG/Twitter card scrapers.&lt;br&gt;
It's invisible to anyone who fetches the URL with &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two checks that would have caught this and didn't:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;My browser tabs were always rendering the right thing&lt;/strong&gt;, because they
ran the JS. Testing the live page by &lt;em&gt;looking at it&lt;/em&gt; is testing the
hydrated version, not the indexed version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page returned 200.&lt;/strong&gt; Uptime monitors stayed green. Status pages
stayed green. Lighthouse scored fine, because Lighthouse runs JS too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only way to see the regression is to bypass JS. &lt;code&gt;curl&lt;/code&gt; does. So does&lt;br&gt;
Googlebot's render preview. So does the View-source feature your browser&lt;br&gt;
hides three menus deep. I'd been opening DevTools to inspect post-hydration&lt;br&gt;
DOM for months, and never View Source on the raw response.&lt;/p&gt;

&lt;p&gt;The numbers, once I looked:&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="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://ferhatatagun.com/blog/four-tools-in-two-weekends &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    | wc -c
32280
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://ferhatatagun.com/blog/four-tools-in-two-weekends &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    | grep -c "claudoscope"
0
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://ferhatatagun.com/blog/four-tools-in-two-weekends &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    | grep -c "TL;&lt;/span&gt;DR&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page is 32 KB and contains no part of the article body. "claudoscope"&lt;br&gt;
appears half a dozen times in the post; in the HTML, zero. Same for&lt;br&gt;
"TL;DR". The HTML was 100% layout chrome.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why &lt;code&gt;prerender = true&lt;/code&gt; wasn't enough
&lt;/h2&gt;

&lt;p&gt;The static adapter's prerender pass walks every route, calls the page's&lt;br&gt;
&lt;code&gt;load&lt;/code&gt;, and renders the resulting component tree to HTML. It runs all the&lt;br&gt;
top-level Svelte component code. What it does &lt;em&gt;not&lt;/em&gt; do is run lifecycle&lt;br&gt;
hooks like &lt;code&gt;onMount&lt;/code&gt;, because those are explicitly contracted to be&lt;br&gt;
browser-only.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;prerender = true&lt;/code&gt; was doing exactly what it advertises. The bug was&lt;br&gt;
that the data dependency lived behind a lifecycle that prerender skips.&lt;/p&gt;

&lt;p&gt;The fix is to make markdown parsing module-level, not lifecycle-level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gfmHeadingId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked-gfm-heading-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mangle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked-mangle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onMount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prismjs/themes/prism-tomorrow.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gfmHeadingId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;mangle&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;let&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;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;$&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&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;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;string&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;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;onMount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Prism&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prismjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prismjs/components/prism-typescript&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;container&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;Prism&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;highlightAllUnder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;bind:this=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"markdown-container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;@html&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things changed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;marked.use(...)&lt;/code&gt; moved to module scope. It now runs both during
prerender and during hydration, configuring the same extensions in
both environments.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parsed = marked.parse(content)&lt;/code&gt; is a reactive top-level statement.
It runs synchronously inside the component's render pass, so its
output is in the HTML that goes to disk.&lt;/li&gt;
&lt;li&gt;Prism syntax highlighting stays inside &lt;code&gt;onMount&lt;/code&gt;, dynamic-imported.
Prism touches &lt;code&gt;self&lt;/code&gt; at import time, which is fine in the browser
but not on the prerender worker. Highlighting is cosmetic — losing
it on prerender is invisible until JS loads, which is acceptable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also dropped DOMPurify. The original code piped marked's output&lt;br&gt;
through it before injecting. That was paying a sanitiser's bundle cost&lt;br&gt;
plus a render-time cost, but the input was our own &lt;code&gt;?raw&lt;/code&gt;-imported&lt;br&gt;
markdown files, not user content. Defending against ourselves was&lt;br&gt;
defensive theatre. If a hostile actor can write to my markdown source,&lt;br&gt;
sanitising the output is the wrong layer to do it at.&lt;/p&gt;

&lt;p&gt;The result of the fix:&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="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; www/build/blog/four-tools-in-two-weekends.html
&lt;span class="go"&gt;45292
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"claudoscope"&lt;/span&gt; www/build/blog/four-tools-in-two-weekends.html
&lt;span class="go"&gt;3
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"TL;DR"&lt;/span&gt; www/build/blog/four-tools-in-two-weekends.html
&lt;span class="go"&gt;1
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;h1 id="&lt;/span&gt; www/build/blog/four-tools-in-two-weekends.html
&lt;span class="go"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;45 KB instead of 32 KB. The 13 KB difference is the article body —&lt;br&gt;
the part Google had been seeing as empty.&lt;/p&gt;
&lt;h2&gt;
  
  
  The home page was even worse
&lt;/h2&gt;

&lt;p&gt;The home page had a different version of the same problem. SvelteKit's&lt;br&gt;
&lt;code&gt;adapter-static&lt;/code&gt; accepts a &lt;code&gt;fallback&lt;/code&gt; option for SPA-style hosting; if&lt;br&gt;
a path doesn't have a prerendered HTML file, the server can serve the&lt;br&gt;
fallback and let the client-side router resolve it.&lt;/p&gt;

&lt;p&gt;The config was:&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;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which writes the fallback shell &lt;em&gt;to &lt;code&gt;index.html&lt;/code&gt;&lt;/em&gt;. The home route at &lt;code&gt;/&lt;/code&gt;&lt;br&gt;
&lt;em&gt;also&lt;/em&gt; prerenders to &lt;code&gt;index.html&lt;/code&gt;. So you have two operations writing to&lt;br&gt;
the same path. The fallback wins, because the adapter writes it after&lt;br&gt;
the prerender. The 40 KB prerendered home gets overwritten with the&lt;br&gt;
13 KB SPA shell.&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="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; www/build/index.html
&lt;span class="go"&gt;13096
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the bare HTML that the bundler emits as the SPA's entry point —&lt;br&gt;
just the imports for the JS bundles, no body content. Anyone hitting &lt;code&gt;/&lt;/code&gt;&lt;br&gt;
with a non-JS user agent was getting &lt;em&gt;that&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The fix is one character of intent:&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;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;200.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;200.html&lt;/code&gt; is a convention some static hosts (Surge, Netlify with&lt;br&gt;
configuration) use to mean "the SPA fallback." The static adapter&lt;br&gt;
doesn't care about the name; it just writes the fallback to whatever&lt;br&gt;
path you give it. Renaming to &lt;code&gt;200.html&lt;/code&gt; keeps the fallback for unknown&lt;br&gt;
paths without colliding with the prerendered home.&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="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; www/build/index.html
&lt;span class="go"&gt;40871
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;3.1× growth, all of it actual rendered home content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three problems the fix uncovered
&lt;/h2&gt;

&lt;p&gt;Each of these only became visible &lt;em&gt;because the previous one was fixed&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Prerender worker OOM
&lt;/h3&gt;

&lt;p&gt;Once the markdown was actually being parsed during prerender, the&lt;br&gt;
GitHub Actions build started failing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error [ERR_WORKER_OUT_OF_MEMORY]: Worker terminated due to reaching
memory limit: JS heap out of memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default Node heap on &lt;code&gt;ubuntu-latest&lt;/code&gt; is about 1.4 GB. The&lt;br&gt;
prerender pass was now doing real work — &lt;code&gt;marked.parse&lt;/code&gt; on every blog&lt;br&gt;
post markdown source, each producing 10–15 KB of HTML. Across 14 blog&lt;br&gt;
posts plus the markdown rendering inside other routes, that pushed the&lt;br&gt;
worker over the line.&lt;/p&gt;

&lt;p&gt;Two fixes:&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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--max-old-space-size=4096&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone unblocked it. Belt-and-suspenders, I also guarded the&lt;br&gt;
&lt;code&gt;marked.use(...)&lt;/code&gt; calls so they only configure once per Node process,&lt;br&gt;
in case Vite's SSR ever re-imports the module across routes:&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;__markedKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__omni_marked_configured__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;__markedScope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="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;__markedScope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;__markedKey&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gfmHeadingId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;mangle&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;__markedScope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;__markedKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The prerender crawler followed every link in the rendered HTML
&lt;/h3&gt;

&lt;p&gt;Several blog posts embed relative links to translations or &lt;code&gt;contributing.md&lt;/code&gt;&lt;br&gt;
files that live in the source repos they reference — &lt;code&gt;[/i18n/README.tr.md]&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;[/contributing.md]&lt;/code&gt;, that kind of thing. When markdown rendering was&lt;br&gt;
client-side, those got hydrated into &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tags but the prerender crawler&lt;br&gt;
never saw them.&lt;/p&gt;

&lt;p&gt;Now the crawler sees them, follows them, and treats the 404s as build&lt;br&gt;
errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: 404 /contributing.md (linked from /skills/nextjs)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are not site routes I own — they're content links inside post bodies.&lt;br&gt;
The fix is to demote prerender 404s from errors to warnings:&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;prerender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;handleHttpError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;handleMissingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&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;h3&gt;
  
  
  3. The shared host wasn't pulling from gh-pages
&lt;/h3&gt;

&lt;p&gt;The repo's CI deploys to &lt;code&gt;gh-pages&lt;/code&gt;. The &lt;code&gt;ferhatatagun.com&lt;/code&gt; domain&lt;br&gt;
points at a Spaceship shared host that I FTP-upload to. The two were&lt;br&gt;
unrelated, which meant every CI deploy updated &lt;code&gt;gh-pages&lt;/code&gt; and the live&lt;br&gt;
site stayed exactly as it had been.&lt;/p&gt;

&lt;p&gt;This wasn't a CI bug; it was a deployment-pipeline shape I'd let drift.&lt;br&gt;
The fix isn't code — it's "manually FTP the &lt;code&gt;gh-pages&lt;/code&gt; contents to the&lt;br&gt;
shared host's &lt;code&gt;public_html&lt;/code&gt;," or rebuild the deploy pipeline to push&lt;br&gt;
directly. For one-shot remediation, I went with the FTP path.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to catch the next one before it ships
&lt;/h2&gt;

&lt;p&gt;The reason this regression survived for months is that none of my&lt;br&gt;
verification ran with JS disabled. To prevent the next one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always view source on a critical route after a deploy.&lt;/strong&gt; Not
DevTools — that shows the hydrated DOM. The browser's "View Source"
shows what arrived on the wire. The two should differ in trivial
ways (hydration markers, attribute order); they should not differ
in &lt;em&gt;content&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl | grep&lt;/code&gt; your most important sentinel.&lt;/strong&gt; For a blog: a phrase
you know is in the body. For a product page: the price. For a
marketing page: the value prop. Make it a 10-second post-deploy
check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test once with JavaScript disabled.&lt;/strong&gt; It's a one-time check per
major template change. The first time a critical body of text is
missing from the JS-disabled page, you have the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For static sites, diff the page sizes you ship over time.&lt;/strong&gt; A
40 KB → 13 KB drop on a single route would have lit up. I had no
alert because I'd never measured a baseline.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For SvelteKit specifically, the pattern is one rule: anything that&lt;br&gt;
materializes data into the rendered DOM should be reactive or&lt;br&gt;
top-level, not in &lt;code&gt;onMount&lt;/code&gt;. &lt;code&gt;onMount&lt;/code&gt; is for browser-only side&lt;br&gt;
effects — DOM measurement, third-party widget init, anything that&lt;br&gt;
needs &lt;code&gt;window&lt;/code&gt;. As soon as you put content production in there, the&lt;br&gt;
prerender stops seeing it. The same shape exists in React (&lt;code&gt;useEffect&lt;/code&gt;),&lt;br&gt;
Vue (&lt;code&gt;onMounted&lt;/code&gt;), and every framework that distinguishes hydration&lt;br&gt;
from render.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this cost
&lt;/h2&gt;

&lt;p&gt;Two evenings to find it. Forty-five minutes to fix it. Three&lt;br&gt;
follow-up commits to deal with the secondary failures the fix exposed.&lt;/p&gt;

&lt;p&gt;The harder cost is the months of indexing where every post's body was&lt;br&gt;
empty. Google's view of those pages now has the title and the OG image&lt;br&gt;
and an empty &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. The dev.to mirrors of seven of the posts, which I&lt;br&gt;
published with &lt;code&gt;canonical_url&lt;/code&gt; pointing back to my site, were &lt;em&gt;more&lt;/em&gt;&lt;br&gt;
indexable than the originals.&lt;/p&gt;

&lt;p&gt;Search engines will recrawl. The mirrors will eventually catch up. But&lt;br&gt;
this is the kind of bug that doesn't reverse itself instantly — the&lt;br&gt;
right move after the fix is to submit a fresh sitemap, request reindex&lt;br&gt;
on the most important URLs, and wait.&lt;/p&gt;

&lt;p&gt;The win-condition I'm watching for is the same &lt;code&gt;curl | grep&lt;/code&gt; that&lt;br&gt;
revealed the bug, run against the production URL:&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="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://ferhatatagun.com/blog/four-tools-in-two-weekends &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    | grep -c "TL;&lt;/span&gt;DR&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero is the bug. One is the fix.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/how-i-shipped-a-blog-google-couldnt-see" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/how-i-shipped-a-blog-google-couldnt-see&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; — browser-only, BYOK, open-source Claude API dev tools&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>sveltekit</category>
      <category>webdev</category>
      <category>seo</category>
      <category>javascript</category>
    </item>
    <item>
      <title>See the prompt before you ship it</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Mon, 08 Jun 2026 11:29:39 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/see-the-prompt-before-you-ship-it-51ao</link>
      <guid>https://dev.to/ferhatatagun/see-the-prompt-before-you-ship-it-51ao</guid>
      <description>&lt;p&gt;The way most teams find out their prompt is too long is in the bill. The way most teams find out their prompt is approaching the context window is when the model starts dropping the system instructions. The way most teams find out their prompt-caching boundary is in the wrong place is by graphing a hit ratio that won't climb above 30%.&lt;/p&gt;

&lt;p&gt;All three of these are diagnosable in advance, in about four seconds, for free. The reason they keep happening is that the tools every Claude developer reaches for — chat playgrounds, IDE plugins, the official SDK — are &lt;em&gt;post-hoc&lt;/em&gt;. They show you what just happened. None of them shows you what your prompt looks like &lt;em&gt;before&lt;/em&gt; you press send.&lt;/p&gt;

&lt;p&gt;The other four tools I've shipped in this suite are all post-hoc too. &lt;a href="https://claudoscope-labs.vercel.app" rel="noopener noreferrer"&gt;claudoscope&lt;/a&gt; x-rays a finished response. &lt;a href="https://agentreplay.vercel.app" rel="noopener noreferrer"&gt;agent-replay&lt;/a&gt; scrubs a finished trace. &lt;a href="https://prompt-lab-promptly.vercel.app" rel="noopener noreferrer"&gt;prompt-lab&lt;/a&gt; compares two finished runs. &lt;a href="https://tool-lab-bice.vercel.app" rel="noopener noreferrer"&gt;tool-lab&lt;/a&gt; sandboxes the agent loop. They're all "look at what just happened" microscopes. None of them is a "look at what you're about to do" lens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://context-lens-sigma.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;context-lens&lt;/strong&gt;&lt;/a&gt; is. Paste a system prompt and a user message; see exactly how the API will count them, where in the 200K window you sit, where caching boundaries fall, and what each call will cost. The pre-flight check that turns a guess into a measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Token cost, context-window position, and prompt-caching layout are all knowable from the prompt alone — you don't need to send the request.&lt;/li&gt;
&lt;li&gt;Anthropic's &lt;code&gt;count_tokens&lt;/code&gt; endpoint gives you the exact number; a &lt;code&gt;~3.7 chars/token&lt;/code&gt; heuristic gives you a good-enough number while you type.&lt;/li&gt;
&lt;li&gt;The most useful single number is "tokens × calls/day × dollars/token" — once you can compute it before deploying, "ship this prompt" stops being an aesthetic call and becomes a budget call.&lt;/li&gt;
&lt;li&gt;A 4× difference in input length between two equivalent prompts is normal. Catching it before it goes to production saves more than the tool costs to build.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you can actually pre-flight
&lt;/h2&gt;

&lt;p&gt;Three things, all derivable from the prompt text alone:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Exact token count.&lt;/strong&gt; Not an estimate. Anthropic ships a &lt;code&gt;/v1/messages/count_tokens&lt;/code&gt; endpoint that takes the exact same shape as &lt;code&gt;/v1/messages&lt;/code&gt; (system, messages, tools) and returns just the &lt;code&gt;input_tokens&lt;/code&gt; number. Same tokenization as the actual API call would use. No model invocation, no output, no cost beyond a single tiny request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Position in the context window.&lt;/strong&gt; Sonnet 4.5 has a 200K-token window. Going past it doesn't error; the model silently drops the oldest content, which usually means dropping your system instructions, which usually means the model stops doing what you asked. The math is &lt;code&gt;(input + max_output) / 200_000&lt;/code&gt;. You should never see "78% of window" in production without knowing about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cost per call.&lt;/strong&gt; Multiply input tokens by input price (&lt;code&gt;$3/M&lt;/code&gt; on Sonnet), output tokens by output price (&lt;code&gt;$15/M&lt;/code&gt;), and you have one number for the cost of one call. Multiply by your traffic and you have the bill. The interesting move: do this &lt;em&gt;before&lt;/em&gt; you commit to a prompt design, not after.&lt;/p&gt;

&lt;p&gt;The fourth thing — where prompt-caching boundaries should sit — is harder to derive purely from text, but it's still pre-flight: you choose where to put &lt;code&gt;cache_control&lt;/code&gt; based on which prefix is &lt;em&gt;stable&lt;/em&gt; across your real traffic. context-lens won't choose for you, but it will show you the boundaries you've chosen so you can sanity-check them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-fold cost difference no one was looking for
&lt;/h2&gt;

&lt;p&gt;A real example, the worked-out kind. Two versions of the same agent system prompt:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Input tokens (counted)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Markdown headings, examples, long taxonomy, JSON schema embedded&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,847&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Single paragraph, schema implied by one example, no preamble&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;612&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model (Sonnet 4.5). Same user inputs (a code review task). The output was substantively equivalent on five real traffic samples — both caught the same critical bugs, both produced valid JSON, both came in under 800 output tokens.&lt;/p&gt;

&lt;p&gt;The cost differential is mechanical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A: &lt;code&gt;(3847 × 3 + 800 × 15) / 1_000_000&lt;/code&gt; = &lt;strong&gt;$0.0235&lt;/strong&gt; per call&lt;/li&gt;
&lt;li&gt;B: &lt;code&gt;(612 × 3 + 800 × 15) / 1_000_000&lt;/code&gt; = &lt;strong&gt;$0.0138&lt;/strong&gt; per call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 10,000 calls per day, that's &lt;strong&gt;$97/day saved&lt;/strong&gt;, or &lt;strong&gt;$3,000/month&lt;/strong&gt;. For a single prompt rewrite that took two hours to test in context-lens.&lt;/p&gt;

&lt;p&gt;The salient detail: I didn't &lt;em&gt;intend&lt;/em&gt; version B to be cheaper. I intended it to be more readable. The cost reduction was a side-effect that I would not have noticed without the pre-flight number, because both prompts felt "about the same length" to me in an editor. context-lens told me one was 6.3× the length of the other, in the only metric that matters: the metric the API uses.&lt;/p&gt;

&lt;p&gt;The lesson is that "feels about the same" is a uniformly bad estimator for token count, and you stop making the mistake the day you start measuring before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the heuristic mode exists
&lt;/h2&gt;

&lt;p&gt;context-lens does two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live as you type: a fast heuristic, roughly &lt;code&gt;3.7 chars/token&lt;/code&gt; for English-ish text, that updates with every keystroke. No API call, no key required, instant.&lt;/li&gt;
&lt;li&gt;On demand: a real API call to &lt;code&gt;count_tokens&lt;/code&gt; that gives you the exact number Anthropic will use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The heuristic isn't quite right — Turkish, code, and JSON all tokenize differently than English prose, sometimes by 30%. But it's a real-time signal while you iterate, which is more useful than an accurate-but-asynchronous one while you write. When you're ready to commit, you click the button and get the exact number. The two modes are intentional: one for the iteration phase, one for the verification phase.&lt;/p&gt;

&lt;p&gt;The pattern generalizes. Every place you have a fast-approximate metric and a slow-exact one, ship both, label them clearly, default to the fast one. The fast metric should never be wrong by more than ~30%; otherwise it's not a useful approximation. ~3.7 chars/token meets that bar for the languages context-lens has to handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about prompt caching
&lt;/h2&gt;

&lt;p&gt;Caching is the lever most teams underuse — and the one context-lens helps with most by surfacing where the boundaries are. Anthropic lets you mark any segment of your prompt as cacheable with &lt;code&gt;cache_control: { type: "ephemeral" }&lt;/code&gt;. The next 5 minutes, requests that share that exact prefix get the cached portion at &lt;strong&gt;10% of the input price&lt;/strong&gt;. The math flips: a 4,000-token system prompt that costs &lt;code&gt;$0.012&lt;/code&gt; per cold call costs &lt;code&gt;$0.0012&lt;/code&gt; per warm call. That's 10×.&lt;/p&gt;

&lt;p&gt;The catch: every byte before the &lt;code&gt;cache_control&lt;/code&gt; boundary must be identical. If you interpolate the user's name into the system prompt — gone. If your tool list reorders between requests — gone. If you append a timestamp — gone.&lt;/p&gt;

&lt;p&gt;context-lens shows you the structure you're sending. It doesn't auto-detect cacheability for you, but it does let you toggle "assume input is cache-read" and see what the cost would be if your caching worked. If &lt;code&gt;$0.012 → $0.0012&lt;/code&gt; is interesting at your traffic level, the path to verify it works is in &lt;a href="https://claudoscope-labs.vercel.app" rel="noopener noreferrer"&gt;claudoscope&lt;/a&gt;, which shows you the actual cache-read and cache-write breakdown on a live call. The two tools are complementary: context-lens predicts, claudoscope measures.&lt;/p&gt;

&lt;p&gt;I wrote a longer piece on the caching observability case in &lt;a href="https://ferhatatagun.com/blog/prompt-caching-nobody-measures" rel="noopener noreferrer"&gt;Prompt caching is the cheapest Claude optimization. Nobody measures it.&lt;/a&gt; if you want the full argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd recommend you do this week
&lt;/h2&gt;

&lt;p&gt;Three escalating moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Today (5 minutes):&lt;/strong&gt; Take whatever prompt your team is shipping right now. Paste it into context-lens with a representative user message. Note the token count. Now write a 1-paragraph version of the same prompt and paste that. If the count drops by 50% with no quality regression on three real inputs, you have a free production cost cut.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This sprint (an afternoon):&lt;/strong&gt; Add a pre-merge step to your prompt-change workflow: every PR that touches a prompt must include the context-lens token counts (before / after) in the description. Same energy as showing test results. If a PR triples your input tokens, that should be a conversation, not a stealth deploy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This quarter (a habit):&lt;/strong&gt; Track the prompt-cost-per-feature number across your product as a real metric. If feature X costs &lt;code&gt;$0.02/call&lt;/code&gt; and feature Y costs &lt;code&gt;$0.20/call&lt;/code&gt;, that's information you should know about before the bill teaches you. context-lens is the cheapest place to start collecting it — &lt;code&gt;count_tokens&lt;/code&gt; is free to call.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The economics of LLM apps in 2026 are not about model selection, mostly. They're about prompt design. Teams that can see their prompts before they ship them will out-compete teams that can't, on cost first and on quality second. The "see them" part is what's missing in most setups, and what context-lens is for.&lt;/p&gt;




&lt;p&gt;I shipped this in &lt;a href="https://context-lens-sigma.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;context-lens&lt;/strong&gt;&lt;/a&gt; — paste a Claude prompt, see what it costs before you ship. BYOK, no backend, runs in the browser. Source: &lt;a href="https://github.com/ferhatatagun/context-lens" rel="noopener noreferrer"&gt;github.com/ferhatatagun/context-lens&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same protocol-level approach also powers four sibling tools — &lt;a href="https://claudoscope-labs.vercel.app" rel="noopener noreferrer"&gt;claudoscope&lt;/a&gt;, &lt;a href="https://agentreplay.vercel.app" rel="noopener noreferrer"&gt;agent-replay&lt;/a&gt;, &lt;a href="https://prompt-lab-promptly.vercel.app" rel="noopener noreferrer"&gt;prompt-lab&lt;/a&gt;, &lt;a href="https://tool-lab-bice.vercel.app" rel="noopener noreferrer"&gt;tool-lab&lt;/a&gt;. All open source, all BYOK: &lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;ferhatatagun.com/tools&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/see-the-prompt-before-you-ship-it" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/see-the-prompt-before-you-ship-it&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; the post discusses — all browser-only, BYOK, open-source&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>anthropic</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What I learned shipping four open-source Claude dev-tools in two weekends</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Mon, 08 Jun 2026 11:29:08 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/what-i-learned-shipping-four-open-source-claude-dev-tools-in-two-weekends-1f4f</link>
      <guid>https://dev.to/ferhatatagun/what-i-learned-shipping-four-open-source-claude-dev-tools-in-two-weekends-1f4f</guid>
      <description>&lt;p&gt;About a month ago I tried to import the Anthropic SDK into a Next.js project and the bundler crashed. The fix was straightforward — talk to the Messages API directly, ~150 lines of TypeScript replacing the SDK — but the side-effect was that I now had a hand-rolled SSE client lying around, with all of Claude's streaming behaviour visible to me at the protocol level for the first time.&lt;/p&gt;

&lt;p&gt;That client became the seed of four small open-source tools, shipped over two weekends. Each one points a different microscope at the same protocol:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://claudoscope-labs.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;claudoscope&lt;/strong&gt;&lt;/a&gt; — live x-ray of token economics: input, cache write, cache read, output, all visible as the response streams.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://agentreplay.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;agent-replay&lt;/strong&gt;&lt;/a&gt; — paste a Claude agent trace, replay it step-by-step on a cinematic timeline.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://prompt-lab-promptly.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;prompt-lab&lt;/strong&gt;&lt;/a&gt; — run two prompts (or models) on the same input, side by side, with output/cost/latency compared.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tool-lab-bice.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;tool-lab&lt;/strong&gt;&lt;/a&gt; — define Claude tools in a JSON editor, type the mock responses by hand, watch the agent loop play out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four run only in your browser, BYOK, no backend, MIT-licensed. Together they're around 400 KB gzipped; the shared SSE client is the same file in all four repos. Five long-form posts on &lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;ferhatatagun.com/blog&lt;/a&gt; and Medium document the engineering decisions behind each one.&lt;/p&gt;

&lt;p&gt;The work is done — the more interesting question for me now is what shipping them in this shape, on this timeline, taught me about building developer tools in the AI-tooling era.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resistance from the official SDK ended up being the most generative constraint. Without the crash, I would never have written the parser, and without the parser, I would never have noticed how much the SDK hides.&lt;/li&gt;
&lt;li&gt;"One tool per insight" beats "one tool for everything." Each of the four tools makes exactly one thing visible. They compose because they don't try to.&lt;/li&gt;
&lt;li&gt;BYOK + browser-only is a credibility multiplier. The threshold for "I'll try this" drops dramatically when there's no account to make and no server to trust.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;&amp;lt;150-line&lt;/code&gt; shared protocol client across four projects is a more interesting reuse pattern than "extract into a library." It travels by copy-paste, but with intent.&lt;/li&gt;
&lt;li&gt;The articles are not promotion; they're scaffolding. Every tool needs a long-form artifact that explains &lt;em&gt;why&lt;/em&gt; it exists, not what it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The constraint that made the work possible
&lt;/h2&gt;

&lt;p&gt;If the Anthropic SDK had imported cleanly into my Next.js bundle, none of this exists. I would have used the SDK, never seen the SSE event stream, never realized that the four &lt;code&gt;usage&lt;/code&gt; fields are sitting there in every response, and shipped some boring product feature instead.&lt;/p&gt;

&lt;p&gt;What broke first was the bundler — &lt;code&gt;node:fs/promises&lt;/code&gt; from inside an agent-toolset module, deep in the SDK's transitive imports. The fix wasn't subtle: don't use the SDK. Talk to &lt;code&gt;api.anthropic.com&lt;/code&gt; directly with &lt;code&gt;fetch&lt;/code&gt;. Add the &lt;code&gt;anthropic-dangerous-direct-browser-access&lt;/code&gt; header. Parse the SSE stream by hand. About 150 lines.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't the parser — it was what I saw &lt;em&gt;because&lt;/em&gt; of the parser. I'd been calling Claude for months without ever noticing that &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; were distinct fields. I'd never looked at the granular order of &lt;code&gt;content_block_delta&lt;/code&gt; events. I'd never noticed that &lt;code&gt;tool_use&lt;/code&gt; inputs arrive as partial-JSON deltas you have to accumulate. The SDK had been doing me a favor by hiding this stuff, and I'd been doing my apps a disservice by letting it.&lt;/p&gt;

&lt;p&gt;The lesson, restated: when an SDK fights you, the fight is the gift. The work to bypass it gives you ground-truth visibility you'd never have bought yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  One tool, one thing it makes visible
&lt;/h2&gt;

&lt;p&gt;The temptation, once I had the SSE parser, was to build "a Claude developer dashboard" — one tool that did everything. I almost did. The reason I didn't is that the most useful diagnostic tools I've ever used (Wireshark, Chrome DevTools' specific panels, the React Profiler) all share a property: each panel makes &lt;em&gt;exactly one thing&lt;/em&gt; visible in a way no other tool does.&lt;/p&gt;

&lt;p&gt;So I broke the work into four:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Makes visible&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claudoscope&lt;/td&gt;
&lt;td&gt;The four &lt;code&gt;usage&lt;/code&gt; fields, live, as cost in dollars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent-replay&lt;/td&gt;
&lt;td&gt;The decision sequence inside a &lt;code&gt;messages&lt;/code&gt; array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prompt-lab&lt;/td&gt;
&lt;td&gt;The latency/cost/output diff between two variants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tool-lab&lt;/td&gt;
&lt;td&gt;What the model actually does with your tool schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each is a small surface area. None of them does the other three's job. They're all the same shape — paste-some-JSON, watch-some-output, see-the-thing — but the "thing" is intentionally different in each.&lt;/p&gt;

&lt;p&gt;This decomposition cost me something: I have four landing pages to maintain, four READMEs, four sets of cross-links. But it bought me an asymmetric thing: a clear pitch per tool. "X-ray a Claude API call" is easier to share than "an all-in-one Claude developer console." On a Show HN front page or a Twitter timeline, the small specific claim wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  BYOK + browser-only as a trust multiplier
&lt;/h2&gt;

&lt;p&gt;The first version of each tool, in my head, had a backend. A small Node service, an API key kept server-side, maybe a rate limiter. I started building the first one this way, then stopped at the deploy step and asked: why am I making the user trust me with their key?&lt;/p&gt;

&lt;p&gt;There is no good answer. For a developer tool that the user is going to use for ten minutes to debug their own work, no backend is necessary. Their key, their requests, their data. The browser is the right runtime; &lt;code&gt;localStorage&lt;/code&gt; is the right persistence layer; "nothing leaves your tab" is the right privacy guarantee.&lt;/p&gt;

&lt;p&gt;What this changed: the "try it" threshold collapsed. No account creation. No OAuth dance. No "should I trust this site with my key?" hesitation. Open the URL, paste a key, hit send. The tool is yours in under thirty seconds. The Anthropic header named &lt;code&gt;anthropic-dangerous-direct-browser-access&lt;/code&gt; was clearly built for exactly this kind of usage — a developer wants to look at the protocol directly, on their own machine, with their own credentials.&lt;/p&gt;

&lt;p&gt;The flip side: this design only works for &lt;em&gt;developer tools used by their own creator&lt;/em&gt;. A production app that ships keys to users would still need a backend. But for the diagnostic case, BYOK + browser-only is the right architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 150-line client, copied four times
&lt;/h2&gt;

&lt;p&gt;The shared SSE streaming client is &lt;code&gt;src/lib/anthropic.ts&lt;/code&gt; in all four repos. Same file. Same 150ish lines. I considered extracting it to an npm package — &lt;code&gt;@ferhatatagun/claude-fetch&lt;/code&gt; or similar — and decided against it three times.&lt;/p&gt;

&lt;p&gt;The case against extraction is intuitive once you've worked at scale: a shared library across four tools creates a fan-out problem. A breaking change in the library breaks all four; a non-breaking change requires version-pinning logic; a hotfix requires four PR's to deploy. Meanwhile the four tools are &lt;em&gt;small enough that the file is reviewable in five minutes&lt;/em&gt;. There's nothing to abstract over.&lt;/p&gt;

&lt;p&gt;What I do instead: the file at the top of &lt;code&gt;src/lib/anthropic.ts&lt;/code&gt; in each repo says, in a comment, where it was last synced from. When I improve the parser in one tool, I diff the file across the four repos and reconcile. It takes minutes, not hours, and the four tools stay in sync without the ceremony of a published package.&lt;/p&gt;

&lt;p&gt;This isn't a universal pattern — for ten projects it would break down, for a hundred it's clearly wrong. But for four tools shipped by one person on weekends, it's strictly better than the npm-and-versioning alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The articles aren't marketing — they're scaffolding
&lt;/h2&gt;

&lt;p&gt;Each of the four tools has a long-form post that explains why it exists. claudoscope has two (one on the streaming client itself, one on cache observability). prompt-lab, tool-lab, and agent-replay each have one. There are also five matching Turkish translations on ferhatatagun.com.&lt;/p&gt;

&lt;p&gt;These posts are not promotion in the marketing sense. I'm not optimizing them for SEO and I'm not pumping them on LinkedIn for impressions. (OK, I'm pumping them on LinkedIn a little. But that's not the point.)&lt;/p&gt;

&lt;p&gt;The point is: a tool that does one specific thing benefits massively from an artifact that explains &lt;em&gt;why&lt;/em&gt; that specific thing is worth doing. "Here's a tool to A/B test Claude prompts" is a less convincing pitch than "you're choosing prompts by vibes; here's what side-by-side reveals that sequential doesn't, with a worked example, and a tool for it." The article does the persuasion; the tool catches the convinced reader.&lt;/p&gt;

&lt;p&gt;Without the writing, the tools look like toys. With the writing, they look like the natural conclusion of an argument. The two work as a pair.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A handful of small things I'd front-load if I were starting over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Demo mode from day one.&lt;/strong&gt; I added &lt;code&gt;?demo=1&lt;/code&gt; to three of the four tools as an afterthought. It's the single highest-conversion feature — users who land on a tool and don't have a key still need something to look at, or they bounce. Should have been there at first commit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Per-tool OG cards.&lt;/strong&gt; I shipped each tool with a generic OG image and went back two days later to make per-tool 1200×630 cards in the right brand color. The first two days of traffic that came in via shared links looked generic. Should have been there at launch.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-linking inside the tools.&lt;/strong&gt; Each tool's footer points to the other three. I added this in the second weekend. The first weekend, every tool was a silo, and visitors discovered them one at a time. Should have been baked into the template.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A "what's in this for me" line on the landing page.&lt;/strong&gt; I had four hero descriptions like "see what Claude is doing." Better: "see prompt caching save you 90% of your bill, live, as you debug." Specific outcome &amp;gt; vague capability. I corrected this in the second pass.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are large fixes. They're all things that, if you've ever shipped a small developer tool, you already know. Knowing and remembering at the moment of shipping are different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Whatever the API surface adds next, the same pattern applies: ship a small visualizer for it the day it lands. Anthropic shipped MCP, batch, files, computer-use, and citations over the last year, and most of them still don't have great developer-side observability tools. Each one is a 200-300 line tool waiting to be built.&lt;/p&gt;

&lt;p&gt;For now, the four-tool suite is at a natural stopping point. The work I'm interested in now is around adoption — making it visible enough that the people who need these tools can find them. If you've read this far and one of the four sounds like it would have saved you time last week, take it for a spin and let me know what's missing.&lt;/p&gt;




&lt;p&gt;All four tools: &lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;ferhatatagun.com/tools&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Source on &lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;github.com/ferhatatagun&lt;/a&gt;. MIT, BYOK, no backend.&lt;/p&gt;

&lt;p&gt;Articles on each one: &lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;ferhatatagun.com/blog&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/four-tools-in-two-weekends" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/four-tools-in-two-weekends&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; the post discusses — all browser-only, BYOK, open-source&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>anthropic</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I debug Claude agents by replaying their trace</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Mon, 08 Jun 2026 11:28:37 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/how-i-debug-claude-agents-by-replaying-their-trace-484</link>
      <guid>https://dev.to/ferhatatagun/how-i-debug-claude-agents-by-replaying-their-trace-484</guid>
      <description>&lt;p&gt;Your agent did something weird in production. A user reported it, you found the failed run in your logs, and you're now staring at a JSON file that's 400 messages long, half of them are &lt;code&gt;tool_result&lt;/code&gt; blocks the size of small databases, and somewhere in there is the moment the agent decided to do the wrong thing.&lt;/p&gt;

&lt;p&gt;You can't re-run the agent: the API state has moved on, the tool would behave differently now, the prompt has been updated three times since. You have only the trace.&lt;/p&gt;

&lt;p&gt;The way most of us read agent traces is: open the JSON in an editor, ctrl+F for the tool name we suspect, scroll through walls of escaped strings, try to mentally reconstruct the sequence. It takes thirty minutes, by the end of which you have one of three answers — "yeah I see what went wrong," "I'm pretty sure I see what went wrong," or "I have no idea what went wrong." About a third of the time it's the third one, and you go ship a band-aid that may or may not fix the actual problem.&lt;/p&gt;

&lt;p&gt;The thing nobody talks about is that this isn't a hard problem. The JSON contains all the information. The issue is purely &lt;em&gt;presentational&lt;/em&gt; — it's nearly impossible to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent traces are a sequence of decisions but stored as a wall of nested JSON. The signal is there; the format is the problem.&lt;/li&gt;
&lt;li&gt;The right primitive isn't a JSON viewer — it's a timeline. Each thought, tool call, tool result, and final answer becomes its own discrete, color-coded step.&lt;/li&gt;
&lt;li&gt;Once you can scrub through the trace step by step, the failure point becomes visually obvious in seconds instead of minutes.&lt;/li&gt;
&lt;li&gt;This is post-hoc, not interactive. You don't need to re-run the agent or hit the API — replay works on the raw trace alone.&lt;/li&gt;
&lt;li&gt;A browser-only tool can do this in 4 seconds. No backend, no key, just paste the JSON.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What an agent trace actually contains
&lt;/h2&gt;

&lt;p&gt;When you save a Claude agent run, you usually persist the &lt;code&gt;messages&lt;/code&gt; array — the full conversation including the model's responses and the tool results you fed back. A six-step agent run looks roughly like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Find me the cheapest flight from IST to LAX next Tuesday"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'll search for flights and check prices."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool_use"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tu_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search_flights"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="c1"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool_result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tool_use_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tu_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[&amp;lt;2KB of JSON&amp;gt;]"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Looking at three of those..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tool_use"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tu_02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_price"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="c1"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="c1"&gt;// ...four more steps...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every interesting moment of the agent's behaviour is in there: which tool it picked, what arguments it constructed, what it said about its own reasoning, how it interpreted the result. The structure is fundamentally a &lt;strong&gt;sequence of discrete events&lt;/strong&gt;, not a "document."&lt;/p&gt;

&lt;p&gt;But you read it as a document, because that's what an editor shows you. The brain has to do the work of converting "alternating role: assistant / role: user with tool_result content blocks" into "step 3 was a tool call to get_price with argument X, which returned Y, which the agent then interpreted as Z."&lt;/p&gt;

&lt;p&gt;That conversion is what kills your debugging time. Doing it manually for a 12-step trace takes minutes. Doing it for a 60-step agent on a complex task takes hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The right primitive: a timeline of decisions
&lt;/h2&gt;

&lt;p&gt;The reframe is: stop reading the trace as JSON, start watching it as a sequence of decisions. Each step is one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;💭 &lt;strong&gt;Thought&lt;/strong&gt; — the model wrote text (the part of its response that isn't a tool call)&lt;/li&gt;
&lt;li&gt;🔧 &lt;strong&gt;Tool call&lt;/strong&gt; — the model invoked a tool with specific arguments&lt;/li&gt;
&lt;li&gt;📥 &lt;strong&gt;Tool result&lt;/strong&gt; — what came back, fed into the next turn&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Final answer&lt;/strong&gt; — the model's &lt;code&gt;end_turn&lt;/code&gt;, no more tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Color-code those four event types. Lay them out in order, one card per event. You now have a timeline you can scrub, step through, and play back. The information density per card is high enough that you can read the entire trace at a glance, and zoom in only on the cards that look suspicious.&lt;/p&gt;

&lt;p&gt;The structural insight: agent debugging is closer to debugging a script with breakpoints than to reading source code. You want to step through, not skim. JSON gives you no steps; the timeline gives you nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs that become obvious in this view
&lt;/h2&gt;

&lt;p&gt;Three failure modes I see repeatedly when I drop a trace into the timeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The wrong tool, picked silently.&lt;/strong&gt; The model called &lt;code&gt;search_archive&lt;/code&gt; when it should have called &lt;code&gt;search_recent&lt;/code&gt;. In JSON this is one line out of 200 that flies past your eye. In the timeline it's a card with a tool name you didn't expect, and you click on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Hallucinated arguments.&lt;/strong&gt; The model called the right tool but with an argument shape that doesn't match the schema — usually because the schema is ambiguous. In JSON you see &lt;code&gt;{"q": "foo", "limit": "10"}&lt;/code&gt; and don't notice that &lt;code&gt;limit&lt;/code&gt; should have been an integer. In the timeline the tool result card right after shows a 400 error and you trace it back one step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The infinite loop precursor.&lt;/strong&gt; Some agents get stuck in a pattern where they keep calling the same tool with slightly different inputs, never reaching a conclusion. In JSON it's a wall of near-identical blocks. In the timeline it's a visual rhythm — five purple cards in a row with the same tool name — that you can see in your peripheral vision the moment you scroll.&lt;/p&gt;

&lt;p&gt;In all three cases, the bug isn't subtle. It just &lt;em&gt;looks&lt;/em&gt; subtle when it's hidden in JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replay gives you that re-running doesn't
&lt;/h2&gt;

&lt;p&gt;The temptation when an agent fails is to re-run it with print statements, see what happens, iterate. Don't. Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs API calls.&lt;/strong&gt; A failed agent that called 15 tools costs you 15× input tokens to re-run. With caching maybe less; either way, the bill is real. Replay is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API state has moved.&lt;/strong&gt; The tool you call today might return different data than the tool returned during the original run. You're not debugging the original failure anymore; you're debugging &lt;em&gt;whatever happens now&lt;/em&gt;, which might be a totally different bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model is stochastic.&lt;/strong&gt; Even at temperature 0, retries can produce different outputs. Re-running an agent and getting a &lt;em&gt;different&lt;/em&gt; failure mode means you've now got two bugs to investigate. The trace is the canonical artifact of what actually happened.&lt;/p&gt;

&lt;p&gt;Replay sidesteps all three. You're inspecting a frozen artifact, deterministically, at whatever speed you want. The bug doesn't move while you're looking at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in agent-replay
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://agentreplay.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;agent-replay&lt;/strong&gt;&lt;/a&gt; is the tool I built for this. Paste your trace into a JSON pane on the left. The right pane renders it as a cinematic timeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each event is a card with an icon and color&lt;/li&gt;
&lt;li&gt;You can press space to play through the trace at 1× speed (one event per second), or scrub manually&lt;/li&gt;
&lt;li&gt;Click any card to see the full content — the thought text, the tool call's input JSON, the raw tool result, expanded&lt;/li&gt;
&lt;li&gt;Filter by event type — "show me only the tool calls" or "show me only the assistant thoughts" — when you want to focus&lt;/li&gt;
&lt;li&gt;The whole thing is in your browser; no key needed, no backend, your trace never leaves the tab&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a sample trace seeded on &lt;code&gt;?demo=1&lt;/code&gt; if you want to see what a 12-step agent looks like without copying your own data anywhere.&lt;/p&gt;

&lt;p&gt;The thing I keep finding: the moment I'm debugging is no longer "where in the JSON did the agent screw up." It's "which card looks wrong, and what does the next card show as a consequence." A 30-minute investigation becomes a 30-second one. Not because the tool is doing anything clever — it's just showing the same data in the right shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd recommend you do this week
&lt;/h2&gt;

&lt;p&gt;Three escalating moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Today (5 minutes):&lt;/strong&gt; Find the last weird agent run you have a trace for. Paste it into agent-replay. See how long it takes to find the failure point. If it's faster than your usual JSON-scrolling approach, you just changed your debugging workflow.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This week (an afternoon):&lt;/strong&gt; Add a trace-export endpoint to your agent. Every agent run, finished or failed, dumps the &lt;code&gt;messages&lt;/code&gt; array to S3 or a database row. You need the trace before you need to debug it, not after.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This quarter (a habit):&lt;/strong&gt; When a user reports "the agent did something weird," your first move is to pull the trace and open it in a timeline view, &lt;em&gt;before&lt;/em&gt; you read the user's report carefully. Most of the time you'll know what happened before you finish reading the bug report.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Agent debugging is presented as an emerging engineering discipline. It isn't — it's a tooling problem we've solved many times before for non-AI systems. We just haven't built the tools yet for this one. Once the trace is in the right shape, the bugs are obvious. The work is laying out the data, not interpreting it.&lt;/p&gt;




&lt;p&gt;I shipped this in &lt;a href="https://agentreplay.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;agent-replay&lt;/strong&gt;&lt;/a&gt; — paste a trace, scrub the timeline. No key, no backend, runs in the browser. Source: &lt;a href="https://github.com/ferhatatagun/agent-replay" rel="noopener noreferrer"&gt;github.com/ferhatatagun/agent-replay&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same SSE client (for traces that include streaming events) also powers three sibling tools — &lt;a href="https://claudoscope-labs.vercel.app" rel="noopener noreferrer"&gt;claudoscope&lt;/a&gt;, &lt;a href="https://prompt-lab-promptly.vercel.app" rel="noopener noreferrer"&gt;prompt-lab&lt;/a&gt;, &lt;a href="https://tool-lab-bice.vercel.app" rel="noopener noreferrer"&gt;tool-lab&lt;/a&gt;. All open-source, all BYOK: &lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;ferhatatagun.com/tools&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/debug-claude-agents-by-replaying-traces" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/debug-claude-agents-by-replaying-traces&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; the post discusses — all browser-only, BYOK, open-source&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>anthropic</category>
      <category>agents</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Build the sandbox before you write a single tool</title>
      <dc:creator>Ferhat Atagün</dc:creator>
      <pubDate>Mon, 08 Jun 2026 11:28:05 +0000</pubDate>
      <link>https://dev.to/ferhatatagun/build-the-sandbox-before-you-write-a-single-tool-2ja3</link>
      <guid>https://dev.to/ferhatatagun/build-the-sandbox-before-you-write-a-single-tool-2ja3</guid>
      <description>&lt;p&gt;The first time you ship a Claude agent that uses tools you'll do it the obvious way: design the schema, write the actual tool function, hit the API, parse the &lt;code&gt;tool_use&lt;/code&gt; block, run the function, feed the result back, loop. It works. It also has a fundamental ordering bug:&lt;/p&gt;

&lt;p&gt;You wrote the tools before you knew if they were the right tools.&lt;/p&gt;

&lt;p&gt;By the time you've stood up a database query function, two API calls, and a thing that hits the file system, you've sunk maybe a day. You run the agent. It calls a non-existent tool. It hallucinates an argument shape that doesn't match your schema. It picks the wrong tool when both would have worked. &lt;em&gt;Now&lt;/em&gt; you're going to redesign the schema, and the four real tool implementations you wrote are going in the bin or being rewritten.&lt;/p&gt;

&lt;p&gt;The thing that makes this worse is that the failure mode looks like an "agent quality" problem when it's actually a "premature implementation" problem. The model knew what it wanted; you'd just built the wrong scaffolding around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool implementations are the slowest part of agent development; tool &lt;em&gt;design&lt;/em&gt; is the fastest part to get wrong.&lt;/li&gt;
&lt;li&gt;Decouple them: write the tool schemas, run the agent loop with mocked responses, see how the model picks and uses the tools — then write the real implementations only for the tools that survived.&lt;/li&gt;
&lt;li&gt;The right mental model is "you play the role of every tool, by hand" — slow for the agent, fast for you, brutal for bad designs.&lt;/li&gt;
&lt;li&gt;This is a fifteen-minute exercise for a five-tool agent that would otherwise take a day, and it catches design mistakes before they touch your codebase.&lt;/li&gt;
&lt;li&gt;The whole thing fits in a browser tool with no backend.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What "premature implementation" actually looks like
&lt;/h2&gt;

&lt;p&gt;A worked example. I was building a code review agent. My first instinct was four tools:&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;tools&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="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="s2"&gt;read_file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read a file from the repo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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="s2"&gt;search_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grep across the repo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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="s2"&gt;get_diff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;show the diff for this PR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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="s2"&gt;post_comment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;leave a review comment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I implemented all four. Real filesystem access. Real git invocation. Real GitHub API call. Probably four hours total. Then I ran the agent on a real PR.&lt;/p&gt;

&lt;p&gt;What happened: the agent called &lt;code&gt;get_diff&lt;/code&gt; first (good), then called &lt;code&gt;search_code&lt;/code&gt; for every single identifier in the diff (catastrophic — the diff had 200 lines, 50 unique identifiers, my rate limit ran out). It never called &lt;code&gt;read_file&lt;/code&gt; because the diff already contained the context. It called &lt;code&gt;post_comment&lt;/code&gt; once at the end with a 4,000-word essay instead of inline comments.&lt;/p&gt;

&lt;p&gt;Three of my four "real" tools were either misused or unused. The agent design was wrong, not the implementations. If I'd run the loop with mocked responses first, I would have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Noticed it called &lt;code&gt;search_code&lt;/code&gt; 50 times → split the tool into &lt;code&gt;search_code(query, limit=5)&lt;/code&gt; with an explicit budget&lt;/li&gt;
&lt;li&gt;Noticed it never used &lt;code&gt;read_file&lt;/code&gt; → deleted it, saved myself an hour&lt;/li&gt;
&lt;li&gt;Noticed &lt;code&gt;post_comment&lt;/code&gt; was being used as &lt;code&gt;post_essay&lt;/code&gt; → split into &lt;code&gt;post_inline_comment(line, body)&lt;/code&gt; and &lt;code&gt;post_summary(body)&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That intervention takes fifteen minutes when the tools are mocked. It takes a day when they're real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The role-play pattern
&lt;/h2&gt;

&lt;p&gt;The trick is shockingly simple: write your tool schemas, send a real user message to Claude, and when the model produces a &lt;code&gt;tool_use&lt;/code&gt; block, &lt;em&gt;you&lt;/em&gt; hand-type the result and feed it back. The loop runs end-to-end, but you're playing every tool.&lt;/p&gt;

&lt;p&gt;In code, this is the same agent loop everyone writes:&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;while &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;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="nf"&gt;callClaude&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end_turn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&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;toolUses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="s2"&gt;tool_use&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolResults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toolUses&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;t&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool_result&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tool_use_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&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="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PROMPT_USER_FOR_RESULT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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;t&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;// &amp;lt;-- you fill this in&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;push&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="s2"&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="nx"&gt;res&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="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="nx"&gt;toolResults&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 only difference between this and a "real" agent loop is the &lt;code&gt;PROMPT_USER_FOR_RESULT&lt;/code&gt; call — instead of executing a function, it shows you what the model called and what arguments it used, and waits for you to type the answer.&lt;/p&gt;

&lt;p&gt;What that produces is surprisingly information-dense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Did the model pick the tool I expected?&lt;/strong&gt; If it took a different path you didn't anticipate, your schema is signaling something other than what you meant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did the input shape match my JSON schema?&lt;/strong&gt; If the model is straining to fit the schema, the schema is too rigid or too loose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many tools did it chain?&lt;/strong&gt; A 12-step tool chain to answer one question is a sign you decomposed the toolset wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did it ask follow-up questions before tool use?&lt;/strong&gt; That's good — it means the model is trying to disambiguate. If it doesn't, your prompt isn't asking it to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You see all of this in a five-minute conversation, before you've written a single line of real implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you can stop role-playing
&lt;/h2&gt;

&lt;p&gt;The sandbox isn't a permanent state. It's a phase. You run it until you've answered three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Are these the right tools?&lt;/strong&gt; — Some get deleted, some get split, some get merged. Usually 30-50% of your initial toolset doesn't survive contact with a real prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are the schemas tight enough?&lt;/strong&gt; — You see the model picking awkward argument values; you constrain the schema (enum instead of string, required instead of optional). &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the agent loop terminate?&lt;/strong&gt; — Some agents will keep calling tools forever if their stopping criteria are vague. The mock-response loop surfaces this immediately because &lt;em&gt;you're&lt;/em&gt; the one getting stuck typing responses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When those three are stable on a handful of real prompts, you write the real implementations. The implementation work is now de-risked: you know which tools to actually build, and the schemas are settled.&lt;/p&gt;

&lt;p&gt;The thing you save isn't the implementation time itself — it's the rework. Writing a tool from scratch is fast. Rewriting a tool because its schema was wrong, then updating the prompt because the new schema needs different framing, then re-running every regression input, is what eats days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in tool-lab
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tool-lab-bice.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;tool-lab&lt;/strong&gt;&lt;/a&gt; is what I built to do this without setting up a project each time. Three panes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;┌─&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Tools&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;editor)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;─────────┬─&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Conversation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;────────────────────┐&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;                             &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;user:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;review&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;PR&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read_file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;assistant:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;I'll&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;get&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;diff.&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search_code"&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tool_use:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;get_diff()&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_diff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;←&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tool_result:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;YOU&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;TYPE&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"post_comment"&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;assistant:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;                   &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;                             &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;                                    &lt;/span&gt;&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;└───────────────────────────────┴───────────────────────────────────┘&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You paste your tool schemas on the left. Type the user message. The model streams its response into the right pane. When it lands a &lt;code&gt;tool_use&lt;/code&gt; block, the conversation pauses with a text field for the result. You type whatever the tool would have returned — JSON, a string, an error, whatever. Hit continue. The loop runs again with your fake result included.&lt;/p&gt;

&lt;p&gt;It's about 12KB of relevant logic on top of the shared SSE client I wrote about &lt;a href="https://ferhatatagun.com/blog/browser-only-claude-streaming" rel="noopener noreferrer"&gt;here&lt;/a&gt;. BYOK, no backend, your tool schemas and conversations live in &lt;code&gt;localStorage&lt;/code&gt; only. There's a demo conversation seeded on &lt;code&gt;?demo=1&lt;/code&gt; so you can see the loop run without writing tools yourself.&lt;/p&gt;

&lt;p&gt;The thing I keep noticing: the tool-lab session for any new agent takes ten to twenty minutes. The agent design that comes out of it is consistently 30-50% smaller than what I would have written from intuition. Smaller agents with fewer, more focused tools are also dramatically easier to reason about when they go wrong in production — which is the other dividend of doing the sandbox phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd recommend you do this week
&lt;/h2&gt;

&lt;p&gt;Three escalating moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Today (10 minutes):&lt;/strong&gt; Pick an agent you're already building. Paste its tool schemas into tool-lab, send a real user message, see what happens. If the agent picks the wrong tools or uses the right ones in surprising ways, you've just learned something.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This sprint (an afternoon):&lt;/strong&gt; Make "sandbox before implementation" the default for new agents on your team. Stand up the tool schemas first, role-play five representative prompts, then write the implementations only for tools that survived. Track the count: how many initial tools made it through.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This quarter (a habit):&lt;/strong&gt; When something goes wrong with an agent in production — wrong tool picked, weird argument shape, infinite loop — drop the trace into the sandbox before debugging the implementation. The bug is often in the design, not the code.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tool implementations are not the hard part of agent development. &lt;em&gt;Tool design&lt;/em&gt; is. The thing that separates teams that ship reliable agents from teams that ship agents that "mostly work" isn't the quality of their tool functions; it's how many bad tool designs they killed before writing the function.&lt;/p&gt;

&lt;p&gt;You don't need a framework for this. You don't need a vendor. You need fifteen minutes and a willingness to play the role of every tool, by hand, until you know which ones deserve to be real.&lt;/p&gt;




&lt;p&gt;I shipped this in &lt;a href="https://tool-lab-bice.vercel.app" rel="noopener noreferrer"&gt;&lt;strong&gt;tool-lab&lt;/strong&gt;&lt;/a&gt; — define tools, mock responses, watch the agent loop. BYOK, no backend, runs in the browser. Source: &lt;a href="https://github.com/ferhatatagun/tool-lab" rel="noopener noreferrer"&gt;github.com/ferhatatagun/tool-lab&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same SSE client also powers three sibling tools — &lt;a href="https://claudoscope-labs.vercel.app" rel="noopener noreferrer"&gt;claudoscope&lt;/a&gt;, &lt;a href="https://agentreplay.vercel.app" rel="noopener noreferrer"&gt;agent-replay&lt;/a&gt;, &lt;a href="https://prompt-lab-promptly.vercel.app" rel="noopener noreferrer"&gt;prompt-lab&lt;/a&gt;. All open-source, all BYOK: &lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;ferhatatagun.com/tools&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is mirrored from &lt;a href="https://ferhatatagun.com/blog/build-the-sandbox-first" rel="noopener noreferrer"&gt;ferhatatagun.com/blog/build-the-sandbox-first&lt;/a&gt; — that's the canonical URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More from the same place:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/blog" rel="noopener noreferrer"&gt;Long-form blog&lt;/a&gt; — AI, LLM tooling, frontend at the model boundary&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ferhatatagun.com/tools" rel="noopener noreferrer"&gt;The five tools&lt;/a&gt; the post discusses — all browser-only, BYOK, open-source&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ferhatatagun" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://x.com/ferhatatagun" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ferhatatagun" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss here or on the canonical post — both threads stay open.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>anthropic</category>
      <category>agents</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
