Building with tabnas

Written for an agent that has arrived here to build something. It covers the grammar format you should emit, the constraints the engine imposes, how to check your work, and where the per-package instructions live. A human wanting the argument for any of this should read why tabnas instead.

1 · What you are targeting

A tabnas grammar is data: a table of rules, each with an open and a close phase, each phase holding a list of alternates. An alternate matches a short token pattern and may run an action, push a child rule, or repeat the current one. Parsing is a for-loop over tokens with a rule stack — no recursion and no backtracking.

That is the whole machine. You are not writing a parser; you are filling in a table. It is deliberately dull, which is what makes it a target you can hit reliably.

2 · Install

npm install @tabnas/parser @tabnas/abnf
go get github.com/tabnas/parser/go

Pin exact versions. Everything is pre-1.0, so a caret range will refuse the next minor release without telling you.

3 · Do not start from scratch

This is the step most worth spending time on. Extension is the cheapest path and the one the engine is designed for: if something close to your format already parses, start there and add rules. JSONC is JSON plus comments; jsonic is JSONC with the quoting relaxed. Check the package list before writing anything.

  • Parsing a JSON dialect? Start from jsonic and remove or add rules.
  • Need operators and precedence? Add expr rather than hand-rolling a Pratt parser.
  • Need @name-style forms? Add directive.
  • Need free text between delimiters? Add hoover.
  • Composing several documents into one parse? Add multisource.

aontu is the worked example: a whole configuration language assembled from five of these plugins, defining no parser of its own.

4 · The rule table

This is the form to emit. It is plain data, so you can validate it, diff it, and print it before anything runs.

A rule table, loaded and exercised

