Claude Code hook: validate frontmatter on every Edit

Recipe #3 in the cookbook, after the starter CWD guard and the skill-activation logger. 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:

{
  "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:

#!/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 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.