<?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 (@yatindavra23).</description>
    <link>https://dev.to/yatindavra23</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/yatindavra23</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yatindavra23"/>
    <language>en</language>
    <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/yatindavra23/i-built-a-slot-filling-onboarding-bot-it-leaked-a-validation-error-into-the-chat-e4g</link>
      <guid>https://dev.to/yatindavra23/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/yatindavra23/i-had-a-gbnf-grammar-file-and-nowhere-to-point-it-so-i-added-support-for-it-3ln2</link>
      <guid>https://dev.to/yatindavra23/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/yatindavra23/how-i-ended-up-contributing-five-ways-to-shape-an-llms-output-18cf</link>
      <guid>https://dev.to/yatindavra23/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/yatindavra23/how-to-create-multi-page-tiff-files-in-nodejs-without-imagemagick-im0</link>
      <guid>https://dev.to/yatindavra23/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>
