model-language
v1.1.0
Published
A typed, safe template language for AI-agent prompts. Variables, conditions, loops and filters that render to clean text against live data, type-checked at edit time — never crashes, never leaks syntax. One WebAssembly engine, eight languages.
Maintainers
Readme
model-language
A typed, safe template language for AI-agent prompts — write once, run in eight languages.
▶ Live demo — model-language in a Tiptap editor
Type a template and watch it validate, autocomplete, and render in real time —
right in the browser, powered by this package. See
tiptap-model-language.
model-language lets non-technical users write prompts with variables, conditions, loops, and filters. The engine type-checks them against your data schema and renders them into clean prompts against live data — it never crashes and never leaks template syntax. One canonical TypeScript engine, compiled to WebAssembly, runs byte-for-byte identically in JavaScript, Python, Rust, Ruby, C#, C++, Go, and Elixir — verified by a shared conformance suite in CI.
Learn more: documentation · runnable examples · language hosts · contributing
Installation
You will need Node.js 18+ and npm (or another package manager).
npm install model-languageUsing another language? See Other languages — the same engine ships to Python, Rust, Ruby, C#, Go, C++, and Elixir.
Rendering a prompt
Parse once, render many. render() is a pure function — it never throws, and the
output has zero template syntax left in it.
import { parse, render, type FieldSchema } from 'model-language';
const schema: FieldSchema = [
{ path: 'user.name', type: 'string', nullable: true },
{ path: 'user.plan', type: 'enum', values: ['free', 'pro', 'team'] },
];
const source = `Hi {{user.name | default: "there"}}!
{{if user.plan == "pro"}}Priority support is on.{{/if}}`;
const { ast } = parse(source);
const { text } = render(ast, { user: { name: 'vasyl', plan: 'pro' } }, schema);
// text → "Hi vasyl!\nPriority support is on."Validating in the editor
validate() type-checks a template against the schema and returns stable
ML### diagnostics for live squiggles, quickfixes, and a worst-case token
estimate — so authoring mistakes are caught at edit time, not in production.
import { validate } from 'model-language';
const { diagnostics, maxTokenEstimate } = validate(
'{{if user.plan == "premium"}}...{{/if}}',
[{ path: 'user.plan', type: 'enum', values: ['free', 'pro'] }],
);
// diagnostics → [{ code: 'ML202', message: "'premium' is not a valid value…", … }]Examples: ML101 unknown-field (with a "did you mean" suggestion), ML102
unknown-filter, ML201 type-mismatch, ML210 missing-default on a nullable
field, ML213 prompt-over-budget, ML214 raw-date-comparison, ML220
==-on-multi-select (quickfix to contains). Full list:
diagnostics catalog.
Directives
Inline directives embed machine-readable instructions directly in a prompt
template. They are stripped from render().text and returned as structured data
in render().directives, where the host (e.g. the backend) consumes them as
runtime constraints — routing decisions, tool gates, assignment rules.
import { parse, render, validate } from 'model-language';
import type { DirectiveSpec, FieldSchema } from 'model-language';
const DIRECTIVES: DirectiveSpec[] = [
{ name: 'verify_before', hasBody: false, arg: { kind: 'scalar', type: 'enum', values: ['payments', 'calendar'] } },
{ name: 'identity', hasBody: false, arg: { kind: 'comparison', type: 'field', comparison: { operators: ['=='], operandType: 'field' } } },
{ name: 'assignedTo', hasBody: false, arg: { kind: 'list', type: 'id' } },
{ name: 'assignedToFallback', hasBody: false, arg: { kind: 'list', type: 'id' } },
{ name: 'assignedToRoles', hasBody: false, arg: { kind: 'list', type: 'enum', values: ['OWNER', 'ADMIN', 'EDITOR', 'AGENT'] } },
{ name: 'assignedToMaxCount', hasBody: false, arg: { kind: 'scalar', type: 'number' } },
];
const src = `Help with billing.
{{verify_before: payments}}
{{identity: contact.email == payment.email}}
Greet {{contact.first_name | default: "there"}}.`;
const { diagnostics } = validate(src, schema, { directives: DIRECTIVES });
// diagnostics → [] (zero errors)
const r = render(parse(src).ast, { contact: { first_name: 'Vasyl' } }, schema);
// r.text → 'Help with billing.\n\nGreet Vasyl.'
// r.directives → [{ name: 'verify_before', params: { raw: 'payments' } },
// { name: 'identity', params: { raw: 'contact.email == payment.email' } }]A directive inside a {{if}} branch fires only when that branch renders —
giving conditional gates for free (e.g. "require payment verification only on
the pro plan"). Full reference: docs/directives/README.md.
The language
{{ user.name | default: "there" }} variables + filter pipelines
{{ (order.total - order.discount) | round: 2 }} arithmetic (+ - * /, parens)
{{ calculate(user.mrr / user.seats, 2) }} calculate(expr, decimals?)
{{if x == "a" or y > 3}} … {{elseif …}} … {{else}} … {{/if}} conditionals
{{if user.csm exists}} … {{/if}} null-safe existence checks
{{if user.tags contains "vip"}} … {{/if}} array / multi-select membership
{{for item in order.items | where: "status", "==", "open" | limit: 3}}
- {{ item.title }} (#{{ loop.index }}/{{ loop.count }})
{{else}}
No open items.
{{/for}}
{{#priority high}} … {{/priority}} runtime directives → RenderResult
{{include "signature"}} reusable snippets (cycle-guarded)
{{# a comment, never rendered #}}Operators — == != < > <= >=, and / or / not, in, contains,
contains_any, contains_all, is_empty, exists, startsWith, endsWith,
matches.
Filters (total — the wrong input type passes through, never throws):
- text:
upperlowertrimcapitalizetruncatereplacedefault - number:
roundfloorceilabspercentcurrency - array:
countjoinfirstlastlimitpluckwheresortsummaxmin - datetime:
date(presets + token formats + timezone)time_agodays_agodays_untilis_pastis_future
Types — string · number · boolean · datetime · array · enum · multiEnum ·
object · dynamic, plus null (present-but-empty) vs undefined (missing).
Dates are compared through filters (| days_ago > 30), never raw.
Public API
| Function | Purpose |
|---|---|
| parse(source) | text → { ast, diagnostics } (with error recovery) |
| serialize(ast) | AST → canonical text (deterministic whitespace) |
| validate(source, schema, opts?) | typecheck → { ast, diagnostics, maxTokenEstimate } |
| render(ast, snapshot, schema, opts?) | AST + data → { text, warnings, resolvedBranches, directives, tokenEstimate } — never throws |
| registerFilter(def) | add a host filter (e.g. locale-aware currency) |
| registerRule(rule) | add a host lint rule (e.g. a private-field policy) |
Full type contract: src/types.ts.
Performance
Parse once (cold, cacheable), render many (hot). Rendering a pre-parsed AST is the per-message cost; caching it is the whole game.
| Template | render (hot) | |---|---| | typical prompt (tens–hundreds of lines) | 13–75 µs | | pathological 3,500-line, 500-rule prompt | ~1 ms |
Cost scales linearly with template size — logical rules never blow up (each
condition is short-circuit-evaluated once). Numbers and methodology:
bench/RESULTS.md.
Other languages
The engine is compiled once to a self-contained WebAssembly (WASI) module and
called over a stable JSON contract, so hosts don't reimplement the language — the
same .wasm produces byte-for-byte identical output everywhere, verified against
the shared conformance suite in CI.
| Language | Install | Package | Host |
|---|---|---|---|
| JavaScript | npm i model-language | npm | this package |
| Python | pip install model-language | PyPI | hosts/python |
| Rust | cargo add model-language | crates.io | hosts/rust |
| Ruby | gem install model-language | RubyGems | hosts/ruby |
| C# | dotnet add package ModelLanguage | NuGet | hosts/csharp |
| Elixir | {:model_language, "~> 1.0"} | Hex | hosts/elixir |
| Go | go get …/hosts/go | — (git) | hosts/go |
| C++ | vendor + the C API | — (vendor) | hosts/cpp |
Each package embeds the module — nothing else to install. See
hosts/ for a guide to hosting in any other WASI language, and
RELEASING.md for the publish flow.
Documentation
Complete reference in docs/:
- Getting started · Public API
- Variables · Types · Conditionals · Loops
- Math & functions · Filters · Directives & includes · Diagnostics
Runnable templates with data + expected output live in examples/;
language-neutral golden fixtures (the cross-host contract) in
conformance/.
Development
pnpm install
pnpm test # vitest — golden + unit + fuzz + round-trip (100% coverage gate)
pnpm bench # vitest bench — parse / render / validate
pnpm lint # Biome
pnpm typecheck # tsc --noEmit
pnpm build # tsup → dist (ESM + CJS + .d.ts)
pnpm wasm:build # esbuild bundle → Javy → wasm/dist/model_language.wasmThe golden/conformance suite is the contract — breaking a case is a breaking
change. Every doc example runs as a test (docs can't drift), fuzzing asserts the
prime directive (render() never throws, parse() always recovers), and the
round-trip invariant holds: parse(serialize(ast)) ≡ ast.
Contributing
Contributions welcome — see CONTRIBUTING.md. The engine is
one canonical TypeScript implementation; the conformance suite
is the contract, and coverage is a hard 100% gate. Security issues:
SECURITY.md.
License
MIT © Wexio
