How-to › Describe your API

How to set up a Spectral lint loop for an agent editing OpenAPI#

Run Spectral on every OpenAPI edit a coding agent makes, through a Claude Code hook or a Cursor or Copilot instruction, so the agent fixes errors before moving on.

Audience
API producer
Level
beginner
Topic
Author or repair a spec with a coding agent
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Claude Code hooksThe agent is Claude Code, and you want the check to run without askingA script you maintain, and edits made through Bash do not trigger itYour team uses several agents and wants one mechanism
Copilot instructionsCopilot edits the spec, in the editor or as a cloud agentAn instruction the model may skip, since nothing runs the command for itYou need a check that cannot be forgotten
Cursor rulesCursor edits the spec, and a rule attached by glob is enoughAnother instruction, read when an OpenAPI file is in context, never enforcedYou can use Cursor’s own hooks instead
Redocly CLI lintYou already bundle and build docs with RedoclyA second rule vocabulary, and a recommended set that adds its own errorsYour rules are written for Spectral
SpectralYou want published rulesets to extend, and custom functions for rules of your ownAbout 1.5 seconds on a generated 2,001-operation document here, against 0.4 for vacuumLint time is longer than the agent’s patience
vacuumThe spec is large and lint time matters on every editA Go binary, and different findings from the same Spectral rulesetYou 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.

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.

  1. 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

  1. 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 on PostToolUse, where the tool has already run, there is nothing left to block. ↩︎ Back to text

  2. 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

  3. POSIX lets xargs return any status from 1 to 125 when an invocation fails, and the GNU xargs on this machine chose ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.