<?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: Yatin Davra</title>
    <description>The latest articles on DEV Community by Yatin Davra (@yatindavra).</description>
    <link>https://dev.to/yatindavra</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%2F1224164%2F2746758e-8b79-4cab-9c0c-fd4056490f6f.png</url>
      <title>DEV Community: Yatin Davra</title>
      <link>https://dev.to/yatindavra</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yatindavra"/>
    <language>en</language>
    <item>
      <title>Past the README Demo: Conversations, Healthcare Data, Agents, and CI Checks</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Thu, 27 Aug 2026 06:17:13 +0000</pubDate>
      <link>https://dev.to/yatindavra/past-the-readme-demo-conversations-healthcare-data-agents-and-ci-checks-517p</link>
      <guid>https://dev.to/yatindavra/past-the-readme-demo-conversations-healthcare-data-agents-and-ci-checks-517p</guid>
      <description>&lt;p&gt;"Extract a name and email from this sentence" is the easy 10% of structured output. The other 90% is everything that doesn't fit in one prompt, one turn, or one model call. Here are five things &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;shapecraft&lt;/a&gt; handles once you're past the basics.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Collecting data across a whole conversation
&lt;/h2&gt;

&lt;p&gt;A single message rarely has everything you need. Someone books an appointment over three or four back-and-forth messages, not one. &lt;code&gt;turnaround&lt;/code&gt; mode lets the conversation run naturally and validates the &lt;em&gt;whole transcript&lt;/em&gt; once, at the end, against one schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BookingSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;conversationHistory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;turnaround&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No manual "do I have everything yet?" tracking, no partial-state bugs, just one validated object once the conversation is actually complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Extracting from clinical notes into real FHIR shapes
&lt;/h2&gt;

&lt;p&gt;Healthcare data has a standard (FHIR R4) and it's not optional if you're integrating with anything real. Built-in presets mean you're not hand-writing a &lt;code&gt;Patient&lt;/code&gt; or &lt;code&gt;Observation&lt;/code&gt; schema from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft/fhir&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;PatientSchema&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft/fhir&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;patient&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;PatientSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clinicalNote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same retry/validation guarantees as any other schema, just pre-built to match a spec you'd otherwise have to implement yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. An agent that checks real data before answering
&lt;/h2&gt;

&lt;p&gt;"Is this order still on hold?" isn't answerable from the prompt alone, it needs an actual lookup. &lt;code&gt;generateWithTools()&lt;/code&gt; lets the model call your functions, see the results, and then produce a validated final answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateWithTools&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;result&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;generateWithTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lookupOrder&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;AnswerSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userQuestion&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool call's arguments are validated before your function ever runs, and the final answer goes through the same schema check as a normal &lt;code&gt;generate()&lt;/code&gt; call.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Chaining validated steps into a pipeline
&lt;/h2&gt;

&lt;p&gt;Triage, then diagnose, then draft a response, each step needs the &lt;em&gt;previous&lt;/em&gt; step's validated output, not its raw text. &lt;code&gt;runAgents()&lt;/code&gt; chains a sequence of &lt;code&gt;generate()&lt;/code&gt; calls, each with its own model and schema, threading validated data forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runAgents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft/agentic&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;triage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TriageSchema&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;triage&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;diagnose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DiagnosisSchema&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;diagnose&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;result&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;runAgents&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diagnose&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step keeps its own retry behavior, there's no shared fragile state between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Checking a JSON file against a schema, no code required
&lt;/h2&gt;

&lt;p&gt;Sometimes the thing you need to validate isn't fresh model output, it's a file that already exists, maybe from a batch job, a CI pipeline, or someone else's export. The CLI does the same structural check &lt;code&gt;generate()&lt;/code&gt; uses internally, from the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shapecraft validate &lt;span class="nt"&gt;--schema&lt;/span&gt; schema.json &lt;span class="nt"&gt;--output&lt;/span&gt; output.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exits &lt;code&gt;0&lt;/code&gt; and prints a check mark on success, exits &lt;code&gt;1&lt;/code&gt; with the specific violation on failure. Handy for a CI step that shouldn't need its own validation script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath all of it
&lt;/h2&gt;

&lt;p&gt;Multi-turn, healthcare presets, tool calling, agent pipelines, file validation, none of these are separate systems bolted on. They're all the same &lt;code&gt;generate()&lt;/code&gt; retry-and-validate core, pointed at a different shape of input or output.&lt;/p&gt;

&lt;p&gt;We've published full documentation covering all of this (and everything from the last post too) at &lt;strong&gt;&lt;a href="https://aviasoletechnologies.github.io/shapecraft/" rel="noopener noreferrer"&gt;aviasoletechnologies.github.io/shapecraft&lt;/a&gt;&lt;/strong&gt; - worth a look if any of these five sound like your actual problem.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>5 Things I've Actually Used shapecraft For</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Wed, 19 Aug 2026 04:55:38 +0000</pubDate>
      <link>https://dev.to/yatindavra/5-things-ive-actually-used-shapecraft-for-5726</link>
      <guid>https://dev.to/yatindavra/5-things-ive-actually-used-shapecraft-for-5726</guid>
      <description>&lt;p&gt;Every structured-output library's README shows you the same demo: extract a name and age from a sentence, get back clean JSON. Cool, but that's not really a use case, that's a unit test.&lt;/p&gt;

&lt;p&gt;Here's what I've actually reached for &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;shapecraft&lt;/a&gt; for, across a handful of real projects. Five short examples, no fluff, so you can see where this fits before you commit to reading the full docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Turning support tickets into triageable data
&lt;/h2&gt;

&lt;p&gt;A raw customer message ("hey my invoice from last month looks wrong and also the app crashed twice") isn't something you can route or prioritize as-is. Give it a schema instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&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;TicketSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;how-to&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;urgent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;TicketSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One call, one typed object, ready to drop into a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Parsing receipts, invoices, and other "almost structured" documents
&lt;/h2&gt;

&lt;p&gt;OCR'd text from a receipt is a mess of line items, totals, and stray whitespace. Instead of writing regex to hunt for the total, describe the shape you want and let the model do the extraction:&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;ReceiptSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;lineItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&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;Same &lt;code&gt;generate()&lt;/code&gt; call, same guarantees, just a different schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Slot-filling for a form-driven chatbot
&lt;/h2&gt;

&lt;p&gt;Onboarding flows ("what's your name, email, and company size?") don't need a whole conversation engine, they need a bot that keeps asking until it has every required field, validated. shapecraft's &lt;code&gt;generate()&lt;/code&gt; retry loop already does the "keep trying until it's valid" part, so the bot logic is just: ask, extract, check what's missing, repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Running a local model with a real guarantee
&lt;/h2&gt;

