# How to generate a GBNF grammar from an aontu schema

> Generate a GBNF grammar from an aontu model through its JSON Schema export, and find with a corpus which constraints only vet still enforces.

Source: https://voxgig.com/howto/generate-a-gbnf-grammar-from-an-aontu-schema

- Audience: platform-team
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Run `aontu jsonschema --at '$.order' order.aontu` and feed the export to llama.cpp's `json-schema-to-grammar.mjs`, pinned to a tag, to get a GBNF file the sampler enforces. Enums, integer ranges, and closed maps cross. Patterns cross, but a negated class lets a raw quote break the JSON. Defaults, `neq`, and lists do not cross, so round-trip a corpus of good and bad replies through the grammar and through `aontu vet`, and keep vet in the pipeline after generation.

---
## You will need

Node 22 or later, aontu from npm, the [tabnas GBNF plugin](https://github.com/tabnas/gbnf) as an
offline membership check, and llama.cpp's JavaScript converter, which the sample's `postinstall`
downloads at a pinned tag and checks by hash.[^1] Verified 2026-10-21 against Node 22.22.0, aontu
0.76.0, @tabnas/gbnf 0.1.20, and llama.cpp's `json-schema-to-grammar.mjs` at tag b6900. aontu
declares `engines.node` as 24 or later, so npm prints an `EBADENGINE` warning under Node 22 and
installs it anyway. The sample loads no model: it generates the grammar, decides which strings are
in its language, and vets them. Attaching the file to a `llama-server` run is a separate task.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A hand-written GBNF file](https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md) | A shape with three fields that nobody edits, or an output that is not JSON | A second description of the same shape, which drifts as soon as the model changes unless someone reopens the grammar | The shape already lives in a schema that anything can export |
| [`aontu jsonschema` into llama.cpp's converter](https://aontu.dev/docs/reference-api/#aontu-jsonschema) | The payload shape is already an aontu model and you want one source of truth | A build step, a pinned copy of a converter that left the llama.cpp tree with its legacy web UI, and constraints the grammar cannot carry | You have no aontu model, since the converter takes any JSON Schema |
| [`llama-server` `json_schema` at request time](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) | One deployment, and you would rather send the export with each request than commit a file | Nothing to inspect or test offline, so a constraint the C++ converter skips is first seen in production | You run the model through anything other than `llama-server`, or you want the grammar reviewed in a pull request |

Voxgig maintains aontu. This page compares it with llama-server's request-time conversion and a
hand-written GBNF file.

The hand-written file is quick to write and slow to keep current, because the schema and the
grammar are edited by different people on different days. Sending the schema to `llama-server`
with each request removes the file and the review with it. The C++ converter skips what it cannot
express without a warning, so the first sign is a reply the sampler should not have produced. The
export route keeps one source of truth and adds a build step, plus a corpus to prove what crossed.

## Export the model and convert the export

The schema is the one you already maintain. The sample's version fits on one screen. It uses the
constructs whose fate on the way out differs: a default, a disjunction, a pattern, a bound, a
closed map, a reference, and `neq`.

```aontu title="order.aontu"
# What a local model may fill in from a support email. The grammar is
# generated from $.order; the customer shape is referenced into it.
order: {
  id: string & re("^ord_[a-z0-9]{8}$")
  action: string & neq("delete")
  currency: *"EUR" | "USD" | "GBP"
  priority: *"normal" | "urgent"
  quantity: integer & min(1) & max(100)
  note: *"" | string
  customer: $.customer
}
customer: close({ email: string & re("^[^@ ]+@[^@ ]+$"), tier: "free" | "pro" })
```

`aontu jsonschema` exports the unified value as JSON Schema 2020-12 and reports on stderr what
could not cross. `--at` anchors the export at the order, and it is the same anchor `vet --at`
takes later.

```bash
npx aontu jsonschema --at '$.order' order.aontu
```

```text output
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "action": {
      "not": {
        "enum": [
          "delete"
        ]
      },
      "type": "string"
    },
    "currency": {
      "default": "EUR",
      "enum": [
        "EUR",
        "USD",
        "GBP"
      ]
    },
    "customer": {
      "additionalProperties": false,
      "properties": {
        "email": {
          "pattern": "^[^@ ]+@[^@ ]+$",
          "type": "string"
        },
        "tier": {
          "enum": [
            "free",
            "pro"
          ]
        }
      },
      "required": [
        "email",
        "tier"
      ],
      "type": "object"
    },
    "id": {
      "pattern": "^ord_[a-z0-9]{8}$",
      "type": "string"
    },
```

Read the export against the model. `neq("delete")` became `not`, the disjunction became an `enum`
with a `default`, `re()` became `pattern`, the bounds became `minimum` and `maximum`, and
`close()` became `additionalProperties: false`. The reference `$.customer` is gone: the exporter
writes the unified value, so the customer shape is inlined where it is used and the converter
never meets a `$ref`.

`generate.mjs` runs that export through the converter's `SchemaConverter` and writes the grammar.

```js title="generate.mjs"
// The CLI's JSON report: { schema, lossy: [{ path, construct, reason }], verdict }.
export function exportSchema(file, at) {
  const out = execFileSync(process.execPath, [AONTU, 'jsonschema', '--format', 'json', '--at', at, file], {
    encoding: 'utf8',
  })
  const report = JSON.parse(out)
  return { schema: report.schema, lossy: report.lossy, verdict: report.verdict }
}

export async function toGrammar(schema) {
  const conv = new SchemaConverter({ prop_order: {}, allow_fetch: false, dotall: false })
  conv.visit(await conv.resolveRefs(schema, 'schema.json'), '')
  return conv.formatGrammar()
}
```

```bash
node generate.mjs
```

```text output
action-kv ::= "\"action\"" space ":" space string
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
currency ::= ("\"EUR\"" | "\"USD\"" | "\"GBP\"") space
currency-kv ::= "\"currency\"" space ":" space currency
customer ::= "{" space customer-email-kv "," space customer-tier-kv "}" space
customer-email ::= "\"" ([^@ ]+ "@" [^@ ]+) "\"" space
customer-email-kv ::= "\"email\"" space ":" space customer-email
customer-kv ::= "\"customer\"" space ":" space customer
customer-tier ::= ("\"free\"" | "\"pro\"") space
customer-tier-kv ::= "\"tier\"" space ":" space customer-tier
id ::= "\"" ("ord_" id-1{8,8}) "\"" space
id-1 ::= [a-z0-9]
id-kv ::= "\"id\"" space ":" space id
note ::= note-0 | string
note-0 ::= "\"\"" space
note-kv ::= "\"note\"" space ":" space note
priority ::= ("\"normal\"" | "\"urgent\"") space
priority-kv ::= "\"priority\"" space ":" space priority
quantity ::= ([1-9] | ([1-8] [0-9] | [9] [0-9]) | "100") space
quantity-kv ::= "\"quantity\"" space ":" space quantity
root ::= "{" space action-kv "," space currency-kv "," space customer-kv "," space id-kv "," space note-kv "," space priority-kv "," space quantity-kv "}" space
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\"" space
```

The grammar keeps the enum as an alternation, the pattern as a sequence of character classes,
and the range as digit rules that stop at `100`. The classes run over the raw bytes between the
quotes, so the email's `[^@ ]` also admits a `"` that ends the string early. `action` is a plain
`string`: the converter has
no rule for `not`, and the llama.cpp README says unsupported features are skipped silently.
`currency-kv` is required and the default is gone. A grammar cannot leave a key out and fill it
in later, so the model must always choose, and `EUR` is no likelier than before.[^2] `root` also
fixes the key order, so a reply with the same keys in another order is outside the language.

The other npm converter, [json2gbnf](https://github.com/ambient-labs/GBNF), refused this export at
its first `pattern`.

## Round-trip a corpus through the grammar and through vet

A grammar answers one question: whether these bytes are in its language. The model answers
another: whether this value satisfies its constraints. `check.mjs` asks both of every reply in
`corpus.json`, with the tabnas GBNF plugin as the offline matcher and aontu's `vet` from the
library.

```js title="check.mjs"
export function matcher(grammar) {
  const tn = new Tabnas({ plugins: [gbnf] })
  tn.gbnf(grammar)
  return (text) => {
    try {
      tn.parse(text)
      return 'accept'
    } catch {
      return 'reject'
    }
  }
}

// vet's verdict for one reply, with the error findings that decided it. Warnings are left out:
// a compat note about a default is not a refusal.
export function vetter(schemaSrc, at) {
  return (text) => {
    const r = vet(schemaSrc, text, { at })
    const errors = r.findings.filter((f) => f.severity === 'error')
    return { verdict: r.verdict, errors: errors.map((f) => `${f.path} ${f.code}`) }
  }
}
```

```bash
node check.mjs
```

```text output
reply                                                generated  hand    vet
a complete reply                                     accept     accept  valid
a note with text in it                               accept     accept  valid
GBP, added to the schema after the hand-written file accept     reject  valid
quantity 0, below min(1)                             reject     reject  invalid
an email without an @                                reject     reject  invalid
an id in the wrong shape                             reject     reject  invalid
action delete, refused by neq                        accept     accept  invalid
an email with a raw quote, not JSON                  accept     reject  invalid
currency omitted, the default                        reject     reject  valid
an extra reasoning key                               reject     reject  valid
the same keys in another order                       reject     reject  valid

the generated grammar and vet disagree on 5 of 11:
  action delete, refused by neq: grammar accept, vet invalid ($.order.action constraint)
  an email with a raw quote, not JSON: grammar accept, vet invalid ($ syntax)
  currency omitted, the default: grammar reject, vet valid
  an extra reasoning key: grammar reject, vet valid
  the same keys in another order: grammar reject, vet valid
```

The `delete` row and the raw-quote row are the dangerous direction. The grammar admits replies
the model refuses, the second not even JSON, so the sampler can produce them, and only `vet`
stands between them and your order queue. That is the reason to run `vet` after generation rather
than instead of it: unification knows about `neq`, and the grammar never did. Write a pattern's
classes as the characters you allow, as the `id` does, so that a quote cannot cross.

The other three run the opposite way. The grammar demands `currency` where `vet` fills the
default in. The converter closes every object by default, while `vet` stays open until you pass
`--closed`. The fixed key order refuses a reply unification would accept. Each costs you replies,
never correctness, so decide per field whether that is a price you want to pay.

`vet` also attaches two `compat` warnings about the defaults to every reply. They leave the
verdict alone, and `vetter` keeps only the error findings.

## Fail the build when the committed grammar goes stale

`hand.gbnf` is the drift from the opening paragraph, kept in the sample as the control. It was
written when the shop took two currencies.

```text title="hand.gbnf"
# Written by hand for the first version of the order, when the shop took euros and dollars.
root ::= "{" space action-kv "," space currency-kv "," space customer-kv "," space id-kv "," space note-kv "," space priority-kv "," space quantity-kv "}" space
action-kv ::= "\"action\"" space ":" space string
currency-kv ::= "\"currency\"" space ":" space ("\"EUR\"" | "\"USD\"") space
```

The generated file has the same exposure, with one difference: it can be regenerated and
compared. The first test does that in memory on every run, and asserts the export's `lossy` list
is empty, so a `must()` added to the model later fails here before it fails in production.

```js title="check.test.mjs"
test('the committed grammar is what the schema generates today', async () => {
  const { grammar, lossy } = await generate('order.aontu', '$.order')
  assert.deepEqual(lossy, [], 'every construct in the order crossed into JSON Schema')
  assert.equal(grammar, files.generated, 'order.gbnf is stale: run node generate.mjs and commit it')
})
```

## Check it worked

Run the suite under the [Node test runner](https://nodejs.org/api/test.html). The third test pins
the disagreement list to exactly five names. A converter release that starts honoring `not` moves
the list and fails the run, and so does an exporter that stops listing defaulted keys as required.

```bash
node --test check.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1104.848664
```

`# pass 5` is the whole result: the grammar on disk is the one the model generates, every corpus
reply gets its recorded verdicts, and the hand-written file refuses `GBP`. The fifth test makes the
install refuse a converter that is not the pinned one.

## When it goes wrong

The converter throws on a list. A written list is a tuple to the exporter, so `[ $.item ]` comes
out as `prefixItems` with `items: false`, and the JavaScript converter reads that `false` as the
item schema.[^3] `vet` accepts a second item with `qty` 0 against the same model, because the
written list constrains the first position and a later element passes without a check in this
version.

```bash
node pitfall.mjs
```

```text output
lossy: []
items exported as: {"items":false,"minItems":1,"prefixItems":[{"properties":{"qty":{"minimum":1,"type":"integer"},"sku":{"type":"string"}},"required":["qty","sku"],"type":"object"}],"type":"array"}
converter: TypeError: Cannot use 'in' operator to search for 'const' in false
vet, second item with qty 0: valid
```

The `lossy` list is empty, because the export is faithful to what aontu means. Keep lists out of
the part of the model you anchor the export at, or write the list rule by hand and splice it in. The
llama.cpp README lists `prefixItems` as broken in the C++ converter as well, so sending the schema
to `llama-server` is not a way around it.

The matcher rejects a string the sampler would accept. The tabnas engine runs one rule stack with
first-match alternatives, so a grammar that needs backtracking can fail there while constraining
llama.cpp correctly. Its [known gaps](https://github.com/tabnas/gbnf/blob/main/ts/doc/known-gaps.md)
page keeps the list, and chess notation is its example. The converter's output never needed
backtracking in this corpus. When a rejection surprises you, check the string with llama.cpp's own
`test-gbnf-validator` before you trust it.

## When not to do this

Do not adopt aontu to get a grammar. The converter takes any JSON Schema, so a Zod or Pydantic
schema exported to JSON Schema reaches the same file in one step less. aontu earns its place when
the model already carries defaults, references, and relations that several consumers read, and
the grammar is one more export of it.

Do not treat the grammar as the validator. It shaped the bytes; it did not check `neq`, a
computed value, or anything the export reported as a loss. Run `vet` on every reply the sampler
produces, and read a `lossy` line in the export as a constraint you have agreed to check yourself.

Do not use this route for a model from the large providers. Their structured outputs take JSON
Schema, and OpenAI's [grammars for custom tools](https://developers.openai.com/api/docs/guides/function-calling)
take Lark or a regular expression, not GBNF. Send the export, and keep the GBNF step for a
llama.cpp sampler.

Do not hand-write the grammar for a shape you also keep in a schema. The file in `hand.gbnf` fell
behind the model the day the model changed, and the corpus test is what noticed.

## Related how-tos

- [Validate JSON model output before you use it](/howto/validate-json-model-output-in-typescript)

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

- [Let a coding agent edit a config spec without breaking it](/howto/let-an-agent-edit-a-config-spec)

## Last verified

Verified 2026-10-21 against Node 22.22.0, aontu 0.76.0, @tabnas/gbnf 0.1.20 with @tabnas/parser
0.12.13 and @tabnas/bnf 0.1.31, and llama.cpp's `json-schema-to-grammar.mjs` at tag b6900,
downloaded by `postinstall` and checked by SHA-256. Every output block is what the command
preceding it printed. No model was loaded: grammar membership was decided by the tabnas engine,
not by a llama.cpp sampler. The json2gbnf result was run once outside the page's checks.

[^1]: The converter's first line says it was ported from `json_schema_to_grammar.py` and asks that
bugs be fixed there first. The Python original lived in `examples/` and the port beside the
server's legacy web UI. Both are gone from master, and the
[tag the sample pins](https://github.com/ggml-org/llama.cpp/blob/b6900/tools/server/public_legacy/json-schema-to-grammar.mjs)
is the last one probed for this page that carries them. The
[grammar README](https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md) on master
still advises the command-line Python converter for its warnings. The C++ port in `common/` is the
one `llama-server` runs, and the one that stayed.

[^2]: JSON Schema's validation specification files `default` under
[a vocabulary for basic meta-data annotations](https://json-schema.org/draft/2020-12/json-schema-validation#section-9.2)
and places no restriction on its value. It recommends that the value be valid against the schema,
and its section on `examples` lets an implementation show it as one more example. Nothing in the
keyword's two paragraphs asks a validator to apply it, so a converter that drops it has read the specification
correctly. aontu's exporter writes it anyway, because a reader of a schema is not always a
validator.

[^3]: The 2020-12 [release notes](https://json-schema.org/draft/2020-12/release-notes) explain why
`items` was split in two. With one keyword for both, people defined a tuple when they meant an
array and could not see why only the first element was validating. `prefixItems` took the tuple
meaning and `items` kept the rest. The exporter writes the tuple form on purpose, because a written
list is a tuple to it, and the llama.cpp README lists `prefixItems` as broken. The keyword renamed
to end a confusion is the one the two tools read differently.