How-to › Model, configure and generate

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.

Audience
Agent builder
Level
intermediate
Topic
Unify configuration with a spec language
Verified

The agent adds a service on port 80, aontu vet refuses it, and the agent’s next edit deletes min(1024) from the spec. Every check passes after that, because each one validates the data against whatever spec it is handed. The constraint that kept services off privileged ports is gone, in a one-line hunk inside a forty-file change.

What you get

You will end up with a check that refuses an edit which loosens the spec, wired to run after every agent edit and again at commit. This is for you if an agent edits the spec as well as the data it governs.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
aontu language server and MCP serverThe agent’s client speaks LSP or MCP, and errors should arrive while it editsTwo more processes per session, and an LSP range that can point into the wrong fileThe client passes neither diagnostics nor tool results to the model
Claude Code PostToolUse hookThe agent is Claude Code, and every edit should be checked as it landsTies the check to one agent, and it runs after every edit, so it must stay fastSeveral agents and people edit the spec, so the check belongs in the repository
CUE cue vet in CIThe data is YAML the agent already edits, and CI is the gateThe agent hears about an error after a push, in a log it may never readThe agent needs the error in the same turn as the edit
pre-commit hookAny editor, any agent, one check at commit timeSilent until the commit, and git commit --no-verify skips it entirelyThe 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

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

@"./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.

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

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

{
  "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.

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

node loop.mjs
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.

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 })
  }
})
node --test guard.test.mjs
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:

for f in agent-edits/spec-*.aontu; do echo "$f"; npx aontu breaking --mode forward --against spec.aontu "$f" | grep -E '^verdict|sub_'; done
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 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 says it does not reach a script that writes files itself. Put spec.aontu and must-refuse/ under a CODEOWNERS 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:

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

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.

Footnotes

  1. The 1024 in min(1024) is two rules that agree. RFC 6335 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 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. ↩︎ Back to text

  2. The pre-commit documentation 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. ↩︎ Back to text

  3. The two servers frame the same JSON-RPC differently. The Language Server Protocol 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 ends each message with a newline and forbids newlines inside one. loop.mjs carries a reader for each. ↩︎ 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.