Lint your Claude Code subagent files before commit, because a typo makes them quietly do nothing
A 70-line script, a pre-commit hook and one CI step catch the frontmatter mistakes Claude Code skips without telling you. About 15 minutes to set up.
Part of the hooks reading guide

A teammate adds a reviewer subagent to the repo. It reads well, the pull request gets approved, and from then on nobody notices that the agent never has the tool restrictions they wrote, or never loads at all. One field was spelled maxturns. Another had a model name with a stray letter on the end.
Claude Code doesn’t complain about either. According to the sub-agents docs, unknown fields are ignored without an error, and a file with no name is treated as documentation and skipped. The checks that do exist write to a debug log nobody reads.
The fix is a script that reads that log for you, plus a few checks the log doesn’t make, wired into a git hook and a CI job so a bad agent file can’t reach main. Start to finish it takes about 15 minutes, needs Python 3 and the claude binary you already have, and starts no conversation.
What fails quietly
The sub-agents docs list the supported frontmatter fields: name and description are required, and tools, model, effort, isolation, permissionMode, autoCompactWindow, maxTurns and many others are optional. Field names are case-sensitive camelCase.
Here’s one file with five separate mistakes in it:
---
name: reviewer
description: Reviews diffs for bugs
tools: Read, Grepp
model: sonnett
effort: hgih
isolation: worktre
autoCompactWindow: 200k
colour: red
---
Review the diff.
Start Claude Code in that directory and nothing appears on screen. Nothing tells the author that the agent differs from what they wrote. To see what Claude Code noticed, run its startup path with a debug file and no conversation:
claude --init-only --debug-file /tmp/claude-debug.log </dev/null
grep -E 'agents/' /tmp/claude-debug.log
--init-only is documented as running the Setup and SessionStart hooks and then exiting without starting a conversation. --debug-file writes the debug log to a path you choose. Between them you get Claude Code’s own validation, in about a second, with no API call. The grep prints:
[WARN] Agent file .claude/agents/reviewer.md has a frontmatter key Claude Code does not recognize or apply: "colour" (did you mean "color"?)
[DEBUG] Agent file .claude/agents/reviewer.md has invalid isolation value 'worktre'. Valid options: worktree, remote
[DEBUG] Agent file .claude/agents/reviewer.md has invalid effort 'hgih'. Valid options: low, medium, high, xhigh, max or an integer
[WARN] Agent file .claude/agents/reviewer.md has invalid autoCompactWindow '200k'. Must be an integer from 100000 to 1000000.
(Timestamps and the absolute path are trimmed.) Four of the five mistakes show up. The two that don’t are model: sonnett and tools: Grepp: the log has no line for either. Neither value is checked at load time, so a typo there stays invisible until someone notices the agent behaving oddly.
The docs also warn about the 200k line in particular. YAML frontmatter reads 200k as the number 200, which is out of range, so the value has to be written as a plain integer: 200000.
Other mistakes are even quieter. Delete the name line and the log says nothing, because that’s the documented “treat it as documentation” path. A name containing a colon is rejected with an error line, since colons are reserved for plugin-scoped names. A frontmatter block that doesn’t parse as YAML is logged as ignored.
Why not claude plugin validate
There’s a built-in command for this: claude plugin validate <directory>, which the docs say checks the directory you name for frontmatter that doesn’t parse (it needs v2.1.233 or later). Point it at .claude/agents and it does catch a broken YAML block, and exits 1.
It’s not enough on its own. Run it, with and without --strict, against a file containing model: sonnett, maxturns: 5 and tools: Grepp:
Validating components in: .claude/agents
√ Validation passed
Both runs exit 0. The docs are explicit that it checks parse errors, so this is documented behaviour, not a bug. Keep it as a cheap first check if you like. The script below covers the rest.
The script
Save this as scripts/lint-agents.py. It does two passes. The first reads each agent file and checks the things Claude Code doesn’t: a missing name, a model that isn’t an alias or a full model ID, a tool name that isn’t a documented built-in, and a maxTurns that isn’t an integer. The second runs claude --init-only, collects every line in the debug log that mentions a file under .claude/agents/, and keeps the ones that describe a problem.
#!/usr/bin/env python3
"""Fail when a project subagent file would load wrongly, or not at all."""
import re, subprocess, sys, tempfile
from pathlib import Path
ROOT = Path.cwd().resolve()
AGENTS = ROOT / ".claude" / "agents"
MODELS = {"sonnet", "opus", "haiku", "fable", "inherit"}
TOOLS = set("""Agent AskUserQuestion Bash CronCreate CronDelete CronList Edit EnterPlanMode
EnterWorktree ExitPlanMode ExitWorktree Glob Grep ListMcpResourcesTool LSP Monitor NotebookEdit
PowerShell ReadMcpResourceTool Read Skill TaskCreate TaskGet TaskList TaskOutput TaskStop
TaskUpdate TodoWrite ToolSearch WebFetch WebSearch Write""".split())
problems = []
def flag(path, msg):
problems.append(f"{path.relative_to(ROOT)}: {msg}")
def frontmatter(path):
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
return None
fields = {}
for line in lines[1:]:
if line.strip() == "---":
return fields
m = re.match(r"^([A-Za-z]\w*):\s*(.*)$", line)
if m:
fields[m.group(1)] = m.group(2).strip().strip("\"'")
return None
# Pass 1: the mistakes Claude Code skips without a word.
for f in sorted(AGENTS.rglob("*.md")):
fm = frontmatter(f)
if fm is None:
flag(f, "no frontmatter block, so the file is skipped silently")
continue
if not fm.get("name"):
flag(f, "no name, so the file is treated as documentation and skipped")
model = fm.get("model")
if model and model not in MODELS and not model.startswith("claude-"):
flag(f, f"model '{model}' is not an alias or a full model ID")
for tool in filter(None, (t.strip() for t in fm.get("tools", "").split(","))):
base = tool.split("(")[0]
if base not in TOOLS and not base.startswith("mcp__"):
flag(f, f"tool '{tool}' is not a documented tool name")
if not fm.get("maxTurns", "1").isdigit():
flag(f, f"maxTurns '{fm['maxTurns']}' is not an integer")
# Pass 2: the checks Claude Code already makes, but only writes to its debug log.
with tempfile.TemporaryDirectory() as tmp:
log = Path(tmp) / "debug.log"
subprocess.run(["claude", "--init-only", "--debug-file", str(log)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, cwd=ROOT, check=False)
text = log.read_text(encoding="utf-8", errors="replace") if log.exists() else ""
pattern = re.compile(r"\[(WARN|ERROR|DEBUG)\] (.*" + re.escape(str(AGENTS)) + r"/.*)")
interesting = ("does not recognize", "invalid", "missing required",
"failed to parse", "Failed to parse")
for level, msg in pattern.findall(text):
if any(k in msg for k in interesting):
problems.append(msg.replace(str(ROOT) + "/", ""))
if problems:
print("\n".join(sorted(set(problems))))
sys.exit(1)
print("agents ok")
A few choices in there are worth knowing about. The frontmatter reader is deliberately naive: it handles flat key: value lines, which is what almost every agent file uses, and ignores anything indented. A tools: field written as a YAML list therefore skips the tool-name check, though pass 2 still runs. If your files use lists heavily, swap in PyYAML.
The tool list is the set of built-in names from the tools reference, which describes them as the exact strings used in subagent tool lists. Bash(git diff *) is reduced to Bash before the check, and anything starting mcp__ is passed through, because MCP tool names depend on your servers. The tool list will age. When a release adds a tool, a legitimate agent file fails the lint and the fix is one word in the set, which is the right amount of friction.
The log filter matches the absolute path of your project’s .claude/agents directory, so user-level agents in ~/.claude/agents don’t produce noise in someone else’s repo.
Run it against the broken file
With the five-mistake file from earlier plus a second file that has no name, the script prints:
.claude/agents/noname.md: no name, so the file is treated as documentation and skipped
.claude/agents/reviewer.md: model 'sonnett' is not an alias or a full model ID
.claude/agents/reviewer.md: tool 'Grepp' is not a documented tool name
Agent file .claude/agents/reviewer.md has a frontmatter key Claude Code does not recognize or apply: "colour" (did you mean "color"?)
Agent file .claude/agents/reviewer.md has invalid autoCompactWindow '200k'. Must be an integer from 100000 to 1000000.
Agent file .claude/agents/reviewer.md has invalid effort 'hgih'. Valid options: low, medium, high, xhigh, max or an integer
Agent file .claude/agents/reviewer.md has invalid isolation value 'worktre'. Valid options: worktree, remote
The exit code is 1. Every mistake in the file is reported in one run, so you fix them together rather than discovering them one commit at a time. On a clean directory it prints agents ok and exits 0 in about a second.
The script is also useful as a reviewer of other people’s agent files. Pull a branch, run it, and the output tells you which fields the author thought were doing something.
Block the commit
A git hook is the cheapest place to enforce it. This one only runs the linter when an agent file is staged, so commits that don’t touch agents pay nothing:
mkdir -p .githooks
cat > .githooks/pre-commit <<'EOF'
#!/bin/sh
# Lint subagent definitions only when one is staged.
if git diff --cached --name-only | grep -q '^\.claude/agents/'; then
python3 scripts/lint-agents.py || {
echo "Subagent definitions failed the lint. Commit aborted." >&2
exit 1
}
fi
EOF
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
core.hooksPath points git at a directory that lives in the repo, so the hook is versioned and reviewable, unlike anything under .git/hooks. Each person still has to run the git config line once. If the repo already uses Husky or pre-commit, add the if block to the hook you have.
Here it is working. A new reviewer.md with maxturns: 8 and autoCompactWindow: 200k is staged and committed:
Agent file .claude/agents/reviewer.md has a frontmatter key Claude Code does not recognize or apply: "maxturns" (did you mean "maxTurns"?)
Agent file .claude/agents/reviewer.md has invalid autoCompactWindow '200k'. Must be an integer from 100000 to 1000000.
Subagent definitions failed the lint. Commit aborted.
The commit exits 1 and nothing lands in history. Fix the two fields, stage again, and the same commit goes through.
Block the pull request
Hooks are advisory: git commit --no-verify skips them, and a contributor may never have run the git config line. CI is the enforcement. A GitHub Actions job that installs the binary from npm (the setup docs give npm install -g @anthropic-ai/claude-code, which needs Node.js 22 or later) and runs the same script:
name: lint-agents
on:
pull_request:
paths:
- ".claude/agents/**"
- "scripts/lint-agents.py"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g @anthropic-ai/claude-code
- run: python3 scripts/lint-agents.py
This workflow is written from the documentation and wasn’t run on a hosted runner. What was verified is the part that could be a problem in CI: the script ran correctly with a throwaway empty home directory and no environment variables beyond PATH, with no login and no API key, and it still printed agents ok. The startup path being used makes no model call, so the job needs no secrets.
The paths filter keeps the job off unrelated pull requests.
Variations
Pin the version. The log wording this script depends on is not a documented format, and the docs only promise the does not recognize or apply message for v2.1.296 or later. Install a specific version in CI (npm install -g @anthropic-ai/claude-code@2.1.302) and bump it on purpose, so a wording change in a future release shows up as a pull request that edits the pin rather than a lint that silently stops matching.
Keep the script honest with a fixture. Commit one deliberately broken file under a path the linter doesn’t scan, and a test that copies it into a temporary .claude/agents and asserts the script exits 1. When Claude Code changes its log lines and the second pass stops catching anything, the test fails instead of the linter going green forever.
Lint skills and commands too. The same trick applies to any file Claude Code loads at startup and validates into the debug log. Widen the path in the pattern and read what your own log says about .claude/skills before trusting it.
What goes wrong
The biggest caveat is that --init-only runs your Setup and SessionStart hooks, as the CLI reference says. If the project defines a SessionStart hook that does something slow or has side effects, the linter runs it too, in CI as well as locally. Keep those hooks cheap, or run the linter with a settings override that has none.
The second is that this is log scraping. The debug log is not an API, the messages above were observed on 2.1.302, and a release can reword them. The interesting keyword list is the thing to adjust when that happens, and the fixture above is how you find out.
The third is the allowlists. The model check accepts any string beginning claude- because there’s no list of valid IDs to check against offline, so claude-sonet-5-5 passes. Only the aliases are properly policed.
Don’t bother if a single person owns the repo and the agents change twice a year. Open the file and read it. This pays off when several people edit .claude/agents, or when agent files are generated by something and nobody reads the output.
The first 15 minutes
Run claude --init-only --debug-file /tmp/d.log </dev/null && grep -E 'agents/' /tmp/d.log in a repo that already has subagents. If it prints anything, that’s a file doing less than its author thinks. Then save the script, wire the hook, and commit the workflow.