<?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: Anguardia</title>
    <description>The latest articles on DEV Community by Anguardia (@anguardia).</description>
    <link>https://dev.to/anguardia</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%2F3898513%2F7a750544-e77c-4b75-a743-f7076ceef62d.png</url>
      <title>DEV Community: Anguardia</title>
      <link>https://dev.to/anguardia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anguardia"/>
    <language>en</language>
    <item>
      <title>Designing a parser contract for AI output (not just a prompt)</title>
      <dc:creator>Anguardia</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:41:41 +0000</pubDate>
      <link>https://dev.to/anguardia/designing-a-parser-contract-for-ai-output-not-just-a-prompt-8gd</link>
      <guid>https://dev.to/anguardia/designing-a-parser-contract-for-ai-output-not-just-a-prompt-8gd</guid>
      <description>&lt;p&gt;Most posts about getting structured data out of an LLM stop at the prompt: ask for JSON, maybe hand it a schema, done. That's necessary but not sufficient — the harder problem shows up on the other end, in the code that has to trust what came back. I hit this building the import pipeline for a CRM (&lt;a href="https://anguardia.com" rel="noopener noreferrer"&gt;Anguardia&lt;/a&gt;) that reads AI-generated prospect research, and the parser ended up teaching me more than the prompt did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format isn't the hard part
&lt;/h2&gt;

&lt;p&gt;The prompt asks for a fixed markdown shape — headings, a table, checkbox tasks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- anguardia-dossier v1 --&amp;gt;&lt;/span&gt;
&lt;span class="gh"&gt;# Dossier: &amp;lt;Company Name&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Company&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Industry: &lt;span class="nt"&gt;&amp;lt;industry&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Website: &lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Location: &lt;span class="nt"&gt;&amp;lt;city&lt;/span&gt; &lt;span class="na"&gt;or&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Source: &lt;span class="nt"&gt;&amp;lt;cold&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="na"&gt;referral&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="na"&gt;inbound&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="na"&gt;research&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## People&lt;/span&gt;
| Name | Role | Email | Phone | LinkedIn |
|------|------|-------|-------|----------|

&lt;span class="gu"&gt;## Suggested tasks&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] &lt;span class="nt"&gt;&amp;lt;task&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; | due: &lt;span class="nt"&gt;&amp;lt;YYYY-MM-DD&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;optional&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Suggested outreach&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;the&lt;/span&gt; &lt;span class="na"&gt;first&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;under&lt;/span&gt; &lt;span class="err"&gt;150&lt;/span&gt; &lt;span class="na"&gt;words&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the easy 80%. Any capable model follows a structure like this reliably. The interesting decisions all live in the parser that reads it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: the parser never throws
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Deterministic Dossier v1 parse. Always returns a dossier object + warnings. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseDossier&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ParseResult&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;warnings&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;=&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;hasMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;textContainsDossierMarker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;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;hasMarker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Dossier marker not detected. Parsing best-effort.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... parsing continues regardless&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;parseDossier has no failure mode — it always returns a dossier object and a warnings array, even for input that doesn't look like a dossier at all. A model's output is not a contract you control, so treating a malformed dossier as an error case just means building a second, worse UI for "sorry, try again." Best-effort parsing plus visible warnings does the same job without the dead end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: unknown fields are dropped, not guessed
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;KNOWN_COMPANY_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unknown company field ignored: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bullet&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&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="k"&gt;continue&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;A model will occasionally add a field nobody asked for, or misspell one. Silently coercing it into the nearest known field is how you end up with a company's Slack handle stored as its website. The parser drops anything it doesn't recognize and says so — a warning the user can see, not a guess they can't.&lt;/p&gt;

&lt;p&gt;Same logic on malformed data that did land in the right field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DATE_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dueRaw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dueDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dueRaw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Malformed due date ignored: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dueRaw&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;A due date that isn't YYYY-MM-DD doesn't get parsed loosely — it gets dropped, with a warning. The alternative (a fuzzy date parser trying to make sense of whatever the model wrote) fails in a way nobody notices until a task has the wrong due date silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: "never invent" is a prompt instruction and a parser rule
&lt;/h2&gt;

&lt;p&gt;The prompt tells the model not to guess contact details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Never invent. Leave any field blank if you cannot verify it from a real source.
Do not guess emails, phone numbers, or names. Blank is always better than plausible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's necessary but it's a request, not a guarantee — nothing stops a model from ignoring it. So the parser is built the same way independently: a blank table cell stays null, not an empty string coerced into something that looks like data. Two independent layers agreeing "blank beats plausible" is worth more than either one alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 4: version the format with a marker, not a schema migration
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DOSSIER_MARKER_LEGACY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;!-- founder-os-dossier v1 --&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DOSSIER_MARKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;!-- anguardia-dossier v1 --&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DOSSIER_MARKERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;DOSSIER_MARKER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DOSSIER_MARKER_LEGACY&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The product's name changed after the format shipped. Rather than migrate every dossier anyone had already generated, the parser just accepts both markers indefinitely. A one-line HTML comment on the first line is a cheap, durable version tag — cheaper than a schema registry, and it survives a rebrand without anyone having to regenerate old research.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, generalized
&lt;/h2&gt;

&lt;p&gt;If you're parsing anything an LLM produces and acting on it automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Never throw on malformed input — return a result plus diagnostics, always&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reject and report unknown data, don't coerce it — a warning is recoverable, a wrong guess isn't&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Match your prompt's honesty constraints in the parser — don't rely on the model alone to keep a promise&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Version the format at the boundary (a marker, a header), not by trying to migrate every past output&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is specific to CRMs or prospect research — it's the same shape for any pipeline where a model's output becomes a record something else acts on. The prompt gets the output roughly right most of the time. The parser is what makes "most of the time" safe to automate.&lt;/p&gt;




&lt;p&gt;If you want to see the actual prompt this parses: &lt;a href="https://anguardia.com/prospect-research-prompt" rel="noopener noreferrer"&gt;the free Dossier v1 prompt&lt;/a&gt; — paste it into Claude, ChatGPT, or any model with a company name, no signup required.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
