# How to let a coding agent edit a config spec without breaking it

> Run counterexamples the spec must refuse beside vet, after every agent edit and at commit, so an agent that deletes a constraint to clear an error is stopped there.

Source: https://voxgig.com/howto/let-an-agent-edit-a-config-spec

- Audience: agent-builder
- Level: intermediate
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Keep a directory of counterexamples, instances the spec must refuse, each naming the path the refusal must come from, and run them in the same check that vets the data. Run that check from a Claude Code PostToolUse hook and from pre-commit. When an agent deletes a constraint to clear an error, vet passes, and a counterexample passes with it, which is what the check reports.

---
## You will need

Node 22 or later, aontu installed from npm, and an agent session that runs hooks or reaches an
MCP server. Verified 2026-10-15 against Node 22.22.0 and aontu 0.76.0. Wiring the aontu MCP
server and language server into a client is a task of its own, so this page assumes both are
connected. It also assumes you can read a contradiction report and know what it means for one
schema to subsume another.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [aontu language server and MCP server](https://aontu.dev/docs/reference-agents/) | The agent's client speaks LSP or MCP, and errors should arrive while it edits | Two more processes per session, and an LSP range that can point into the wrong file | The client passes neither diagnostics nor tool results to the model |
| [Claude Code PostToolUse hook](https://code.claude.com/docs/en/hooks) | The agent is Claude Code, and every edit should be checked as it lands | Ties the check to one agent, and it runs after every edit, so it must stay fast | Several agents and people edit the spec, so the check belongs in the repository |
| [CUE `cue vet` in CI](https://cuelang.org/docs/reference/command/cue-help-vet/) | The data is YAML the agent already edits, and CI is the gate | The agent hears about an error after a push, in a log it may never read | The agent needs the error in the same turn as the edit |
| [pre-commit hook](https://pre-commit.com/) | Any editor, any agent, one check at commit time | Silent until the commit, and `git commit --no-verify` skips it entirely | The agent should be corrected before it stacks edits on a bad one |

The language server answers first. Across three runs on the shared container that verified this
page, a diagnostic arrived 17 to 51 ms after each change, before anything was saved. The hook and
pre-commit run the same guard, which took 0.4 to 0.6 seconds as a process: the hook after every
edit, pre-commit at each commit. CI is minutes away and outside the agent's turn. All four check
data against the spec in front of them, so a deleted constraint gets past each one unless
counterexamples run beside it.

Voxgig maintains aontu. It is one of four options here, and the counterexample check works the
same with `cue vet`, as the CUE section shows.

## Give the spec counterexamples

The spec constrains every entry of `service` through the `&:` template.[^1]

```aontu title="spec.aontu"
# What every service entry must satisfy. An agent may add services, and may not loosen this.
service: {
  &: {
    owner: string
    port: integer & min(1024) & max(65535)
    replicas: integer & min(2)
  }
}
```

The data includes the spec, so the language server checks `services.aontu` against it while the
agent types.

```aontu title="services.aontu"
@"./spec.aontu"

service: {
  billing: { owner: "payments", port: 8443, replicas: 2 }
  search: { owner: "discovery", port: 9200, replicas: 3 }
}
```

A counterexample is an instance the spec must refuse, and its first line names the path the
refusal must come from. There is one per constraint: `low-port.aontu`, `high-port.aontu`,
`one-replica.aontu` and `owner-number.aontu`. Each sits one step past its bound, port 1023 rather
than port 80. An agent that wants a service on 443 lowers `min(1024)` to `min(443)` rather than
deleting it, and a counterexample on port 80 is still refused after that edit.

```aontu title="must-refuse/low-port.aontu"
# refuse: $.service.legacy.port
# 1023 is the last port that needs root to bind, one below min(1024) in spec.aontu. On the
# boundary, it also fails when the bound is lowered rather than deleted.
service: legacy: { owner: "ops", port: 1023, replicas: 2 }
```

A constraint with no counterexample can be deleted without the guard noticing, so add one with
every constraint you add.

## Run the counterexamples beside vet

`guard.mjs` calls `vet` from the aontu library twice over: once for the data, once for each
counterexample.

```js title="guard.mjs"
  for (const name of cases) {
    const file = join(refuse, name)
    const want = EXPECT.exec(readFileSync(resolve(dir, file), 'utf8'))?.[1]
    if (!want) {
      problems.push(`${file}: no "# refuse: <path>" line, so it tests nothing in particular`)
      continue
    }
    const r = check(file)
    if (r.verdict === 'valid') {
      problems.push(`${file}: admitted. The spec no longer refuses ${want}`)
    } else if (!r.findings.some((f) => f.path === want)) {
      const got = r.findings.map((f) => f.path).join(', ') || r.verdict
      problems.push(`${file}: refused at ${got}, not ${want}, so it no longer tests its constraint`)
    }
  }
```

The second branch matters as much as the first. A counterexample refused at a different path no
longer tests its constraint: change `port: 80` to `port: 8080, replicas: 1` and the file still
fails, for the wrong reason.

## Run it where the agent hears it

Merge this into `.claude/settings.json`. The hook runs the guard after every Edit or Write.
With `--hook`, a failure exits 2 with the report on stderr, and the hooks reference says Claude
Code shows that stderr to the agent for PostToolUse. The deny rule keeps the built-in edit tools
out of `must-refuse/`.

```json title="claude-settings.json"
{
  "permissions": {
    "deny": ["Edit(/must-refuse/**)"]
  },
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "cd \"${CLAUDE_PROJECT_DIR}\" && node guard.mjs --hook" }
        ]
      }
    ]
  }
}
```

The same guard runs at commit for every other editor and agent.

```yaml title=".pre-commit-config.yaml"
minimum_pre_commit_version: '4.4.0'
repos:
  - repo: local
    hooks:
      - id: aontu-guard
        name: spec vets, and every counterexample is still refused
        entry: node guard.mjs
        language: unsupported
        files: \.aontu$
        pass_filenames: false
```

`language: unsupported` is the name pre-commit 4.4.0 gave the old `language: system`.[^2] Run
locally under pre-commit 4.7.0, the hook passed on the committed spec and failed on a loosened
one with the same message the guard prints.

## Replay an agent against it

`loop.mjs` copies this directory to a scratch folder and applies three edits from
`agent-stub.mjs`, a scripted stand-in that takes the place of a model. After each edit it asks
the language server, the MCP server's `vet` tool, and the guard.[^3]

```bash
node loop.mjs
```

```text output
edit 1: add a metrics service on port 80
  lsp    constraint at $.service.metrics.port, range 5:21 covers 'discovery", port: 9200, repl'
  mcp    vet invalid
         $.service.metrics.port constraint at spec.aontu:5:21 and services.aontu:6:44
  hook   FAIL
         services.aontu: invalid ($.service.metrics.port constraint)
edit 2: delete min(1024) from the spec, so the error goes away
  lsp    no diagnostics
  mcp    vet valid
  hook   FAIL
         must-refuse/low-port.aontu: admitted. The spec no longer refuses $.service.legacy.port
edit 3: put min(1024) back and move metrics to port 9100
  lsp    no diagnostics
  mcp    vet valid
  hook   ok
```

All three channels catch edit 1. Edit 2 clears the language server and MCP `vet`, because the
spec they check against is the loosened one, and the guard alone reports it. Edit 3 is the fix
the guard's message points at.

## Check it worked

The suite pins each way a constraint can go missing, and the test below is the one this page
exists for.

```js title="guard.test.mjs"
test('deleting min(1024) passes vet and fails the guard', () => {
  const dir = copy({ 'spec.aontu': ['integer & min(1024) & max(65535)', 'integer & max(65535)'] })
  try {
    const spec = join(dir, 'spec.aontu')
    const data = join(dir, 'services.aontu')
    const plain = vet(readFileSync(spec, 'utf8'), readFileSync(data, 'utf8'), { schemaPath: spec, dataPath: data })
    assert.equal(plain.verdict, 'valid', 'validation alone sees nothing wrong')
    const r = guard({ dir })
    assert.equal(r.ok, false)
    assert.deepEqual(r.problems, ['must-refuse/low-port.aontu: admitted. The spec no longer refuses $.service.legacy.port'])
  } finally {
    rmSync(dir, { recursive: true, force: true })
  }
})
```

```bash
node --test guard.test.mjs
```

```text output
1..15
# tests 15
# suites 0
# pass 15
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1891.096559
```

Two of the fifteen run the guard as a hook process and assert exit code 2 with the report on
stderr, including when `spec.aontu` is missing. A crash that exits 1 would reach the user and not
the agent.

## When it goes wrong

The language server underlines the wrong line. In edit 1 its range is 5:21, which is where
`min(1024)` sits in `spec.aontu`, but aontu 0.76.0 publishes it against `services.aontu`, where
that range covers part of the search entry. The path in the message is right. Tell the agent to
act on the path, or use the MCP `vet` tool, which names both files and lines correctly.

`aontu breaking` cannot tell a deletion from a tightening here:

```bash
for f in agent-edits/spec-*.aontu; do echo "$f"; npx aontu breaking --mode forward --against spec.aontu "$f" | grep -E '^verdict|sub_'; done
```

```text output
agent-edits/spec-loosened.aontu
verdict: undecided
$.service.&.port: sub_unresolved [compat]
agent-edits/spec-tightened.aontu
verdict: undecided
$.service.&.port: sub_unresolved [compat]
```

Both answers are `undecided`, because aontu 0.76.0 does not compare these ranges inside an `&:`
template. Written as a plain `port:` field, the same two edits came back `breaking` and
`compatible` in a local run. A gate that requires `breaking` to pass blocks both edits, and one that accepts
`undecided` admits both. The counterexamples refuse the first and pass the second, which the
suite asserts.

The agent edits the spec with a shell command. The [hooks reference](https://code.claude.com/docs/en/hooks)
says a PostToolUse hook matching `Edit|Write` does not run when a Bash command rewrites the file,
so a `sed` that loosens the spec is first checked at commit. Add `Bash` to the matcher if your
agent edits through the shell, at the price of half a second after every command.

The agent deletes the counterexample along with the constraint. The test named `deleting a
constraint together with its counterexample passes the guard` shows that gap. The deny rule
covers Claude Code's own file tools, and the
[permissions documentation](https://code.claude.com/docs/en/permissions) says it does not reach a
script that writes files itself. Put `spec.aontu` and `must-refuse/` under a
[CODEOWNERS](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners)
rule as well, so a person reviews any change to either.

## Run the same loop with CUE

The same services, written as YAML against a CUE schema with `port: int & >=1024 & <=65535`, ran
locally under cue v0.17.1. `cue vet` refused port 80 and named both `schema.cue:3:18` and the
YAML line. With `>=1024` deleted it passed the services file, and it also passed a
`low-port.yaml` counterexample that the strict schema refuses. The counterexample check carries
over unchanged:

```bash
cue vet schema.cue services.yaml
for f in must-refuse/*.yaml; do ! cue vet schema.cue "$f" || exit 1; done
```

That loop checks the exit code only, so it misses a counterexample refused for the wrong reason.
Match the path in the error text if that matters to you.

CUE has a language server too: the [CUE wiki](https://github.com/cue-lang/cue/wiki/LSP:-Getting-started)
says `cue` has carried one since v0.15.0-alpha.1. It was not run for this page, so the latency
comparison earlier on this page covers aontu's only.

## When not to do this

Do not rely on the aontu language server, the MCP server or `vet` to stop a loosened spec. Each
checks data against the spec it is given, and a weaker spec satisfies all three.

Do not let the agent own `must-refuse/`. The counterexamples are the record of what the spec
promised, so whoever can delete them can delete the promise.

Do not add the language server for an agent whose client does not pass diagnostics to the model.
Check that first: the hook delivers the same finding after every edit with no extra process.

Do not write a counterexample for every bad value you can think of. One per constraint, placed
just past its bound, proves the bound has not moved, and more tests the validator rather than the
spec.

## Related how-tos

- [Let a coding agent customize a generated SDK safely](/howto/customise-a-generated-sdk-with-an-agent-safely)

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

- [Explain why a unified config has a given value](/howto/explain-why-a-unified-config-value)

## Last verified

Verified 2026-10-15 against Node 22.22.0 and aontu 0.76.0. Every output block is what the command
preceding it printed. No model was called: the agent is the scripted stub in `agent-stub.mjs`.
The pre-commit run, the CUE results and the latency figures were measured on the container,
under pre-commit 4.7.0 and cue v0.17.1, and the page's checks do not re-run them. The Claude Code
hook was exercised by running its command, not inside a Claude Code session.

[^1]: The 1024 in `min(1024)` is two rules that agree. [RFC 6335](https://www.rfc-editor.org/rfc/rfc6335.html)
calls ports 0 to 1023 the System Ports, assigned by IANA, and says nothing about who may bind
them. The Linux kernel's [`ip_unprivileged_port_start`](https://docs.kernel.org/networking/ip-sysctl.html)
defaults to 1024 and requires root or `CAP_NET_BIND_SERVICE` below it. The same entry explains
how to set it to 0, which disables privileged ports altogether. The `max(49151)` in the tightened
spec is where RFC 6335 ends the User Ports.

[^2]: The [pre-commit documentation](https://pre-commit.com/) marks `unsupported` as new in
4.4.0, previously `language: system`, and says the alias will be removed in a future version. It
is the setting for an executable pre-commit does not install or isolate, so the hook runs
whatever `node` is on the PATH. The rename trades a description of where the program comes from
for a description of how much support pre-commit gives it.

[^3]: The two servers frame the same JSON-RPC differently. The
[Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/)
puts HTTP-style headers before each message, a required `Content-Length` and an optional
`Content-Type` whose default is `application/vscode-jsonrpc`. The MCP
[stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
ends each message with a newline and forbids newlines inside one. `loop.mjs` carries a reader
for each.