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

> Source: <https://dev.to/anguardia/designing-a-parser-contract-for-ai-output-not-just-a-prompt-8gd>
> Published: 2026-08-10 12:41:41+00:00

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](https://anguardia.com)) 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:

``` php
<!-- 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>
```

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.

``` php
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](https://anguardia.com/prospect-research-prompt) — paste it into Claude, ChatGPT, or any model with a company name, no signup required.
