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 atemplate.tramaj+ctx.json+expected.jsontriple (plus optionallibs/*.tramaj) that every implementation must satisfy — treat these as runnable, ground-truth examples rather than prose. A case’smeta.jsonmay carry"profiles"(int-float,arithmetic,int64, …), naming what an implementation must provide before it runs that case. Every implementation must provideint-floatandarithmetic; a case that lists another name it lacks, such as the integer range it does not have, is skipped.- See also
llms.txtfor a short machine-readable index of the above (Kitchen-Sink serves arbitrary.txtfiles under/raw/, so this isn’t at the conventional/llms.txtroot path).
Leaders — what each sigil means
| Leader | Meaning |
|---|---|
. | 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.
| written | type | str gives |
|---|---|---|
3, -7, 1_000_000 | integer | 3, -7, 1000000 |
3.0, 1.5, 1e5 | float | 3.0, 1.5, 100000.0 |
eq(1, 1.0)isfalse.lt/lte/gt/gtetake two integers or two floats; a mixed pair is aTypeMismatch. Writegt($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)).
| builtin | operands | result |
|---|---|---|
sum(…), product(…) | one or more, all integers or all floats; arrays are flattened | same type |
negate(x) | integer or float | same type |
quotient(a, b), inverse(x) | floats only | float |
floor-quotient(a, b), modulo(a, b) | integers only | integer, rounding toward negative infinity |
floor(x) | either | integer |
round(x) | either | the nearest integer, a tie going away from zero |
real(x) | either | float |
@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 aTypeMismatch. When the type of a context number is not known, normalize it:real($x)orfloor($x). sum()andsum([])are aTypeMismatch. Seed a fold over a list that may be empty:sum(0, $xs)orsum(0.0, $xs).- A zero divisor, an integer overflow and a non-finite float are a
NotRepresentableerror, nevernullor0. Guard withbranch, 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
intandfloat(@n : int = 3); there is nonumber.
Sorting and number formatting need no profile:
sort-by(list, fn)andsort-by-descending(list, fn)order a list by the keyfngives each element, as insort-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

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$ratiois a float. Match the literal to the value, or convert withreal/floor. - Don’t write a count as
1.0or1e3: 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.