# Claude Code hook: validate frontmatter on every Edit

> A PostToolUse hook validating blog and skill frontmatter against the same zod shape Astro uses at build. Real config, two live blocks captured.

**Canonical URL**: https://agentcookbooks.com/blog/claude-code-frontmatter-guard-hook/

**Published**: 2026-04-30

**Tags**: claude-code, hooks, cookbook

---

Recipe #3 in the cookbook, after [the starter CWD guard](/blog/claude-code-hooks-cookbook/) and the [skill-activation logger](/blog/claude-code-skill-activation-hook/). A `PostToolUse` hook on `Edit|Write|MultiEdit` that validates Astro content frontmatter the moment a content file is touched — instead of waiting for `astro build` to fail at the end of a session. The hook checks the same shape `src/content.config.ts` enforces at build (required fields present, `pubDate` parses as a date, `draft` is a boolean), but as a dependency-free Node script that runs in milliseconds. On a structural failure it exits `2` with a structured `decision: block` reason — Claude reads the stderr and self-corrects on the next turn. Two live blocks captured this same session, both reproduced verbatim below: a `pubDate: tomorrow` from a model freeform date, and a missing `description` after a sloppy multi-line edit.

## The hook: validate content frontmatter at edit-time

Goal: every time Claude Code edits a file under `src/content/blog/` or `src/content/skills/`, parse the frontmatter and check the required fields against the same shape `src/content.config.ts` enforces at build. If anything is off, exit `2` with a structured `decision: block` reason. Claude reads the stderr, sees what's wrong, and self-corrects on the next turn.

This is the cheapest possible "code quality" hook for a content site: zod runs anyway at build, but build runs once at the end of a 20-minute session. By the time it fails, the model has moved on to other files. Hoisting the check to edit-time turns a "find out later" failure into a "fix it now" failure.

`.claude/settings.json`:

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/check-frontmatter.mjs",
            "timeout": 5
          }
        ]
      }
    ]
  }
}
```

`.claude/hooks/check-frontmatter.mjs` — reads the hook payload, exits early on tool calls outside the two content collections, parses the frontmatter, and blocks on missing required fields, unparseable `pubDate`, non-boolean `draft`, or unknown `receiptsStatus` values:

```js
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve, sep, posix } from 'node:path';

const REQUIRED_BLOG = ['title', 'description', 'pubDate'];
const REQUIRED_SKILL = ['title', 'description', 'useCase', 'pubDate'];

let raw = '';
process.stdin.on('data', (c) => (raw += c));
process.stdin.on('end', () => {
  let payload;
  try { payload = JSON.parse(raw); } catch { process.exit(0); }

  if (!['Edit', 'Write', 'MultiEdit'].includes(payload.tool_name)) process.exit(0);

  const filePath = payload.tool_input?.file_path;
  if (!filePath) process.exit(0);

  const norm = filePath.split(sep).join(posix.sep);
  const isBlog = /\/src\/content\/blog\/[^/]+\.mdx?$/.test(norm);
  const isSkill = /\/src\/content\/skills\/[^/]+\.mdx?$/.test(norm);
  if (!isBlog && !isSkill) process.exit(0);

  const body = readFileSync(resolve(filePath), 'utf8');
  const fm = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!fm) block('frontmatter block missing — file must start with --- ... ---');

  const fields = parseFrontmatter(fm[1]);
  const required = isBlog ? REQUIRED_BLOG : REQUIRED_SKILL;
  const missing = required.filter((k) => !(k in fields) || fields[k] === '');
  if (missing.length) {
    block(`missing required frontmatter field(s): ${missing.join(', ')}`);
  }

  if (Number.isNaN(new Date(fields.pubDate).getTime())) {
    block(`pubDate "${fields.pubDate}" does not parse as a date — use YYYY-MM-DD`);
  }

  process.exit(0);
});

function parseFrontmatter(yaml) {
  const out = {};
  for (const line of yaml.split(/\r?\n/)) {
    const m = line.match(/^([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)$/);
    if (!m) continue;
    out[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
  }
  return out;
}

function block(reason) {
  process.stderr.write(JSON.stringify({ decision: 'block', reason }) + '\n');
  process.exit(2);
}
```

The script is dependency-free on purpose. Frontmatter in this project is a well-constrained subset of YAML — `key: value` pairs, the occasional inline array — so a 10-line regex parser is enough to catch the failure modes that actually happen in authoring. The full zod schema still runs at build; the hook is a cheap pre-filter, not a replacement.

## Two live blocks, captured this session

Both came from triggering the hook on a deliberate test file in this same session it was installed.

**1. `pubDate: tomorrow` — the model's freeform date.**

```
PostToolUse:Write hook blocking error from command:
"node .claude/hooks/check-frontmatter.mjs":
{"decision":"block","reason":"pubDate \"tomorrow\" does not parse as a date — use YYYY-MM-DD"}
```

The reason field surfaces directly as feedback. One Edit later — `pubDate: 2026-04-30` — the hook passes silently. This is the entire self-correct loop: structured stderr in, fix on the next turn, no human in the middle.

**2. Missing `description` after a sloppy multi-line Edit.**

```
PostToolUse:Edit hook blocking error from command:
"node .claude/hooks/check-frontmatter.mjs":
{"decision":"block","reason":"missing required frontmatter field(s): description"}
```

This is the failure mode that motivated the hook in the first place. Without it, a missing `description` on a blog post slips through every check until `astro build` runs at the end of the session, by which point the model is three files past the bug. The hook turns a 20-minute round-trip into a one-turn correction.

## What this hook does not catch

Worth being honest about the gap. The hook checks **presence of required fields**, **`pubDate` parses as a date**, **`draft` is a boolean**, and **`receiptsStatus` is one of the three allowed values**. It does *not* re-implement zod. So:

- A typo in a `tags` array (`tags: claude-code` instead of `tags: ["claude-code"]`) won't block — zod will catch it at build.
- A `sourceUrl` that isn't a valid URL won't block — zod will catch it at build.
- A `hero` path pointing at a missing file won't block — that's a runtime concern, not a frontmatter concern.

The hook is deliberately under-spec relative to zod. It's a cheap edit-time filter for the failure modes that are common, plain-text, and detectable without parsing YAML properly. Anything that needs the full schema still gets caught at build — just later.

## Watcher caveat (still relevant)

The watcher quirk from [recipe #2](/blog/claude-code-skill-activation-hook/) applies here too: if `.claude/settings.json` didn't exist at session start, adding it mid-session won't be picked up until you open `/hooks` or restart Claude Code. In this case `settings.json` already existed (it had the `Skill` matcher from recipe #2), so the new `Edit|Write|MultiEdit` matcher was picked up live — both blocks above came from the same session the hook was installed in. If the hook seems silent on a fresh project, that's the watcher, not the script.

## What's next

Nine more hooks lined up. Each one ships only after a real run on real code. Subscribe to RSS for the rest.