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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written GBNF file | 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 | 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 | 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.
# 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.
Related how-tos
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
-
The converter’s first line says it was ported from
json_schema_to_grammar.pyand asks that bugs be fixed there first. The Python original lived inexamples/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 incommon/is the onellama-serverruns, and the one that stayed. ↩︎ Back to text -
JSON Schema’s validation specification files
defaultunder 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 onexampleslets 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 -
The 2020-12 release notes explain why
itemswas 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.prefixItemstook the tuple meaning anditemskept the rest. The exporter writes the tuple form on purpose, because a written list is a tuple to it, and the llama.cpp README listsprefixItemsas broken. The keyword renamed to end a confusion is the one the two tools read differently. ↩︎ Back to text