---
title: "Agents · tabnas"
description: "How an agent builds a parser with tabnas: the rule table format to emit, the constraints to design around, and how to verify the grammar you wrote."
source: "https://tabnas.dev/agents/"
---

# Building with tabnas

Written for an agent that has arrived here to build something. It covers the distributions you can install (five skills, an MCP server, a CLI), 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](https://tabnas.dev/why/) 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 the workflow: skills, MCP, CLI

Everything the rest of this page teaches is also packaged for you to install rather than read. Three surfaces, one shared implementation:

-   **[The skills](https://tabnas.dev/skills/)**: five portable [Agent Skills](https://agentskills.io/) that carry the working procedures: [`build-a-plugin`](https://tabnas.dev/skills/build-a-plugin/), [`create-grammar`](https://tabnas.dev/skills/create-grammar/), [`debug-parse`](https://tabnas.dev/skills/debug-parse/), [`test-a-grammar`](https://tabnas.dev/skills/test-a-grammar/), [`upgrade-a-plugin`](https://tabnas.dev/skills/upgrade-a-plugin/). Each has its own page here, and each is plain markdown a platform without plugin support can read straight from [the repository](https://github.com/tabnas/skills).
-   **[The MCP server](https://tabnas.dev/mcp/)**: `@tabnas/mcp` makes the commands the skills teach executable: seven tools to 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. It is `dev.tabnas/mcp` in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=tabnas), so a client that resolves servers from the registry finds both transports declared there.
-   **The `tabnas` CLI**: the same package, the same seven operations in a shell. `--json` prints byte-for-byte what the MCP tool returns (golden tests hold the two front-ends to it), so a shell transcript and an agent transcript describe the same run.

### In Claude Code

One plugin delivers all of it: the five skills plus both MCP server entries. Two commands, and the second is not optional: adding a marketplace registers a catalogue and installs nothing.

```
/plugin marketplace add tabnas/skills
/plugin install tabnas@tabnas
```

### In any MCP client

Add the stdio server to your client's MCP configuration: it needs no install step of its own; `npx` fetches it on first run:

```
npx --yes @tabnas/mcp@0.1.17 mcp
```

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`. Local stdio is the recommended path: free, private, unlimited. [MCP](https://tabnas.dev/mcp/) has the manifest JSON, each tool's contract, and the hosted endpoint's bounds.

### In a shell

```
npm install -g @tabnas/mcp    # installs the tabnas command
```

`tabnas parse | validate | diagnose | test | plugins | compare`, each mirroring one MCP tool. The skills teach the CLI spellings, so they work with or without a server connected.

## 3 · Install the engine

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

## 4 · 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](https://tabnas.dev/releases/) before writing anything: or ask the `list_plugins` MCP tool (`tabnas plugins`), which answers from each plugin's own descriptor.

-   Parsing a JSON dialect? Start from [jsonic](https://github.com/tabnas/jsonic) and remove or add rules.
-   Need operators and precedence? Add [expr](https://github.com/tabnas/expr) rather than hand-rolling a Pratt parser.
-   Need `@name`\-style forms? Add [directive](https://github.com/tabnas/directive).
-   Need free text between delimiters? Add [hoover](https://github.com/tabnas/hoover).
-   Composing several documents into one parse? Add [multisource](https://github.com/tabnas/multisource).

[aontu](https://tabnas.dev/examples/) is the worked example: a whole configuration language assembled from five of these plugins, defining no parser of its own.

## 5 · 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**

TypeScript:

```ts
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)
}
```

Go:

```go
package main

import (
	"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)
	}
}
```

Explain: 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`

| Field | On an alternate, means |
| --- | --- |
| `s` | Match this token sequence: one token, or several for lookahead. |
| `p` | **Push** a child rule: it becomes a child node. |
| `r` | **Repeat** a rule at the same stack depth: no nesting, same parent. |
| `a` | Action: a function, a `@ref` name, a `$`\-builtin, or an array of them. |
| `c` | Condition: 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.   ``

