0.0
The project is in a healthy, maintained state
Runs the exact same engine as the JavaScript/TypeScript package via an embedded WebAssembly module — byte-for-byte identical output, guaranteed by a shared conformance suite.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 30.0
 Project Readme

model-language

A typed, safe template language for AI-agent prompts — write once, run in eight languages.

npm PyPI crates.io RubyGems NuGet Hex
CI bundle size License: MIT powered by wexio.io

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-language

Using 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: upper lower trim capitalize truncate replace default
  • number: round floor ceil abs percent currency
  • array: count join first last limit pluck where sort sum max min
  • datetime: date (presets + token formats + timezone) time_ago days_ago days_until is_past is_future

Typesstring · 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/:

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

The 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


Built and maintained by Wexiowexio.io