tramaj for coding agents

On Thu, 10 Sep 2026, by @lucasdicioccio, 1059 words, 3 code snippets, 10 links, 1images.

tramaj for coding agents

tramaj was designed to be as easy for a model to emit correctly as for a person to write by hand — a small builtin set, one expression grammar, and no template/expression phase split to keep track of. This page is a dense cheatsheet plus a fetch strategy for getting the full grammar when the summary below isn’t enough.

Fetch the canonical source first

Don’t rely on prose alone for anything you’re about to emit — pull the normative text:

  • Every page on this site is also served as plain text at /text/<page-name>.cmark.text (e.g. /text/reference-language.cmark.text) — no HTML to strip.
  • The specs themselves are the ultimate source of truth, on GitHub: reference.md, node-json.md, v3-symbols.md, v4-types.md.
  • corpus/cases/ in the repository is a conformance corpus: each case is a template.tramaj + ctx.json + expected.json triple (plus optional libs/*.tramaj) that every implementation must satisfy — treat these as runnable, ground-truth examples rather than prose. A case’s meta.json may carry "profiles" (int-float, arithmetic, int64, …), naming what an implementation must provide before it runs that case. Every implementation must provide int-float and arithmetic; a case that lists another name it lacks, such as the integer range it does not have, is skipped.
  • See also llms.txt for a short machine-readable index of the above (Kitchen-Sink serves arbitrary .txt files under /raw/, so this isn’t at the conventional /llms.txt root path).

Leaders — what each sigil means

LeaderMeaning
.document: .tag(...) element, or .(...) fragment
$read a binding or path
@define a binding (@name=expr)
!emit a constraint, statement position (!constraint(name, ...))
?symbolic allocation/demand, v3 (?(key))
%type hole, v4
--line comment, to end of line

Shape of a program

@n=cardinality($ctx.items)
.p("there are `$n` item(s)")

An optional block of @name=expr bindings, evaluated once each in order, followed by one root expression. Backtick-quoted `$expr` interpolates into a string; a bare $path can be a child directly. a <> b concatenates strings, arrays, and objects.

Documents are ordinary values

There is no separate “template value” type — an element or fragment can be bound, passed to a function, and returned from one:

@kids=.(.p("one"), .p("two"))
@panel=(title, children) => .section(.h2($title), $children)

.main($panel("Deployment", $kids))

Numbers and arithmetic

Two number types, told apart by the text of the literal or of the JSON number in the context. Nothing converts between them implicitly.

writtentypestr gives
3, -7, 1_000_000integer3, -7, 1000000
3.0, 1.5, 1e5float3.0, 1.5, 100000.0
  • eq(1, 1.0) is false. lt/lte/gt/gte take two integers or two floats; a mixed pair is a TypeMismatch. Write gt($x, 0.0) for a float.
  • There are no operators: no +, -, *, /. Arithmetic is ten named builtins, and there is no subtraction: sum(a, negate(b)).
builtinoperandsresult
sum(…), product(…)one or more, all integers or all floats; arrays are flattenedsame type
negate(x)integer or floatsame type
quotient(a, b), inverse(x)floats onlyfloat
floor-quotient(a, b), modulo(a, b)integers onlyinteger, rounding toward negative infinity
floor(x)eitherinteger
round(x)eitherthe nearest integer, a tie going away from zero
real(x)eitherfloat
@subtotal = sum($ctx.compute, $ctx.storage, negate($ctx.credit))
@share    = branch(0, gt($ctx.total, 0),
                   floor-quotient(product(100, $ctx.used), $ctx.total))
@total    = sum(0.0, map($ctx.lines,
                         (l) => product(real($l.qty), real($l.price))))
  • A mixed call (sum(1, 2.5), quotient(1, 2)) is a TypeMismatch. When the type of a context number is not known, normalize it: real($x) or floor($x).
  • sum() and sum([]) are a TypeMismatch. Seed a fold over a list that may be empty: sum(0, $xs) or sum(0.0, $xs).
  • A zero divisor, an integer overflow and a non-finite float are a NotRepresentable error, never null or 0. Guard with branch, whose unselected arm is not evaluated.
  • Arithmetic is a profile the host turns on (tramaj-cli --arithmetic). Without it the ten names are unbound.
  • In symbolic mode, a builtin with a symbol among its operands evaluates to a term, {"$term": "sum", "arguments": [...]}, for the host to evaluate. "$term" is a reserved object key, like "$sym" and "$type".
  • The type primitives are int and float (@n : int = 3); there is no number.

Sorting and number formatting need no profile:

  • sort-by(list, fn) and sort-by-descending(list, fn) order a list by the key fn gives each element, as in sort-by($rows, (r) => $r.spend). The sort is stable, and the keys of one call are all integers, all floats or all strings.
  • format-number(x, decimals, group) writes a number as text: format-number(1234567.891, 2, ",") is "1,234,567.89".

The reference marks these two and round [§20]. All six implementations (PureScript, Haskell, TypeScript, Rust, Go and Python) have the number split, the ten builtins, the two sorts and format-number. They are on the main branch and in no release yet: a released package predates them, and there every number is a double and these names are unbound. Templates written before the split: Migrating to integers and floats.

Actions are opaque to the language

action("on-click", "select", {"id": $i.id}) attaches structured data to an element. The language does not interpret it — a host decides what the key means. Action keys and adaptation prefixes must be literal strings, never computed, which is what keeps “what actions can this template emit” statically answerable.

Imports

import(...) pulls in another program as a library. Parameters can arrive as an expression, as a ctx(path) hole (a static marker that the importing program’s own context gets read at that point), or be omitted and supplied later by calling the import. Imports are lazy — nothing runs until .rendered or .vals is read on the result.

v3 — symbols and constraints (frozen, implemented)

?(key) allocates an opaque symbol; only the root program may do this (libraries raise AllocationInLibrary). !constraint(name, ...) emits a constraint. Running in Symbolic mode (a host choice, not something the template declares) wraps the result in a tramaj/symbolic/1 envelope of root value, symbols, and constraints instead of a finished document — use this when the actual values should come from a solver rather than be supplied up front. Full grammar: v3-symbols.md.

v4 — types (draft, implemented)

@x : T = e desugars to @x=e plus a has-type constraint carrying T’s canonical id. Types are resolved and erased before evaluation — tramaj fixes type identity (canonical ids, string equality), a separate host checker decides whether values actually satisfy it. Type parameters thread through imports. Full grammar: v4-types.md.

Static analyses available without evaluating

Because imports, action keys, adaptation prefixes, and ctx(...) paths are all literal strings in the syntax, these can all be answered by walking the AST — no context, no evaluation:

  • what a program imports, and with what parameters
  • what actions it can possibly emit
  • what context paths it reads (ctx(...) holes)
  • which import parameters are still unsupplied
  • which arithmetic builtins it references (analyze arithmetic)
  • (v3/v4) what constraints and symbols/types it introduces

Self-check loop for an agent: draft, run analyze, compare the interface with what was asked, revise or hand over

tramaj-cli’s analyze subcommand exposes these directly — see Getting started for exact invocations. If you’re generating a template and want to self-check it before handing it to a host, running analyze on it is cheaper and more reliable than re-deriving these properties by re-reading your own output.

Common pitfalls

  • Don’t compute an import name, action key, or ctx(...) path from an expression — these must be literal strings; the grammar doesn’t allow otherwise.
  • Bindings are visible only after their declaration, in order — not mutually recursive.
  • A library (something loaded via import) may not call ?(key) — only the root program may allocate symbols.
  • Don’t compare or combine an integer with a float: gt($ratio, 0) fails when $ratio is a float. Match the literal to the value, or convert with real/floor.
  • Don’t write a count as 1.0 or 1e3: a float renders with its fraction (1.0, 1000.0).
  • Evaluation order is unconstrained outside of binding precedence: don’t write templates that rely on side-effect ordering, because there isn’t one — evaluation is meant to be referentially transparent.