````   ## 6 · 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, and the only kind of grammar the MCP tools will accept.  **A grammar with no code in it**  TypeScript:  ```ts 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)) } ```  Go:  ```go package main  import ( 	"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) 	} } ```  Explain: `@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`  | Builtin | Effect | | --- | --- | | `@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**  TypeScript:  ```ts 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}`) } ```  Go:  ```go package main  import ( 	"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))) 	} } ```  Explain: 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**  TypeScript:  ```ts 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)) } ```  Go:  ```go package main  import ( 	"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))) 	} } ```  Explain: 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.   ``  ``   ## 7 · 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](https://en.wikipedia.org/wiki/Left_recursion#Removing_all_left_recursion), 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.  ## 8 · Verify your work  A grammar that looks right and parses one happy-path input is not evidence. The MCP tools and the CLI from [section 2](#skills-and-mcp) exist for exactly this loop. Before reporting success:  -   **Validate before running.** `tabnas validate --grammar g.json` (the `validate_grammar` tool) checks a serialized grammar against the engine's JSON Schema and loads it in a fresh instance: so a malformed table fails as a named error, not a misparse. -   Parse valid inputs and assert the returned _values_. A successful parse alone does not verify the result. -   Parse known-bad inputs and confirm they're rejected. A grammar that accepts everything is a common failure. -   **Read the diagnostic, not the message.** On a failure, `tabnas diagnose` (`explain_parse_error`) returns code, position, expected tokens and the rule stack; the `code` is a URL at [/errors](https://tabnas.dev/errors/). -   **Pin behaviour with fixtures.** Write `.tsv` rows: input, expected value or `ERROR:<code>`: and run them with `tabnas test --spec fixtures.tsv` (`test_grammar`). One file runs against both runtimes. -   **Changing a grammar others already use?** `tabnas compare` (`compare_grammars`) reports what a change provably keeps, what it observably changes, and with what confidence: evidence, never a bare verdict. -   Run `tabnas-abnf --marks` if an action didn't fire. -   Use [@tabnas/debug](https://github.com/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](https://github.com/tabnas/railroad) to draw it when the structure is hard to hold in your head. -   Try it in the [playground](https://tabnas.dev/playground/): it takes ABNF or a rule table and shows both the tree and the value.  ## 9 · 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](https://github.com/tabnas/parser/blob/main/AGENTS.md).  Engine  [parser](https://github.com/tabnas/parser/blob/main/AGENTS.md)  Grammar tooling  [abnf](https://github.com/tabnas/abnf/blob/main/AGENTS.md)[bnf](https://github.com/tabnas/bnf/blob/main/AGENTS.md)[ebnf](https://github.com/tabnas/ebnf/blob/main/AGENTS.md)[debug](https://github.com/tabnas/debug/blob/main/AGENTS.md)[railroad](https://github.com/tabnas/railroad/blob/main/AGENTS.md)[support](https://github.com/tabnas/support/blob/main/AGENTS.md)  Agent tooling  [mcp](https://github.com/tabnas/mcp/blob/main/AGENTS.md)  Languages and formats  [json](https://github.com/tabnas/json/blob/main/AGENTS.md)[jsonc](https://github.com/tabnas/jsonc/blob/main/AGENTS.md)[json5](https://github.com/tabnas/json5/blob/main/AGENTS.md)[jsonl](https://github.com/tabnas/jsonl/blob/main/AGENTS.md)[jsonic](https://github.com/tabnas/jsonic/blob/main/AGENTS.md)[yaml](https://github.com/tabnas/yaml/blob/main/AGENTS.md)[toml](https://github.com/tabnas/toml/blob/main/AGENTS.md)[ini](https://github.com/tabnas/ini/blob/main/AGENTS.md)[csv](https://github.com/tabnas/csv/blob/main/AGENTS.md)[xml](https://github.com/tabnas/xml/blob/main/AGENTS.md)[markdown](https://github.com/tabnas/markdown/blob/main/AGENTS.md)[css](https://github.com/tabnas/css/blob/main/AGENTS.md)[c](https://github.com/tabnas/c/blob/main/AGENTS.md)[proto](https://github.com/tabnas/proto/blob/main/AGENTS.md)[zon](https://github.com/tabnas/zon/blob/main/AGENTS.md)[feed](https://github.com/tabnas/feed/blob/main/AGENTS.md)[chess](https://github.com/tabnas/chess/blob/main/AGENTS.md)[semver](https://github.com/tabnas/semver/blob/main/AGENTS.md)[gbnf](https://github.com/tabnas/gbnf/blob/main/AGENTS.md)  Web components  [chess-view](https://github.com/tabnas/chess/blob/main/AGENTS.md)  Syntax plugins  [expr](https://github.com/tabnas/expr/blob/main/AGENTS.md)[directive](https://github.com/tabnas/directive/blob/main/AGENTS.md)[hoover](https://github.com/tabnas/hoover/blob/main/AGENTS.md)[path](https://github.com/tabnas/path/blob/main/AGENTS.md)[multisource](https://github.com/tabnas/multisource/blob/main/AGENTS.md)  Command line  [jsonic-cli](https://github.com/tabnas/jsonic-cli/blob/main/AGENTS.md)[lsp](https://github.com/tabnas/lsp/blob/main/AGENTS.md)  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](https://tabnas.dev/community/#this-project-is-ai-friendly).  ## 10 · This site, machine-readable  -   **[llms.txt](https://tabnas.dev/llms.txt)** indexes the site and **[llms-full.txt](https://tabnas.dev/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](https://tabnas.dev/versions.json)** says which package versions this documentation describes: worth reading before trusting an example against whatever you have installed. -   **[/errors](https://tabnas.dev/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](https://github.com/tabnas).     `` ````
