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

Source: https://voxgig.com/howto/spectral-lint-loop-for-a-coding-agent

- Audience: api-producer
- Level: beginner
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://code.claude.com/docs/en/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](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-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](https://cursor.com/docs/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](https://redocly.com/docs/cli/commands/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](https://docs.stoplight.io/docs/spectral/9ffa04e052cc1-spectral-cli) | 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](https://quobix.com/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.

```yaml title=".spectral.yaml"
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`.

```bash
npx spectral lint fixtures/openapi.missing-operation-id.yaml
```

```text output
 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`](https://code.claude.com/docs/en/hooks) `Edit|Write` fires it after either tool, and
`$CLAUDE_PROJECT_DIR` points at the project root wherever the session started.

```json title=".claude/settings.json"
{
  "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.

```js title=".claude/hooks/spectral-lint.mjs"
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.

```bash
echo '{"tool_name":"Edit","tool_input":{"file_path":"fixtures/openapi.missing-operation-id.yaml"}}' | node .claude/hooks/spectral-lint.mjs
```

```text output
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`.

```bash
node simulate.mjs
```

```text output
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.

```bash
node --test hook.test.mjs
```

```text output
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.

```markdown title=".cursor/rules/openapi.mdc"
---
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`.

```markdown title="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](https://cursor.com/docs/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.

```bash
npx redocly lint fixtures/openapi.missing-operation-id.yaml --format=stylish
```

```text output
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](https://quobix.com/vacuum/rulesets/understanding/) 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.

```bash
node simulate.mjs naive-settings.json
```

```text output
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

- [Fill missing schemas and examples with a coding agent](/howto/fill-missing-schemas-and-examples-with-an-agent)

- [Stop a coding agent inventing endpoints not in the spec](/howto/stop-a-coding-agent-inventing-endpoints)

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

## 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 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](https://code.claude.com/docs/en/hooks) calls 2 a blocking
error, and on `PostToolUse`, where the tool has already run, there is nothing left to block.

[^2]: The [vacuum home page](https://quobix.com/vacuum/) 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.

[^3]: [POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/xargs.html) lets `xargs`
return any status from 1 to 125 when an invocation fails, and the GNU `xargs` on this machine chose
123. 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.