&lt;p&gt;Not everything can leave the device. For document classification that has to run fully offline, &lt;code&gt;llamaCpp()&lt;/code&gt; applies a GBNF grammar at the token level, so the model &lt;em&gt;cannot&lt;/em&gt; produce anything outside your five category labels, not "usually doesn't," actually can't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;llamaCpp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;llamaCpp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./models/llama-3.2-3b.gguf&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;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gbnf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;categoryGrammar&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the difference between &lt;code&gt;constrained&lt;/code&gt; and &lt;code&gt;best-effort&lt;/code&gt; guarantee levels, and it matters a lot more once you're running unattended over a few thousand files overnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Batch-processing a folder of anything
&lt;/h2&gt;

&lt;p&gt;Classify, summarize, or extract from a directory of files without hand-rolling concurrency control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateBatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;results&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;generateBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each item validates and retries independently, so one bad file doesn't take down the batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's the range
&lt;/h2&gt;

&lt;p&gt;Ticket triage, document extraction, chatbot slot-filling, offline classification, batch jobs, all the same core idea: describe the shape you want, get back something you can actually trust. Check &lt;code&gt;result.guaranteeLevel&lt;/code&gt; any time you want to know exactly how much.&lt;/p&gt;

&lt;p&gt;One more thing: we've now published full documentation, with a guide for every feature mentioned here (and a bunch not covered in this post, like streaming, tool calling, and multi-agent orchestration). Go explore it at &lt;strong&gt;&lt;a href="https://aviasoletechnologies.github.io/shapecraft/" rel="noopener noreferrer"&gt;aviasoletechnologies.github.io/shapecraft&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>node</category>
      <category>llm</category>
    </item>
    <item>
      <title>I Streamed Structured Output Into a Live Panel. A Field Rendered Before I Knew It Was Wrong</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Thu, 13 Aug 2026 13:19:40 +0000</pubDate>
      <link>https://dev.to/yatindavra/i-streamed-structured-output-into-a-live-panel-a-field-rendered-before-i-knew-it-was-wrong-40b8</link>
      <guid>https://dev.to/yatindavra/i-streamed-structured-output-into-a-live-panel-a-field-rendered-before-i-knew-it-was-wrong-40b8</guid>
      <description>&lt;p&gt;The panel was for our support team. An agent pastes a customer's message in, and four fields fill in next to it: category, priority, a one-line summary, and which team it should go to. All four come from one model call against one schema. Nothing exotic - a single &lt;code&gt;generate()&lt;/code&gt; call would have done it in about three lines.&lt;/p&gt;

&lt;p&gt;The problem was the three seconds that call took. Agents kept re-clicking the button mid-request, assuming it had hung, because the panel just sat there blank until the whole object came back at once. So the ask became: show each field the moment it's ready, not all four at once at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  First pass: parse whatever text has arrived so far
&lt;/h2&gt;

&lt;p&gt;The obvious move was to switch to the streaming call, take the raw text deltas as they arrived, and after every delta try &lt;code&gt;JSON.parse()&lt;/code&gt; on whatever had accumulated so far, wrapped in a try/catch. If it parsed, grab whatever fields existed and render them.&lt;/p&gt;

&lt;p&gt;It almost never parsed. An object with unclosed braces throws every time, right up until the very last delta closes the outer &lt;code&gt;}&lt;/code&gt;. So in practice this did nothing for the entire three seconds and then dumped all four fields on screen at once anyway - the same experience as not streaming, with extra code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second pass: track bracket depth by hand
&lt;/h2&gt;

&lt;p&gt;Next I tried tracking whether a field's value had structurally closed by counting &lt;code&gt;{&lt;/code&gt;/&lt;code&gt;}&lt;/code&gt; and quote state as deltas came in, and slicing out that one field's substring the moment its depth returned to zero. That got fields appearing one at a time, which was the actual goal.&lt;/p&gt;

&lt;p&gt;It also broke in two ways I didn't expect. The &lt;code&gt;assignee&lt;/code&gt; field wasn't a plain string, it was &lt;code&gt;{ team, reason }&lt;/code&gt;, and nested braces threw my depth counter off by exactly the amount you'd guess. And separately - this is the one that actually worried me - "structurally closed" isn't the same as "correct." The model once returned &lt;code&gt;"priority": "urgent-ish"&lt;/code&gt; instead of one of the four enum values. My counter saw the closing quote, decided the field was done, and rendered an orange "urgent-ish" badge on screen. A second later, when the full object finally failed validation and the whole thing retried, the badge silently flipped to something else. From the agent's side, a wrong priority had flashed on their ticket for a second before quietly changing. That's a bad thing to ship to people making triage calls under time pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was already there
&lt;/h2&gt;

&lt;p&gt;I went looking for how &lt;code&gt;generateStream()&lt;/code&gt; itself decides when a field is "done," assuming I'd have to patch around it, and found it already draws the line where I actually needed it drawn: a field only becomes visible once it's &lt;em&gt;both&lt;/em&gt; structurally closed &lt;em&gt;and&lt;/em&gt; has passed its own piece of the schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&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;TicketSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;how-to&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;urgent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;assignee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;TicketSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;partial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;renderFields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// only ever the fields that have already passed validation&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;attempt-failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;resetPanel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// a field failed its own check mid-stream; the next attempt starts clean&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;partial&lt;/code&gt; event only fires after a field is checked against its own sub-schema, not just parsed. So a &lt;code&gt;priority&lt;/code&gt; of &lt;code&gt;"urgent-ish"&lt;/code&gt; never reaches &lt;code&gt;renderFields()&lt;/code&gt; at all - the moment that field fails, the attempt is marked failed and abandoned mid-stream, without waiting to finish consuming the rest of that doomed response, and a fresh attempt starts. The panel never had a badge to flip back from, because it never rendered the wrong one in the first place.&lt;/p&gt;

