cd /news/artificial-intelligence/designing-a-parser-contract-for-ai-o… Β· home β€Ί topics β€Ί artificial-intelligence β€Ί article
[ARTICLE Β· art-90384] src=dev.to β†— pub= topic=artificial-intelligence verified=true sentiment=Β· neutral

Designing a parser contract for AI output (not just a prompt)

Anguardia's import pipeline for AI-generated prospect research uses a deterministic parser that never fails, instead returning warnings for malformed or unrecognized data. The parser accepts legacy markers and drops unknown fields or invalid dates with visible warnings, treating blank as better than plausible. This approach ensures that AI output is handled reliably without dead ends.

read4 min views1 publishedAug 10, 2026

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 (Anguardia) that reads AI-generated prospect research, and the parser ended up teaching me more than the prompt did.

The prompt asks for a fixed markdown shape β€” headings, a table, checkbox tasks:

<!-- anguardia-dossier v1 -->

## Company
- Industry: <industry>
- Website: <url>
- Location: <city or region>
- Source: <cold | referral | inbound | research>

## People
| Name | Role | Email | Phone | LinkedIn |
|------|------|-------|-------|----------|

## Suggested tasks
- [ ] <task title> | due: <YYYY-MM-DD, optional>

## Suggested outreach
<the first message, under 150 words>

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.

/** Deterministic Dossier v1 parse. Always returns a dossier object + warnings. */
export function parseDossier(text: string): ParseResult {
  const warnings: string[] = [];
  const hasMarker = textContainsDossierMarker(content);

  if (!hasMarker) {
    warnings.push("Dossier marker not detected. Parsing best-effort.");
  }
  // ... parsing continues regardless

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.

if (!KNOWN_COMPANY_KEYS.has(key)) {
  warnings.push(`Unknown company field ignored: ${bullet[1].trim()}`);
  continue;
}

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.

Same logic on malformed data that did land in the right field:

if (DATE_RE.test(dueRaw)) {
  dueDate = dueRaw;
} else {
  warnings.push(`Malformed due date ignored: ${dueRaw}`);
}

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.

The prompt tells the model not to guess contact details:

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.

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.

export const DOSSIER_MARKER_LEGACY = "<!-- founder-os-dossier v1 -->";
export const DOSSIER_MARKER = "<!-- anguardia-dossier v1 -->";
export const DOSSIER_MARKERS = [DOSSIER_MARKER, DOSSIER_MARKER_LEGACY] as const;

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.

If you're parsing anything an LLM produces and acting on it automatically:

Never throw on malformed input β€” return a result plus diagnostics, always

Reject and report unknown data, don't coerce it β€” a warning is recoverable, a wrong guess isn't

Match your prompt's honesty constraints in the parser β€” don't rely on the model alone to keep a promise

Version the format at the boundary (a marker, a header), not by trying to migrate every past output

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.

If you want to see the actual prompt this parses: the free Dossier v1 prompt β€” paste it into Claude, ChatGPT, or any model with a company name, no signup required.

── more in #artificial-intelligence 4 stories Β· sorted by recency
── more on @anguardia 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/designing-a-parser-c…] indexed:0 read:4min 2026-08-10 Β· β€”