You ask a coding agent to add POST /orders to openapi.yaml. It writes a tidy operation with no
operationId, reports success, and moves on to the handler. Your SDK generator invents a method
name from the path, and CI fails an hour later. The forty rules in your instruction file did not
help, because nothing checked the edit while the agent was still on it.
What you get
You will end up with a ruleset in the repository, a hook that lints each OpenAPI edit and hands the errors back to the agent, and instruction files for Cursor and Copilot. This is for you if a coding agent edits your spec.
Short answer
Check a Spectral ruleset into the repository and add a Claude Code PostToolUse hook, matched on
Edit|Write, that lints the file the agent just changed. When Spectral finds errors, the hook
prints them to stderr and exits 2, which makes Claude Code show them to Claude. For Cursor and
Copilot, a short instruction file names the same lint command, the ruleset, and one golden
operation.
You will need
Node 22 or later, an OpenAPI 3.1 document, even a stub, and a coding agent such as Claude Code, Cursor, or Copilot. Verified against Node 22.22.0. No model was called for this page: the agent in the transcripts is a scripted stand-in, and the page says where.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Claude Code hooks | The agent is Claude Code, and you want the check to run without asking | A script you maintain, and edits made through Bash do not trigger it | Your team uses several agents and wants one mechanism |
| Copilot instructions | Copilot edits the spec, in the editor or as a cloud agent | An instruction the model may skip, since nothing runs the command for it | You need a check that cannot be forgotten |
| Cursor rules | Cursor edits the spec, and a rule attached by glob is enough | Another instruction, read when an OpenAPI file is in context, never enforced | You can use Cursor’s own hooks instead |
| Redocly CLI lint | You already bundle and build docs with Redocly | A second rule vocabulary, and a recommended set that adds its own errors | Your rules are written for Spectral |
| Spectral | You want published rulesets to extend, and custom functions for rules of your own | About 1.5 seconds on a generated 2,001-operation document here, against 0.4 for vacuum | Lint time is longer than the agent’s patience |
| vacuum | The spec is large and lint time matters on every edit | A Go binary, and different findings from the same Spectral ruleset | You rely on the exact behavior of the Spectral rules |
The hook is the only row that runs whether or not the model remembers to. Cursor and Copilot instructions cost nothing to add but depend on the agent obeying them. Among the engines, Spectral has the rulesets, vacuum has the speed, and Redocly has the other commands you may already run.
Check the ruleset in
Put the rules in a file the agent can read, not in prose. This one extends the OpenAPI rules Spectral ships, raises three to errors, and adds a naming rule whose message names the golden operation.
extends: [[spectral:oas, recommended]]
rules:
operation-operationId: error
operation-description: error
operation-tags: error
operation-id-camel-case:
description: operationId is lowerCamelCase and starts with a verb, like getOrder
message: "{{value}} is not lowerCamelCase; copy the shape of getOrder"
given: "$.paths[*][get,put,post,delete,patch].operationId"
severity: error
then:
function: pattern
functionOptions:
match: "^(get|list|create|update|delete)[A-Z][A-Za-z0-9]*$"
Run it on a copy of the spec with an operation that has no operationId.
npx spectral lint fixtures/openapi.missing-operation-id.yaml
36:10 error operation-operationId Operation must have "operationId". paths./orders.post
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)
Lint every edit from a hook
Register the hook in .claude/settings.json. The
matcher Edit|Write fires it after either tool, and
$CLAUDE_PROJECT_DIR points at the project root wherever the session started.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/spectral-lint.mjs",
"timeout": 60
}
]
}
]
}
}
The script reads the hook’s JSON from stdin, skips any file that is not an OpenAPI document, and runs Spectral on the one that is.
const file = event.tool_input?.file_path
if (!file || !IS_SPEC.test(file)) process.exit(0)
function notLinted(why) {
console.error(`${relative(project, file)} was not linted: ${why}`)
console.error('Fix that first; until then every edit to the spec goes unchecked.')
process.exit(2)
}
if (!existsSync(SPECTRAL)) notLinted('Spectral is not installed here; run npm ci in the project root')
Exit code 2 carries the findings.1 The hooks reference says Claude never sees stderr from a
hook that exits 0, and that a PostToolUse hook should exit 2 to surface a warning. Printing JSON
with additionalContext and exiting 0 is the other route. A hook that cannot run Spectral exits 2
as well, because an unchecked edit the agent believes was checked is worse than a complaint.
Run the hook directly, as Claude Code would, against the spec with the violation.
echo '{"tool_name":"Edit","tool_input":{"file_path":"fixtures/openapi.missing-operation-id.yaml"}}' | node .claude/hooks/spectral-lint.mjs
Spectral found 1 error(s) in fixtures/openapi.missing-operation-id.yaml. Fix them before moving on.
fixtures/openapi.missing-operation-id.yaml:36 operation-operationId: Operation must have "operationId". (at paths./orders.post)
Rules: .spectral.yaml. Model new operations on GET /orders/{orderId} (operationId getOrder) in openapi.yaml.
The last line is the instruction file’s job, done at the moment it matters. It points at the ruleset and one operation to copy, rather than restating either.
Check it worked
Ask your agent to add an operation without an operationId and watch whether it fixes it
unprompted. The transcript here comes from simulate.mjs, a scripted stand-in for the agent. It
edits the file and runs the hooks exactly as .claude/settings.json configures them. It applies
its scripted fix only if the feedback names the missing operationId.
node simulate.mjs
settings: .claude/settings.json
turn 1 agent Edit work/openapi.yaml: add POST /orders
hook exited 2
agent sees:
Spectral found 1 error(s) in work/openapi.yaml. Fix them before moving on.
work/openapi.yaml:36 operation-operationId: Operation must have "operationId". (at paths./orders.post)
Rules: .spectral.yaml. Model new operations on GET /orders/{orderId} (operationId getOrder) in openapi.yaml.
turn 2 agent Edit work/openapi.yaml: add operationId createOrder, as getOrder has one
hook exited 0
agent sees: nothing
That proves the wiring, not the model. With a real agent, the success signal is turn 2 arriving
without a prompt from you. The hook’s own tests cover the golden spec, a snake_case id, a broken
ruleset, a missing install, and a cap of 15 findings per edit. One more feeds it 3,600 errors, a
report larger than the 1 MB that execFileSync buffers by default, which the hook raises to 64 MB.
node --test hook.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
Point the other agents at the same command
Neither has a hook in this setup, so the instruction names the command, the ruleset and the golden
operation, and nothing else. Cursor attaches a rule when a file matching its globs is in context,
and an @ path tells the agent which file to read when it needs the content.
---
description: Editing the OpenAPI document
globs: openapi*.yaml
alwaysApply: false
---
- After every edit to an OpenAPI file, run `npx spectral lint openapi.yaml` and fix each error before anything else.
- The rules are in @.spectral.yaml. Read that file; it is the only statement of them.
- Model a new operation on `getOrder` in @openapi.yaml: operationId, summary, description, tags, a 404.
For Copilot, the same three lines go in .github/copilot-instructions.md.
## OpenAPI
- After every edit to `openapi.yaml`, run `npx spectral lint openapi.yaml` and fix each error before anything else.
- The rules are in `.spectral.yaml`. Read that file; it is the only statement of them.
- Model a new operation on `getOrder` in `openapi.yaml`: operationId, summary, description, tags, a 404.
Cursor’s documentation asks for the same discipline: reference files rather than copying them, so
rules stay short. Cursor also has hooks, and its postToolUse
hook can return additional_context, which is the mechanical route if you need one.
Choose the engine
Redocly CLI finds the same missing operationId under its own rule, and its recommended set adds
security-defined errors Spectral did not raise.
npx redocly lint fixtures/openapi.missing-operation-id.yaml --format=stylish
fixtures/openapi.missing-operation-id.yaml:
36:5 error operation-operationId Operation object should contain `operationId` field.
15:5 error security-defined Every operation should have security defined on it or on the root level.
36:5 error security-defined Every operation should have security defined on it or on the root level.
vacuum was built from source and timed outside the captured runs, in the four-vCPU Linux
container this page was checked in, with other jobs running beside it. The document came from
make-big-spec.mjs: openapi.yaml plus 2,000 generated GET operations, every other one without
an operationId. Over five runs each, the median was 0.42 seconds for vacuum, 1.48 for Spectral
and 2.53 for Redocly.2 The same .spectral.yaml gave 1,000 findings in Spectral and 3,005 in
vacuum, 1,999 of them description-duplication notices about the repeated OK. Its
ruleset documentation explains why: it treats
spectral:oas as an alias for its own built-in rules.
When it goes wrong
The agent edits the spec, and the findings never reach it. A hook that runs Spectral directly exits with
the status Spectral returns, 1, which Claude Code treats as a non-blocking error and does not show to
Claude. This is that hook, jq -r .tool_input.file_path | xargs npx spectral lint, under the same
scripted agent.
node simulate.mjs naive-settings.json
settings: naive-settings.json
turn 1 agent Edit work/openapi.yaml: add POST /orders
hook exited 123 (non-blocking error)
agent sees: nothing
turn 2 agent stops: nothing told it the operation is incomplete
xargs reported 123 rather than 1.3 Any status other than 2 hides the findings, so map the
failing lint to exit 2 in a script, as spectral-lint.mjs does.
The hook never fires on some edits. Claude Code does not run a PostToolUse hook matched on
Edit|Write when the agent rewrites the file with sed through Bash. Lint in CI as well. The
script also skips a fragment of a split spec, such as schemas/Order.yaml, because IS_SPEC only
matches names that start with openapi. For a split spec, have the hook lint the root document
whenever a file under the spec’s directory changes.
When not to do this
Do not restate OpenAPI rules in the instruction file. Forty lines of prose cost context on every turn, and the agent weighs them against everything else it was told. A ruleset path and one golden operation cost three lines.
Do not hand the agent every warning. The hook reports errors only and caps them at 15, because a wall of findings invites the agent to rewrite the file rather than fix it.
Do not make the hook the only gate. It sees edits made through Edit and Write alone, and only
for the agent that has it configured, so run the same ruleset in CI.
Do not pick vacuum for speed on a small spec. On a stub the whole run is shorter than the agent’s next request, and the ruleset differences cost more than the seconds saved.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, @stoplight/spectral-cli 6.16.3 and @redocly/cli
2.54.3. Every output block is what the preceding command printed in the page’s code directory
after npm ci. No model was called. vacuum 0.33.1 was built with go install and timed in the same container.
- The 1 from Spectral went in, 123 came out, and Claude Code would have treated either one as a hook that broke rather than a spec that did.
Footnotes
-
The codes Spectral uses run the other way. It exits 1 when it finds errors. Run here against a broken ruleset, it printed
Error running Spectral!and exited 2. A hook that passed that status straight through would put its stderr in front of Claude only when nothing had been linted. The hooks reference calls 2 a blocking error, and onPostToolUse, where the tool has already run, there is nothing left to block. ↩︎ Back to text -
The vacuum home page is titled “The worlds fastest API quality tool.” Five runs on one shared machine cannot settle a superlative. They put vacuum at under a third of the Spectral time and about a sixth of the Redocly time on this document, which supports the comparative. ↩︎ Back to text
-
POSIX lets
xargsreturn any status from 1 to 125 when an invocation fails, and the GNUxargson this machine chose ↩︎ Back to text