&lt;p&gt;The nested &lt;code&gt;assignee&lt;/code&gt; object turned out to be handled too, just at a coarser grain than I expected: it's validated and emitted as one field once its own &lt;code&gt;{ team, reason }&lt;/code&gt; closes, not field-by-field inside it. That was finer-grained than my hand-rolled version actually needed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;Fields now start appearing within a few hundred milliseconds of the first one closing instead of all landing at once after three seconds, so the panel stopped looking hung. And the flash-then-correct badge, which agents had started asking about roughly once a shift under my bracket-counting version, hasn't come back - there's nothing left to flash, because nothing renders until it's already right.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Wrote the Same Tool-Calling Loop Three Times, Once Per Provider, Before I Stopped</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Mon, 10 Aug 2026 11:28:03 +0000</pubDate>
      <link>https://dev.to/yatindavra/i-wrote-the-same-tool-calling-loop-three-times-once-per-provider-before-i-stopped-3ec9</link>
      <guid>https://dev.to/yatindavra/i-wrote-the-same-tool-calling-loop-three-times-once-per-provider-before-i-stopped-3ec9</guid>
      <description>&lt;p&gt;The job was an internal ops assistant. Someone asks "is order 4521 still on hold and what's the customer's plan tier?", and answering it means hitting two of our own services first - the orders API and the billing API - then coming back with a short structured summary my UI could render. Not a chatbot. The final answer had to be &lt;code&gt;{ status, tier, summary }&lt;/code&gt;, typed, every time, because a component was rendering it.&lt;/p&gt;

&lt;p&gt;The lookups were the easy part; they're just two functions I already had. What I needed was for the &lt;em&gt;model&lt;/em&gt; to decide which of them to call, with what arguments, see what came back, and then produce that structured answer. And I needed the arguments it invented to be trustworthy before they reached functions that hit real internal services.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop I wrote, then wrote again
&lt;/h2&gt;

&lt;p&gt;OpenAI's tool-calling API is genuinely pleasant, so version one took an afternoon: pass &lt;code&gt;tools&lt;/code&gt;, read &lt;code&gt;message.tool_calls&lt;/code&gt;, &lt;code&gt;JSON.parse&lt;/code&gt; the arguments, dispatch to the right function, push a &lt;code&gt;role: "tool"&lt;/code&gt; message back, loop until the model stops asking. Maybe 60 lines with the turn guard.&lt;/p&gt;

&lt;p&gt;Then I wanted Anthropic for the summarizing step, because it was better at it. Anthropic's wire format is not OpenAI's. Tool requests arrive as &lt;code&gt;tool_use&lt;/code&gt; content blocks inside the message rather than a parallel &lt;code&gt;tool_calls&lt;/code&gt; array, and results go back as &lt;code&gt;tool_result&lt;/code&gt; blocks in a &lt;em&gt;user&lt;/em&gt; message, not a dedicated tool role. So the dispatch loop got rewritten - same logic, different shape, second copy.&lt;/p&gt;

&lt;p&gt;Then, because some of this had to run against a local model for a cost experiment, Ollama. Different again. Third copy.&lt;/p&gt;

&lt;p&gt;Three loops, all doing the identical thing, each subtly wrong in its own way for a while. And a &lt;code&gt;switch&lt;/code&gt; on provider name sitting in the middle of my application code, which is exactly the kind of thing I don't want to own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that actually worried me
&lt;/h2&gt;

&lt;p&gt;Somewhere in copy two, a model called my order lookup with &lt;code&gt;{ orderId: 4521 }&lt;/code&gt; - a number, where every other time it had sent the string &lt;code&gt;"4521"&lt;/code&gt;. My handler did &lt;code&gt;orderId.trim()&lt;/code&gt; and the whole request died with a &lt;code&gt;TypeError&lt;/code&gt; from inside a function that had no business receiving a number in the first place.&lt;/p&gt;

&lt;p&gt;That's when the real problem got clearer. It wasn't the three loops. It was that &lt;code&gt;JSON.parse&lt;/code&gt; succeeding is not the same as the arguments being &lt;em&gt;right&lt;/em&gt;, and I had nothing between the model's invention and a function that talks to a production service. I was one hallucinated field away from calling an internal API with garbage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was already importing
&lt;/h2&gt;

&lt;p&gt;I was using &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;shapecraft&lt;/a&gt; for the structured-output part of this project already, so the fact that &lt;code&gt;generateWithTools()&lt;/code&gt; was sitting in the same package was mildly embarrassing to discover this late:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateWithTools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ToolDefinition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&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;lookupOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToolDefinition&lt;/span&gt; &lt;span class="o"&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;lookup_order&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;Look up an order's current status by its ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ordersApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&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;lookupBilling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToolDefinition&lt;/span&gt; &lt;span class="o"&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;lookup_billing&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;Look up a customer's billing plan tier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;billingApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&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;result&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;generateWithTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-haiku-4-5-20251001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lookupOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lookupBilling&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Is order 4521 still on hold, and what's that customer's plan tier?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// { status: "on_hold", tier: "enterprise", summary: "..." }&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// every call it made, in order, with results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This uses each provider's &lt;em&gt;native&lt;/em&gt; tool API underneath - it isn't a prompt convention pretending to be tool calling. The OpenAI-wire-format backends share one implementation, and Anthropic and Ollama get their own shapes normalized behind the same interface. Which means the three loops collapse into one call, and swapping &lt;code&gt;anthropic(...)&lt;/code&gt; for &lt;code&gt;openai(...)&lt;/code&gt; is a one-line edit rather than a rewrite.&lt;/p&gt;

&lt;p&gt;The part I actually cared about: &lt;code&gt;parameters&lt;/code&gt; is the same schema machinery as everywhere else in the library, and arguments are validated against it &lt;em&gt;before&lt;/em&gt; the handler runs. My &lt;code&gt;{ orderId: 4521 }&lt;/code&gt; case now fails that check, gets handed back to the model as an error it can see, and the model corrects itself. It never reaches &lt;code&gt;ordersApi.get()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my first attempt actually broke
&lt;/h2&gt;

&lt;p&gt;I had &lt;code&gt;lookupOrder&lt;/code&gt; throw on a missing order, because that's what the function already did:&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;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderId&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;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ordersApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&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;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No order &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// don't&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;order&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;First time the model guessed a wrong order number, the entire call died with &lt;code&gt;ToolExecutionError&lt;/code&gt;. No retry, no recovery.&lt;/p&gt;

&lt;p&gt;That's deliberate, and once I read why, I agreed with it: a handler throwing means &lt;em&gt;my code&lt;/em&gt; failed, and re-prompting a model cannot fix a broken function. So it aborts immediately rather than looping. Bad &lt;em&gt;arguments&lt;/em&gt; are the recoverable case, because the model can fix those - and those it does feed back.&lt;/p&gt;

