How-to › Use AI to do the integration

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.

Audience
Platform team
Level
advanced
Topic
Constrain model output to a schema
Languages
TypeScript and JavaScript
Verified

Your order model lives in order.aontu, and the grammar your local model decodes under lives in a .gbnf file someone wrote by hand. The model gained a third currency. The grammar still lists two, so the sampler can never emit GBP, and an order from a British customer comes back in euros, which aontu vet accepts.

What you get

You will end up with a grammar generated from the aontu model, a corpus that shows what only vet still catches, and a test that fails when the committed grammar goes stale. This is for you if your payload shapes live in aontu.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written GBNF fileA shape with three fields that nobody edits, or an output that is not JSONA second description of the same shape, which drifts as soon as the model changes unless someone reopens the grammarThe shape already lives in a schema that anything can export
aontu jsonschema into llama.cpp’s converterThe payload shape is already an aontu model and you want one source of truthA build step, a pinned copy of a converter that left the llama.cpp tree with its legacy web UI, and constraints the grammar cannot carryYou have no aontu model, since the converter takes any JSON Schema
llama-server json_schema at request timeOne deployment, and you would rather send the export with each request than commit a fileNothing to inspect or test offline, so a constraint the C++ converter skips is first seen in productionYou 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.

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

npx aontu jsonschema --at '$.order' order.aontu
{
  "$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.

// 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()
}
node generate.mjs
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, 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.

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}`) }
  }
}
node check.mjs
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.

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

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

node --test check.test.mjs
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.

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

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.

Footnotes

  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 is the last one probed for this page that carries them. The grammar README 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. ↩︎ Back to text

  2. JSON Schema’s validation specification files default under a vocabulary for basic meta-data annotations 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. ↩︎ Back to text

  3. The 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. ↩︎ 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.