DEV Community

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

Posted on

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

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 format isn't the hard part

The prompt asks for a fixed markdown shape — headings, a table, checkbox tasks:

<!-- anguardia-dossier v1 -->
# Dossier: <Company Name>

## 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>
Enter fullscreen mode Exit fullscreen mode

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.

Decision 1: the parser never throws

/** 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
Enter fullscreen mode Exit fullscreen mode

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.

Decision 2: unknown fields are dropped, not guessed

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

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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.

Decision 3: "never invent" is a prompt instruction and a parser rule

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.
Enter fullscreen mode Exit fullscreen mode

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.

Decision 4: version the format with a marker, not a schema migration

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;
Enter fullscreen mode Exit fullscreen mode

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.

The pattern, generalized

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.

Top comments (0)