&lt;p&gt;The catch is that "order not found" isn't really my code failing. It's a legitimate result the model should get to react to. So it belongs in the return value, not in a throw:&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;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderId&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;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ordersApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`No order &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// model sees this and adapts&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a wrong guess comes back as data, the model tries a different ID or says it couldn't find one, and the loop carries on. Throw for "my database is down", return for "no such record." That distinction is worth getting right on day one instead of day three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't promise
&lt;/h2&gt;

&lt;p&gt;Tool &lt;em&gt;selection&lt;/em&gt; isn't validated, and can't be. Nothing checks the model picked the sensible tool, or that it should have called one at all - that's model behaviour, and it's the same structural-vs-semantic gap that applies to every schema-validated call. Arguments are guaranteed well-formed; the judgment to use them isn't.&lt;/p&gt;

&lt;p&gt;Handler correctness is entirely mine. The library guarantees my return value gets fed back to the model as-is, and nothing about whether it's the right value.&lt;/p&gt;

&lt;p&gt;There's a turn cap, defaulting to 10, and a model that keeps requesting tools forever hits &lt;code&gt;MaxToolTurnsExceededError&lt;/code&gt; rather than spinning. Blunt, but I'd rather have a blunt guard than a runaway bill.&lt;/p&gt;

&lt;p&gt;And it's non-streaming in v1, which for my use case is fine - I'm rendering a structured object into a component, not streaming prose at anyone. It's also on every cloud backend but not local GGUF models via &lt;code&gt;llamaCpp()&lt;/code&gt;, which simply have no tools API to call. There's a &lt;code&gt;model.capabilities.toolCalling&lt;/code&gt; flag to check rather than memorizing that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves it
&lt;/h2&gt;

&lt;p&gt;Three provider-specific loops and a &lt;code&gt;switch&lt;/code&gt; statement became one function call and two plain &lt;code&gt;{ name, parameters, handler }&lt;/code&gt; objects. Roughly 180 lines of dispatch plumbing deleted, and the thing I was actually nervous about - a hallucinated argument reaching a service that mutates real orders - is now a validation failure the model gets to correct instead of a &lt;code&gt;TypeError&lt;/code&gt; in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateWithTools&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo's at &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;github.com/aviasoletechnologies/shapecraft&lt;/a&gt;, package is &lt;code&gt;@aviasole/shapecraft&lt;/code&gt; on npm.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>llm</category>
      <category>node</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Fri, 07 Aug 2026 12:32:59 +0000</pubDate>
      <link>https://dev.to/yatindavra/-j13</link>
      <guid>https://dev.to/yatindavra/-j13</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/naitik_kapatel_f96f1fb424/i-kept-retrying-a-local-model-into-the-right-shape-turns-out-i-didnt-have-to-retry-at-all-44d9" class="crayons-story__hidden-navigation-link"&gt;I Kept Retrying a Local Model Into the Right Shape. Turns Out I Didn't Have To Retry At All.&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/naitik_kapatel_f96f1fb424" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4067486%2Fb83b1c2c-9951-49a8-bbb4-e9adfe033da7.png" alt="naitik_kapatel_f96f1fb424 profile" class="crayons-avatar__image" width="96" height="96"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/naitik_kapatel_f96f1fb424" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Naitik Kapatel
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Naitik Kapatel
                
                
              
              &lt;div id="story-author-preview-content-4340803" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/naitik_kapatel_f96f1fb424" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4067486%2Fb83b1c2c-9951-49a8-bbb4-e9adfe033da7.png" class="crayons-avatar__image" alt="" width="96" height="96"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Naitik Kapatel&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/naitik_kapatel_f96f1fb424/i-kept-retrying-a-local-model-into-the-right-shape-turns-out-i-didnt-have-to-retry-at-all-44d9" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 7&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/naitik_kapatel_f96f1fb424/i-kept-retrying-a-local-model-into-the-right-shape-turns-out-i-didnt-have-to-retry-at-all-44d9" id="article-link-4340803"&gt;
          I Kept Retrying a Local Model Into the Right Shape. Turns Out I Didn't Have To Retry At All.
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/llm"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;llm&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/node"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;node&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/typescript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;typescript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/gbnf"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;gbnf&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/naitik_kapatel_f96f1fb424/i-kept-retrying-a-local-model-into-the-right-shape-turns-out-i-didnt-have-to-retry-at-all-44d9" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;3&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/naitik_kapatel_f96f1fb424/i-kept-retrying-a-local-model-into-the-right-shape-turns-out-i-didnt-have-to-retry-at-all-44d9#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>My Pipeline Needed Three Different Models to Hand Off to Each Other Without Losing Validation Along the Way</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:36:23 +0000</pubDate>
      <link>https://dev.to/yatindavra/my-pipeline-needed-three-different-models-to-hand-off-to-each-other-without-losing-validation-along-3cf</link>
      <guid>https://dev.to/yatindavra/my-pipeline-needed-three-different-models-to-hand-off-to-each-other-without-losing-validation-along-3cf</guid>
      <description>&lt;p&gt;The task was a moderation pipeline: classify an incoming report, draft a response if it needed one, then have a second pass review that draft before anything went out. Three distinct jobs, and I didn't want one model doing all three badly - a cheap model for triage, a stronger one for the actual drafting, and a strict re-check before anything shipped. Each step had its own schema too: triage returns a category, the drafter returns a response plus a tone flag, the reviewer returns an approval and notes. Every one of those I could already get validated individually with a plain &lt;code&gt;generate()&lt;/code&gt; call. What I didn't have was a way to chain three of them together where step two's prompt is built from step one's &lt;em&gt;already-validated&lt;/em&gt; output, not step one's raw text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I almost built myself
&lt;/h2&gt;

&lt;p&gt;The obvious first move: call &lt;code&gt;generate()&lt;/code&gt; three times, &lt;code&gt;JSON.stringify()&lt;/code&gt; each result into the next prompt by hand, write my own &lt;code&gt;for&lt;/code&gt; loop with a turn cap so a bad router decision couldn't spin forever. Maybe 40 lines. Not hard, but the kind of thing I'd be rewriting slightly differently in every project that needed more than one step.&lt;/p&gt;

&lt;p&gt;The other direction I looked at was going the other way entirely - a real agent framework, something like LangGraph or Google's ADK. Both do this properly: arbitrary agent graphs, shared mutable state, tool-calling loops, the works. I backed off almost as fast as I'd looked. My pipeline is three fixed steps in a known order, not an open-ended graph, and pulling in a framework built for arbitrary orchestration to run a sequence I could describe in one sentence felt like the wrong size tool - all the surface area of "build any agent topology" for a job that's "run these three, in order, stop when the last one says done."&lt;/p&gt;

&lt;h2&gt;
  
  
  What was already sitting there
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;shapecraft&lt;/a&gt; - the same library I was using for each individual step - turned out to have exactly the middle ground: &lt;code&gt;defineAgent()&lt;/code&gt; + &lt;code&gt;runAgents()&lt;/code&gt;, from a separate &lt;code&gt;/agentic&lt;/code&gt; entrypoint so it doesn't cost anything if you never use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runAgents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft/agentic&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;openai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&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;triage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spam&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abuse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false_positive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;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;triage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-haiku-4-5-20251001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;firm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;neutral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;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;draft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-opus-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAgents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;review&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;reportText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;router&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;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;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;triage&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;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;triage&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&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;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;maxTurns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;final&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// review's validated { approved, notes }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step is an ordinary &lt;code&gt;generate()&lt;/code&gt; call underneath - same retry loop, same guarantee level per backend, nothing new invented for validation. &lt;code&gt;runAgents()&lt;/code&gt; is just the loop I was about to write myself, plus the part I hadn't gotten to yet: threading the &lt;em&gt;validated&lt;/em&gt; &lt;code&gt;data&lt;/code&gt; forward, never the model's raw response text. The router is a plain function I write and fully control - no DSL, no hidden state machine, no framework deciding what "agent" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my first attempt actually broke
&lt;/h2&gt;

&lt;p&gt;I let the default handoff do its thing at first - each step's prompt is just the previous step's validated data, JSON-stringified. That's fine between triage and draft. It broke between draft and review: all the reviewer got was &lt;code&gt;{"response": "...", "tone": "neutral"}&lt;/code&gt;. No idea what the original report even said. It approved things it had no basis to approve, because it genuinely didn't have the basis.&lt;/p&gt;

&lt;p&gt;The fix was &lt;code&gt;buildPrompt&lt;/code&gt;, an optional override per agent:&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;review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-opus-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buildPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_history&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="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="s2"&gt;`Original report: &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="s2"&gt;\nProposed response: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;Worth calling out because it's not a bug - the default of "just forward the last validated result" is the right default for a two-step chain. It stops being right the moment a downstream step needs context from more than one hop back, and nothing tells you that until the reviewer starts approving things blind. Once I knew to look for it, the fix was one field.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't promise
&lt;/h2&gt;