import { Tabnas } from '@tabnas/parser'const tn = new Tabnas()// The rule table, exactly as data — no code anywhere in it.tn.grammar({  options: {    fixed: { token: { '#PL': '+' } },   // custom fixed tokens    rule: { start: 'val' },             // where parsing begins  },  rule: {    val: {      open: [{ p: 'add' }],      close: [{}],    },    add: {      open: [{ s: '#NR' }],      close: [{ s: '#PL', r: 'add' }, {}],    },  },})// A grammar is only known to work when the bad inputs fail too.for (const src of ['1', '1+2', '1+2+3', '1+', '+1']) {  let verdict = 'accept'  try {    tn.parse(src)  } catch {    verdict = 'reject'  }  console.log(src.padEnd(6), verdict)}
package mainimport (	"fmt"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	// The rule table, exactly as data — no code anywhere in it.	err := j.Grammar(&tabnas.GrammarSpec{		OptionsMap: map[string]any{			"fixed": map[string]any{"token": map[string]any{"#PL": "+"}}, // custom fixed tokens			"rule":  map[string]any{"start": "val"},                      // where parsing begins		},		Rule: map[string]*tabnas.GrammarRuleSpec{			"val": {				Open:  []*tabnas.GrammarAltSpec{{P: "add"}},				Close: []*tabnas.GrammarAltSpec{{}},			},			"add": {				Open:  []*tabnas.GrammarAltSpec{{S: "#NR"}},				Close: []*tabnas.GrammarAltSpec{{S: "#PL", R: "add"}, {}},			},		},	})	if err != nil {		panic(err)	}	// A grammar is only known to work when the bad inputs fail too.	for _, src := range []string{"1", "1+2", "1+2+3", "1+", "+1"} {		verdict := "accept"		if _, err := j.Parse(src); err != nil {			verdict = "reject"		}		fmt.Printf("%-6s %s\n", src, verdict)	}}

The table is the whole grammar: val pushes add, add matches a number and then either repeats itself after a + or stops at the empty alternate {}. Nothing here is code, so it can be validated, diffed and printed before it runs.

The loop is the point. A grammar that accepts 1+2+3 proves little on its own — 1+ and +1 have to be rejected too, and a phase with no matching alternate is exactly what rejects them.

TypeScript signals a parse failure by throwing; Go returns an error from Parse. Same verdict either way.

output 1 accept · 1+2 accept · 1+2+3 accept · 1+ reject · +1 reject

FieldOn an alternate, means
sMatch this token sequence — one token, or several for lookahead.
pPush a child rule — it becomes a child node.
rRepeat a rule at the same stack depth — no nesting, same parent.
aAction: a function, a @ref name, a $-builtin, or an array of them.
cCondition — the alternate only applies when it holds.
{}The empty alternate. Ends the phase. Without one, no match is a parse error.

p versus r is the distinction that catches people out. Push nests, so the child's parent is the pushing rule. Repeat stays at the same depth, so every repetition shares one parent — which is what makes an accumulator a single value in a single place.

5 · Actions, ideally without code

A grammar that only recognises input returns nothing. Actions build the result. There are three ways to attach them, and you should prefer them in this order:

Builtin actions — no functions at all

The engine ships $-suffixed builtins, merged into the ref map when the grammar loads. Referenced by name, they let you emit a grammar that is pure JSON with no code in it — which is the safest thing you can hand to someone else.

A grammar with no code in it

import { Tabnas } from '@tabnas/parser'const tn = new Tabnas()// No functions anywhere: the only action is a builtin, named by string.tn.grammar({  rule: {    val: {      open: [{ s: '#NR', a: '@value$' }],      close: [{}],    },  },})for (const src of ['42', '3.5', '-7']) {  console.log(src, '=>', tn.parse(src))}
package mainimport (	"fmt"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	// No functions anywhere: the only action is a builtin, named by string.	err := j.Grammar(&tabnas.GrammarSpec{		Rule: map[string]*tabnas.GrammarRuleSpec{			"val": {				Open:  []*tabnas.GrammarAltSpec{{S: "#NR", A: "@value$"}},				Close: []*tabnas.GrammarAltSpec{{}},			},		},	})	if err != nil {		panic(err)	}	for _, src := range []string{"42", "3.5", "-7"} {		out, err := j.Parse(src)		if err != nil {			panic(err)		}		fmt.Println(src, "=>", out)	}}

@value$ is an engine builtin, merged into the ref map when the grammar loads, so it is referenced by name rather than supplied as a function. The whole grammar is therefore data — safe to serialise, store, and hand to someone else.

@value$ resolves the matched scalar token onto the node, and the node of the start rule is what parse returns. The token is already typed: a number comes back as a number, not the source text.

Go carries that number as a float64 where TypeScript uses a JS number; both print the same here because neither prints a trailing .0.

output 42 => 42 · 3.5 => 3.5 · -7 => -7

BuiltinEffect
@object$r.node = {}
@array$r.node = []
@key$Capture the matched key token.
@setval$Assign the child's node as an object property.
@push$Append the child's node to an array.
@value$Resolve the matched scalar token.
@reset$Clear the parent-seeded node.
@node$ @capture$ @bubble$Rebuild the { rule, src, kids } tree. Used by the ABNF compiler.

Named refs — code, bound out of band

Keeps the grammar declarative while the code lives in the host program. Two kinds of name: alternate marks (open- or close-phase), and rule-phase hooks (bo, ao, bc, ac — before/after open and close). Keep results on the node, not in a variable outside the parse, so the grammar stays reusable.

A rule-phase hook and an alternate mark

import { Tabnas } from '@tabnas/parser'import { abnf } from '@tabnas/abnf'const tn = new Tabnas({ plugins: [abnf] })// The grammar stays declarative; the code lives out here, bound by name.tn.abnf(`  val = add  add = NR [ PL add ]  PL  = "+"`, {  actions: {    // Rule-phase hook: after 'val' opens, seed the accumulator on its node.    '@val:ao': (r: any) => { r.node.value = 0; r.node.count = 0 },    // Alternate mark: each number adds to it.    '@add:o:NR': (r: any) => { r.parent.node.value += r.o[0].val; r.parent.node.count++ },  },})for (const src of ['1', '1+2+3', '12+3+45']) {  const node = tn.parse(src)  console.log(`${src} => total ${node.value}, terms ${node.count}`)}
package mainimport (	"fmt"	abnf "github.com/tabnas/abnf/go"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	// The grammar stays declarative; the code lives out here, bound by name.	_, err := abnf.Install(j, `  val = add  add = NR [ PL add ]  PL  = "+"`, nil, abnf.ActionsMap{		// Rule-phase hook: after 'val' opens, seed the accumulator on its node.		"@val:ao": {func(r *tabnas.Rule, ctx *tabnas.Context) {			node := r.Node.(map[string]any)			node["value"] = float64(0)			node["count"] = float64(0)		}},		// Alternate mark: each number adds to it.		"@add:o:NR": {func(r *tabnas.Rule, ctx *tabnas.Context) {			node := r.Parent.Node.(map[string]any)			node["value"] = node["value"].(float64) + r.O[0].Val.(float64)			node["count"] = node["count"].(float64) + 1		}},	})	if err != nil {		panic(err)	}	for _, src := range []string{"1", "1+2+3", "12+3+45"} {		out, err := j.Parse(src)		if err != nil {			panic(err)		}		node := out.(map[string]any)		fmt.Printf("%s => total %v, terms %v\n",			src, int(node["value"].(float64)), int(node["count"].(float64)))	}}

Both kinds of name are here. @val:ao is a rule-phase hook — after val opens — and @add:o:NR is an alternate mark, the open-phase alternate of add that matched a number. The grammar text itself stays free of code.

Because the tail self-reference [ PL add ] compiles to a same-depth repeat, r.parent is val for every term, so one accumulator on one node collects them all. Results live on the node, never in a variable outside the parse, so the grammar stays reusable.

Mark names come from the compiler, not from you — run tabnas-abnf --marks to list them rather than guessing.

Go's number tokens carry float64 and its nodes are map[string]any, so the Go version asserts types where TypeScript just reads properties.

output 1 => total 1, terms 1 · 1+2+3 => total 6, terms 3 · 12+3+45 => total 60, terms 3

Mark names are assigned by the compiler from each alternate's leading discriminator. Do not guess them. Ask: tabnas-abnf --marks -f grammar.abnf.

Inline functions

Written straight onto the alternate as a. The most direct, but the grammar is now code and can no longer be serialised or safely shared.

Actions written onto the alternates

import { Tabnas } from '@tabnas/parser'const tn = new Tabnas({  fixed: { token: { '#PL': '+' } },  rule: { start: 'val' },})// Functions written straight onto the alternates: the grammar is now code.tn.rule('val', (rs: any) =>  rs    .open([{ p: 'add', a: (r: any) => { r.node = 0 } }])    .close([{}]))tn.rule('add', (rs: any) =>  rs    .open([{ s: '#NR', a: (r: any) => { r.parent.node += r.o[0].val } }])    .close([{ s: '#PL', r: 'add' }, {}]))for (const src of ['1', '1+2+3', '12+3+45']) {  console.log(src, '=>', tn.parse(src))}
package mainimport (	"fmt"	tabnas "github.com/tabnas/parser/go")func main() {	plus := "+"	j := tabnas.Make(tabnas.Options{		Fixed: &tabnas.FixedOptions{Token: map[string]*string{"#PL": &plus}},		Rule:  &tabnas.RuleOptions{Start: "val"},	})	NR, PL := j.Token("#NR"), j.Token("#PL")	// Functions written straight onto the alternates: the grammar is now code.	j.Rule("val", func(rs *tabnas.RuleSpec, _ *tabnas.Parser) {		rs.AddOpen(&tabnas.AltSpec{			P: "add",			A: func(r *tabnas.Rule, _ *tabnas.Context) { r.Node = float64(0) },		})		rs.AddClose(&tabnas.AltSpec{})	})	j.Rule("add", func(rs *tabnas.RuleSpec, _ *tabnas.Parser) {		rs.AddOpen(&tabnas.AltSpec{			S: [][]tabnas.Tin{{NR}},			A: func(r *tabnas.Rule, _ *tabnas.Context) {				r.Parent.Node = r.Parent.Node.(float64) + r.O[0].Val.(float64)			},		})		rs.AddClose(&tabnas.AltSpec{S: [][]tabnas.Tin{{PL}}, R: "add"}, &tabnas.AltSpec{})	})	for _, src := range []string{"1", "1+2+3", "12+3+45"} {		out, err := j.Parse(src)		if err != nil {			panic(err)		}		fmt.Println(src, "=>", int(out.(float64)))	}}

The action is written straight onto the alternate as a. This is the most direct of the three ways to attach behaviour, and the least shareable: the grammar is code now, so it can no longer be serialised or safely handed on.

r is the rule instance, r.node the value it carries, r.o the tokens matched in the open phase — so r.o[0].val is the first token's value, already a number — and r.parent the pushing rule. close: [{ s: '#PL', r: 'add' }, {}] repeats at the same depth, so every term sees the same r.parent.

Go declares the token Tins up front (j.Token("#NR")) where TypeScript names them as '#NR' strings, and its numbers are float64.

output 1 => 1 · 1+2+3 => 6 · 12+3+45 => 60

r is the rule instance, r.node the value it carries, r.o the tokens matched in the open phase (so r.o[0].val is the first token's value, already a number), and r.parent / r.child the neighbouring rules.

6 · Constraints to design around

These are the things that will bite. All of them are by design.

  • Deterministic, no backtracking. Alternates are tried in order and the first match wins. Two alternates that can't be told apart from their leading tokens resolve to whichever comes first — order them deliberately.
  • Ambiguity is not supported. One parse or an error. If you need every valid parse, this is the wrong engine.
  • You write the lexer. tabnas separates lexing from parsing and is about the parsing half. Built-ins like NR and fixed tokens cover a lot; beyond that, you assemble it.
  • Left recursion is rewritten, not supported. The ABNF compiler applies Paull's algorithm, so P = P a / b becomes P = b *(a). The tree comes out flat rather than left-nested, and a purely left-recursive rule is an error.
  • A tail self-reference compiles to a repeat. X = prefix [ sep X ] becomes a same-depth r: repeat, so r.parent is the wrapping rule for every repetition and r.parent.node is the accumulator idiom — identical to a hand-written table. Other sugar (( … ), *( … )) still desugars into generated group rules, where parent may be synthetic; check with tabnas-abnf --marks.
  • Empty alternates matter. A phase with no matching alternate is a parse error. {} is how a rule ends.

7 · Verify your work

A grammar that looks right and parses one happy-path input is not evidence. Before reporting success:

  • Parse several known-good inputs and assert the values, not just that it didn't throw.
  • Parse known-bad inputs and confirm they're rejected. A grammar that accepts everything is a common failure.
  • Run tabnas-abnf --marks if an action didn't fire.
  • Use @tabnas/debug to describe the live grammar and render it back to ABNF — if the round-trip isn't what you meant, the grammar isn't either.
  • Use @tabnas/railroad to draw it when the structure is hard to hold in your head.
  • Try it in the playground — it takes ABNF or a rule table and shows both the tree and the value.

8 · Per-package instructions

Every package repository ships an AGENTS.md with its layout, conventions, test commands, and the non-obvious things to know before changing it. Read the one for the package you're touching — start with parser.

Engine

parser

Grammar tooling

abnfbnfdebugrailroad

Command line

jsonic-cli

Work lands in TypeScript first, then Go to match; both run the same fixtures, so a change that doesn't really work fails loudly. Agent-written contributions are welcome and need no disclosure — see community.

9 · Skills and MCP

Everything on this page is also packaged for an agent to install rather than read. Skills are five portable Agent Skills — author a grammar, debug a parse, pin behaviour with fixtures, build and upgrade plugins — and MCP is the server that makes the commands they teach executable: parse, validate a grammar before running it, explain a failure, run fixtures, read the plugin catalogue, and compare a grammar change against the one it replaces. The same seven operations are a tabnas command-line tool, so a shell transcript and an agent transcript describe the same run.

Run it locally. npx --yes @tabnas/mcp mcp is the supported path: free, private, unlimited, and the same code as everything else here. If your client cannot spawn a process, the same seven tools are served over streamable HTTP at https://mcp.tabnas.dev/mcp — bounded by a body cap and a per-IP rate limit, both reported by its /.well-known/mcp, and covered by a privacy policy that is short because the service keeps nothing.

10 · This site, machine-readable

  • llms.txt indexes the site and llms-full.txt is the full documentation text. Both are generated from the site's own pages, so they cannot fall behind them.
  • versions.json says which package versions this documentation describes — worth reading before trusting an example against whatever you have installed.
  • /errors is every error code the engine and its plugins can raise, one page per code at /errors/<code>. A diagnostic's code is therefore a URL you can follow.

Source for everything is under github.com/tabnas.