&lt;p&gt;Each step is validated exactly as strongly as calling &lt;code&gt;generate()&lt;/code&gt; on it directly - no weaker, but also no stronger as a &lt;em&gt;chain&lt;/em&gt;. Nothing checks that triage → draft → review was the right sequence of steps for this particular report, or that the reviewer's notes are actually about the draft it was shown. That's the router's job and mine, same as it would be if I'd hand-written the loop myself.&lt;/p&gt;

&lt;p&gt;Routing is caller-owned, not model-inferred - &lt;code&gt;runAgents()&lt;/code&gt; doesn't ship a meta-agent that decides what runs next on its own. If I want the model to influence routing, I put that decision inside an agent's own schema (triage's &lt;code&gt;category&lt;/code&gt; field, here) and branch on it in my router function. And it's sequential only - one agent at a time, each seeing only what the router explicitly hands it. If I need several independent things running in parallel, that's &lt;code&gt;generateBatch()&lt;/code&gt;, a different axis entirely, not this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves it
&lt;/h2&gt;

&lt;p&gt;Three &lt;code&gt;defineAgent()&lt;/code&gt; calls, one router function, one &lt;code&gt;buildPrompt&lt;/code&gt; override once I found the gap in the default. No hand-rolled loop, no state machine, no framework I'd have had to learn to run a three-step pipeline I could already describe in a sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runAgents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft/agentic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo's at &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;github.com/aviasoletechnologies/shapecraft&lt;/a&gt;, package is &lt;code&gt;@aviasole/shapecraft&lt;/code&gt; on npm.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>node</category>
      <category>typescript</category>
      <category>agents</category>
    </item>
    <item>
      <title>I Built a Slot-Filling Onboarding Bot. It Leaked a Validation Error Into the Chat</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Tue, 21 Jul 2026 05:40:28 +0000</pubDate>
      <link>https://dev.to/yatindavra/i-built-a-slot-filling-onboarding-bot-it-leaked-a-validation-error-into-the-chat-e4g</link>
      <guid>https://dev.to/yatindavra/i-built-a-slot-filling-onboarding-bot-it-leaked-a-validation-error-into-the-chat-e4g</guid>
      <description>&lt;p&gt;I was building an onboarding flow: a new user lands, a chat window asks them three things - what problem they're solving, what their idea is, who the user is - and once all three are answered, the app needs &lt;code&gt;{ problem, idea, user }&lt;/code&gt; as a clean, validated object. Not a transcript. Not "probably in there somewhere." An actual object my code could pass to the next step without re-reading prose.&lt;/p&gt;

&lt;p&gt;My first pass was the obvious one: after every user reply, try to extract the object, check if it validated, and if it didn't, ask the model for a follow-up question. It worked right up until the extraction step threw partway through a conversation - a &lt;code&gt;SchemaViolationError&lt;/code&gt; with actual validation details in it - and I hadn't wrapped that path carefully enough. The raw error text got forwarded straight into the chat window as if it were the bot's next question. A user saw &lt;code&gt;"idea": expected string, received undefined&lt;/code&gt; where a friendly follow-up question should have been.&lt;/p&gt;

&lt;p&gt;I fixed that specific leak with a try/catch. Then found a second one a day later in a different code path. That's when I stopped patching leaks one at a time and looked at whether the tool I was already using (&lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;shapecraft&lt;/a&gt;) had a mode built for exactly this shape of problem, instead of me re-deriving "never let internal errors reach the user" by hand, one bug at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual design problem
&lt;/h2&gt;

&lt;p&gt;It's not really "extract after every message and retry." It's two completely different concerns that I'd tangled into one loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Having a conversation&lt;/strong&gt; - a model asking natural questions, probing vague answers, deciding when it has enough. That's chat. It should never be schema-constrained, because forcing JSON-shaped output mid-conversation is exactly how you get a bot that "helpfully" outputs a stray &lt;code&gt;{...}&lt;/code&gt; into the chat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Producing a validated object, once, at the end&lt;/strong&gt; - a completely separate concern from the conversation itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Conflating them means every turn is a place validation &lt;em&gt;could&lt;/em&gt; fail and leak. Separating them means there's exactly &lt;strong&gt;one&lt;/strong&gt; place validation happens - after the conversation is over - and exactly one rule to enforce: nothing generated internally at that step is ever routed back into the chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;turnaround&lt;/code&gt; mode
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;generate(..., { turnaround: true })&lt;/code&gt; is built around that split. The model drives the conversation entirely through &lt;code&gt;systemPrompt&lt;/code&gt; - what to ask, when to probe a vague answer, when it's satisfied. Shapecraft's only job during collection is to relay the model's reply and carry the transcript forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-haiku-4-5-20251001&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;FACILITATOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are the onboarding facilitator. Ask exactly one focused question at a time to learn: &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="s2"&gt;(1) what problem we're solving, (2) what the idea is, (3) who the user is. &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="s2"&gt;Probe vague answers for specifics. Do not invent details on the user's behalf.&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;r&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OnboardingSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FACILITATOR&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;turnaround&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;collecting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "What problem are you trying to solve?"&lt;/span&gt;

&lt;span class="nx"&gt;r&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OnboardingSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Onboarding takes too long&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FACILITATOR&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;turnaround&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// thread the running transcript back in&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;collecting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "What's your idea to fix that?"&lt;/span&gt;

&lt;span class="c1"&gt;// ...more turns, same pattern...&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { problem, idea, user } - validated once, here, never mid-conversation&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;FACILITATOR&lt;/code&gt; never mentions how to signal "done" - I don't have to write that part. Shapecraft appends its own completion instruction (plus a checklist built from the schema's required fields) to whatever system prompt I pass in, so the sentinel handshake isn't something I own or could get subtly wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;memory&lt;/code&gt; is a plain JSON-serializable object - just the transcript plus a turn count - so a stateless HTTP handler can persist it between requests and rehydrate it on the next one, which is the actual shape a real chat backend needs anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The completion sentinel
&lt;/h2&gt;

&lt;p&gt;The model signals "I'm done" by replying with a literal &lt;code&gt;&amp;lt;&amp;lt;&amp;lt;COMPLETE&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; instead of another question - the instruction to do so is injected automatically, not something I write myself. That reply is the trigger for a single, separate &lt;code&gt;generate()&lt;/code&gt; call - a normal structured extraction pass over the whole transcript, not part of the conversation the user sees. I liked this over having the model just emit JSON directly once it thinks it's done, for the same reason my original leak happened: raw or premature JSON has no business anywhere near the user-facing chat, and "is this the sentinel string" is a trivial check instead of a fragile "does this look like JSON" guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that actually fixes my bug
&lt;/h2&gt;

&lt;p&gt;Every response from a &lt;code&gt;turnaround&lt;/code&gt; call is one of two shapes: &lt;code&gt;{ status: "collecting", message }&lt;/code&gt; where &lt;code&gt;message&lt;/code&gt; is &lt;em&gt;only ever the model's own conversational text&lt;/em&gt;, or &lt;code&gt;{ status: "complete", data }&lt;/code&gt; where &lt;code&gt;data&lt;/code&gt; is only ever produced by the end-of-conversation extraction pass. There is no third path where something shapecraft generated internally ends up in &lt;code&gt;message&lt;/code&gt;. If that end-of-conversation extraction fails validation, it throws a real, terminal error out of &lt;code&gt;generate()&lt;/code&gt; - it does not get wrapped into a fake follow-up question, and the user is never re-interrogated for answers already sitting in the transcript.&lt;/p&gt;

&lt;p&gt;That last part surprised me at first - I expected "validation failed, so ask again." But if the user's answers are already in the transcript, a validation failure at the end means &lt;em&gt;my&lt;/em&gt; extraction step is broken, not that the user needs to repeat themselves. Re-asking would be asking the wrong party to fix the wrong problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves it
&lt;/h2&gt;

&lt;p&gt;My onboarding bot now has exactly one place structured data gets produced, and exactly zero places an internal error can masquerade as a chat message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo's at &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;github.com/aviasoletechnologies/shapecraft&lt;/a&gt;, package is &lt;code&gt;@aviasole/shapecraft&lt;/code&gt; on npm. If you've ever had a slot-filling bot leak something internal into the chat window - curious how you caught it.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>node</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Had a GBNF Grammar File and Nowhere to Point It. So I Added Support for It.</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:44:46 +0000</pubDate>
      <link>https://dev.to/yatindavra/i-had-a-gbnf-grammar-file-and-nowhere-to-point-it-so-i-added-support-for-it-3ln2</link>
      <guid>https://dev.to/yatindavra/i-had-a-gbnf-grammar-file-and-nowhere-to-point-it-so-i-added-support-for-it-3ln2</guid>
      <description>&lt;p&gt;I was building a classification step that had to run entirely on a local model - no cloud calls, straight through llama.cpp. Read a chunk of text, output exactly one of a handful of fixed labels. It ran in a tight, low-latency loop with basically no retry budget, and local models - even good quantized ones - drift more on output formatting than the big hosted APIs do. Wrap the label in a sentence, add a stray quote, whatever. Small thing, but multiplied over a few thousand calls it's a real failure rate.&lt;/p&gt;

&lt;p&gt;The fix already exists: llama.cpp grammars, better known as &lt;strong&gt;GBNF&lt;/strong&gt;. Hand llama.cpp a grammar and it masks the token logits at every generation step, so the model literally cannot produce a token that would break the grammar. Not "probably won't" - cannot.&lt;/p&gt;

&lt;p&gt;I already had a grammar file. What I didn't have was a way to hand it to the structured-output library I was using (&lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;shapecraft&lt;/a&gt;) - it had five schema-input types at the time (Zod, JSON Schema, regex, a validator function, XML), and none of them were built for "apply this formal grammar directly."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the existing schema types didn't fit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not JSON Schema&lt;/strong&gt; - GBNF describes a context-free grammar over an arbitrary string, not a JSON shape. My output was going to be a matched string, not an object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a regex pattern&lt;/strong&gt; - GBNF grammars are recursive (rules referencing rules, alternation, repetition). You can't flatten that into one flat regex.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a validator function&lt;/strong&gt; - a validator checks output &lt;em&gt;after&lt;/em&gt; generation. I wanted the model physically unable to produce the wrong shape, not a check that catches it afterward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I added a schema type that did fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A new schema-input type&lt;/strong&gt;, &lt;code&gt;{ gbnf: grammarString }&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;llamaCpp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;sentimentGrammar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`root ::= "positive" | "negative" | "neutral"`&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;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;llamaCpp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./models/llama-3.2-3b-instruct.gguf&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;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gbnf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sentimentGrammar&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Classify: 'I love this!'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// "positive" - cannot be anything else&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guaranteeLevel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "constrained"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;result.data&lt;/code&gt; is a raw string, not a parsed object - GBNF describes a string language, not a JSON shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;llamaCpp()&lt;/code&gt; backend&lt;/strong&gt; (&lt;code&gt;node-llama-cpp&lt;/code&gt;) - the thing that actually applies the grammar at the token level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bundled GBNF interpreter&lt;/strong&gt; for every other backend (&lt;code&gt;openai&lt;/code&gt;, &lt;code&gt;groq&lt;/code&gt;, &lt;code&gt;anthropic&lt;/code&gt;, &lt;code&gt;ollama&lt;/code&gt;), none of which have a grammar parameter. There, the grammar gets injected into the prompt instead, and the returned string is checked against it - mismatch, retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;date&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;gbnf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`root ::= year "-" month "-" day\nyear ::= [0-9]{4}\nmonth ::= [0-9]{2}\nday ::= [0-9]{2}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;When did WWII end in Europe?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// "1945-05-08"&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guaranteeLevel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "best-effort" - no grammar param on this API&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same grammar, same call, two very different guarantee levels depending on the backend. GBNF ended up being the one schema type where the guarantee genuinely depends on which backend is running it, not just retry count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I tried to break my own validator
&lt;/h2&gt;

&lt;p&gt;I don't trust a validator only tested against grammars that were supposed to pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catastrophic backtracking&lt;/strong&gt; - the classic thing that blows up naive regex engines, like &lt;code&gt;("a" "a"?)* "b"&lt;/code&gt; against a long run of &lt;code&gt;a&lt;/code&gt;s with no trailing &lt;code&gt;b&lt;/code&gt;. Didn't blow up - milliseconds, every time. The matcher dedupes by &lt;em&gt;position reached&lt;/em&gt;, not path taken, so it doesn't re-explore the same dead end repeatedly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A genuinely adversarial grammar&lt;/strong&gt; did slow it down - a ~26-way ambiguous alternation against a 300,000-character input with no valid terminator. That trips a step-budget guard in under a second with a clear error instead of hanging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep right-recursion through a rule reference&lt;/strong&gt; found a real limit. &lt;code&gt;*&lt;/code&gt;/&lt;code&gt;+&lt;/code&gt; repetition is iterative, no limit tested up to 50,000+ repeats. But a grammar like &lt;code&gt;list ::= item "," list | item&lt;/code&gt; recurses through the JS call stack once per repetition, and breaks around 900-1000 reps. Instead of a raw stack-overflow trace, it now throws a clear message telling you to rewrite with &lt;code&gt;*&lt;/code&gt;/&lt;code&gt;+&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real bug this surfaced
&lt;/h2&gt;

&lt;p&gt;Stress-testing also found something unrelated but genuinely broken: the Groq backend was forcing &lt;code&gt;response_format: json_object&lt;/code&gt; on every call, including GBNF ones. Groq's API rejects that mode if the prompt doesn't literally contain the word "json" - which a GBNF grammar prompt has no reason to. Every GBNF call against Groq was failing before the model even ran. Two-line fix, but only found by trying to break the feature instead of just checking the happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves it
&lt;/h2&gt;

&lt;p&gt;From "I have a grammar file and nowhere to point it" to a schema type that's genuinely valid by construction on a local llama.cpp model, and checked (not just trusted) everywhere else - same call shape, same retry behavior as every other schema type.&lt;/p&gt;

&lt;p&gt;If you've got a &lt;code&gt;.gbnf&lt;/code&gt; grammar sitting around, or want a local model physically incapable of drifting off a fixed format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;llamaCpp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo's at &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;github.com/aviasoletechnologies/shapecraft&lt;/a&gt;, package is &lt;code&gt;@aviasole/shapecraft&lt;/code&gt; on npm. Curious if anyone else here is running GBNF grammars against local models, and what for - I only had the one use case in mind.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>gbnf</category>
      <category>opensource</category>
      <category>typescript</category>
    </item>
    <item>
      <title>How I ended up contributing five ways to shape an LLM's output</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Fri, 03 Jul 2026 06:46:44 +0000</pubDate>
      <link>https://dev.to/yatindavra/how-i-ended-up-contributing-five-ways-to-shape-an-llms-output-18cf</link>
      <guid>https://dev.to/yatindavra/how-i-ended-up-contributing-five-ways-to-shape-an-llms-output-18cf</guid>
      <description>&lt;p&gt;A few months back I was on a project that needed LLMs to return actual structured data, not prose I'd have to regex my way through. Simple enough in theory, except every provider wanted the shape described differently - Anthropic's SDK wanted a tool schema, OpenAI had its own response-format, Ollama was basically raw prompting and hoping. I was rewriting the same schema three times per feature and writing my own retry-on-invalid-output loop for anything that didn't guarantee output.&lt;/p&gt;

&lt;p&gt;That's how I found &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;ShapeCraft&lt;/a&gt; (&lt;code&gt;@aviasole/shapecraft&lt;/code&gt; on npm). Define a schema once, hand it a model, get back validated data plus a &lt;code&gt;guaranteeLevel&lt;/code&gt; telling you how much to trust it - "guaranteed" means something different on OpenAI's native schema enforcement than on Anthropic's prompt-and-validate approach than on Ollama's token-level grammar constraints.&lt;/p&gt;

&lt;p&gt;It already supported Zod out of the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&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;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aviasole/shapecraft&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&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;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covered most of what I needed. But a few things in my actual project didn't fit "define a Zod schema," so instead of working around it, I opened some PRs.&lt;/p&gt;

&lt;p&gt;Part of the data I was extracting already had a JSON Schema, generated by another tool upstream. Rewriting it as Zod types just to satisfy the library felt like busywork, so that became the first addition - pass the JSON Schema straight through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;jsonSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;score&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="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I hit cases where I didn't need an object at all, just a string in a specific format. Wrapping that in an object schema felt like putting a hat on a hat, so next came a plain regex option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;What is today's date?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, a rule that only made sense as logic, not a type - something like "required only if this other field equals X." Technically expressible as a schema, miserable to read. So I added a way to pass a validator function directly, plus a &lt;code&gt;hint&lt;/code&gt; so the model still has something to aim for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that mattered most to me was separate from this project entirely. I also do Tally/TDL and GST integration work, and that world runs on XML - nested tags, required fields buried a few levels deep. None of the above helped there since the output isn't JSON-shaped at all. So the last addition was template-based XML: give it an example with typed placeholders, model fills them in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;book&amp;gt;
  &amp;lt;title&amp;gt;{string}&amp;lt;/title&amp;gt;
  &amp;lt;author&amp;gt;{string}&amp;lt;/author&amp;gt;
  &amp;lt;year&amp;gt;{number}&amp;lt;/year&amp;gt;
&amp;lt;/book&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;author&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Extract: "Clean Code" by Robert C. Martin, 2008.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one took the most back-and-forth. Placeholders are deliberately limited to &lt;code&gt;{string}&lt;/code&gt;, &lt;code&gt;{number}&lt;/code&gt;, &lt;code&gt;{boolean}&lt;/code&gt;, and a typo throws before the model is even called - that's a template bug, not something worth a retry.&lt;/p&gt;

&lt;p&gt;The trickier part was literal text. Anything outside the &lt;code&gt;{}&lt;/code&gt; is supposed to survive untouched, but it's best-effort by default - a model will occasionally "improve" fixed text that reads like an instruction, since nothing marks it as untouchable. There's an &lt;code&gt;enforceLiterals: true&lt;/code&gt; flag that force-corrects every literal after the fact if you need that guaranteed. Or, often simpler, just leave the fixed value out of the template and splice it into &lt;code&gt;result.data&lt;/code&gt; yourself afterward.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;required&lt;/code&gt; also checks non-emptiness at any depth, so &lt;code&gt;&amp;lt;items&amp;gt;&amp;lt;/items&amp;gt;&lt;/code&gt; still triggers a retry instead of counting as present. And &lt;code&gt;arrays&lt;/code&gt; plus &lt;code&gt;parse: true&lt;/code&gt; gets you back a parsed JS object instead of raw XML, with specific nodes coerced into arrays even with a single item.&lt;/p&gt;

&lt;p&gt;One caveat worth knowing up front: XML generation is prompt-driven on every backend, no token-level grammar constraint like Ollama has for JSON. It leans on the model being capable, especially with deeply nested templates.&lt;/p&gt;

&lt;p&gt;All five of these route through the same &lt;code&gt;generate()&lt;/code&gt; call underneath - same retries, same &lt;code&gt;guaranteeLevel&lt;/code&gt;, same error types (&lt;code&gt;SchemaViolationError&lt;/code&gt;, &lt;code&gt;MaxRetriesExceededError&lt;/code&gt;). Different doors into the same room, which is probably why it was easy to keep adding them.&lt;/p&gt;

&lt;p&gt;Genuinely curious about a few things: if you've built template-based XML generation before, does &lt;code&gt;enforceLiterals&lt;/code&gt; feel like the right approach, or is there a cleaner way without a re-serialize pass? Is there a schema style still missing that you've actually needed in production - YAML, something protobuf-shaped? And for the validator path, is a &lt;code&gt;hint&lt;/code&gt; object enough context, or would you want more?&lt;/p&gt;

&lt;p&gt;Repo's here: &lt;a href="https://github.com/aviasoletechnologies/shapecraft" rel="noopener noreferrer"&gt;github.com/aviasoletechnologies/shapecraft&lt;/a&gt;. Package is &lt;code&gt;@aviasole/shapecraft&lt;/code&gt;. I'm using it across a few different projects now, so I'll keep adding to it as I run into more edge cases.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to Create Multi-Page TIFF Files in Node.js (Without ImageMagick)</title>
      <dc:creator>Yatin Davra</dc:creator>
      <pubDate>Tue, 14 Apr 2026 13:11:31 +0000</pubDate>
      <link>https://dev.to/yatindavra/how-to-create-multi-page-tiff-files-in-nodejs-without-imagemagick-im0</link>
      <guid>https://dev.to/yatindavra/how-to-create-multi-page-tiff-files-in-nodejs-without-imagemagick-im0</guid>
      <description>&lt;p&gt;If you've ever tried to create a multi-page TIFF in Node.js, you know the pain. Most solutions require ImageMagick as a system dependency, which is a headache in Docker, serverless environments, or CI pipelines. Others wrap native binaries with brittle &lt;code&gt;child_process&lt;/code&gt;calls.&lt;/p&gt;

&lt;p&gt;I ran into this exact problem while building a document processing pipeline — I needed to merge multiple scanned images into a single multi-page TIFF programmatically, with no external system dependencies.&lt;/p&gt;

&lt;p&gt;After digging through the options, I ended up writing a pure Node.js solution and published it as an npm package: &lt;code&gt;multi-page-tiff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Multi-page TIFF files store multiple images as linked Image File Directories (IFDs) inside a single .tiff file. Creating one correctly means writing the TIFF binary structure yourself — most image libraries only handle reading, not writing multi-page TIFFs.&lt;/p&gt;

&lt;p&gt;Common workarounds people try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ImageMagick&lt;/strong&gt; via &lt;code&gt;child_process&lt;/code&gt;— works but requires ImageMagick installed, breaks in serverless&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sharp&lt;/code&gt; — great library but doesn't support writing multi-page TIFFs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tiff-multipage&lt;/code&gt; — last published 3 years ago, minimal maintenance&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tiff-to-png&lt;/code&gt; — converts TIFF to PNG, not the other way&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Solution&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm install multi-page-tiff&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;imagesToTiff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imagesToTiffBuffer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;multi-page-tiff&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;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./page1.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./page2.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./page3.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;imagesToTiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt; &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;output.tiff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Get a Buffer (useful for uploading, streaming, etc.)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tiffBuffer&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;imagesToTiffBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./output.tiff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tiffBuffer&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. No native binaries, no system dependencies. Works on AWS Lambda, Vercel, Docker, Windows — anywhere Node.js runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Cases&lt;/strong&gt;&lt;br&gt;
Multi-page TIFFs are common in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fax systems — fax files are almost always multi-page TIFFs&lt;/li&gt;
&lt;li&gt;Document scanning — scanners output multi-page TIFFs&lt;/li&gt;
&lt;li&gt;Medical imaging — DICOM-adjacent workflows often use TIFF&lt;/li&gt;
&lt;li&gt;Legal/archival — PDF alternatives for scanned documents&lt;/li&gt;
&lt;li&gt;Insurance and banking — document submission pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why Not Just Use PDF?&lt;/strong&gt;&lt;br&gt;
TIFF is lossless, supports higher bit depths (16-bit, 32-bit), and is required in many enterprise, government, and medical workflows where PDF is not accepted. If your system specification says TIFF, there's usually no substitute.&lt;/p&gt;

&lt;p&gt;Links&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/multi-page-tiff" rel="noopener noreferrer"&gt;npmjs.com/package/multi-page-tiff&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/yatindavra/multi-page-tiff" rel="noopener noreferrer"&gt;github.com/yatindavra/multi-page-tiff&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've been fighting with multi-page TIFF generation in Node.js, give it a try and let me know in the comments if you run into any issues.&lt;br&gt;
check my work at &lt;a href="https://yatindavra.vercel.app" rel="noopener noreferrer"&gt;https://yatindavra.vercel.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>npm</category>
    </item>
  </channel>
</rss>
