Reference Experimental
ChatML language reference
Values, functions, pattern matching, tasks, and the moderator script contract.
View Markdown source ↗For current native/daemon hosting, see host modes and agent-host orchestration. Daemon work belongs to the session actor, not a connected UI. The existing language/tool APIs remain shared; file-backed session/controller descriptions should be read in that host context. Instruction helper compatibility names emit developer-role messages.
This document is the implementation-faithful specification of ChatML as it exists in the current codebase.
It describes the language and runtime pipeline implemented by:
lib/chatml/chatml_lexer.mlllib/chatml/chatml_parser.mlylib/chatml/chatml_lang.mllib/chatml/chatml_parse.mllib/chatml/chatml_eval.mllib/chatml/frame_env.mllib/chatml/chatml_slot_layout.mllib/chatml/chatml_typechecker.mllib/chatml/chatml_resolver.mllib/chatml/chatml_builtin_spec.mllib/chatml/chatml_builtin_modules.mllib/chatml/chatml_builtin_surface.mllib/chatml/chatml_value_codec.mllib/chatml/chatml_moderator_runtime.ml
When this document and the implementation disagree, the implementation is authoritative.
1. Purpose and design constraints
Section titled “1. Purpose and design constraints”ChatML is a small, statically typed scripting language intended for:
- orchestration scripts
- event-driven glue logic
- lightweight state-machine code
- embedding inside a host runtime with a tiny standard library
- prompt/test-scenario scripting inside the surrounding OCaml project
ChatML is intentionally not trying to be:
- a full general-purpose application language
- a rich module language
- a type-class / trait / ad-hoc-overloading language
- a full algebraic-datatype / type-parameter language
- a high-performance numerical language
The implementation is intentionally biased toward:
- sound static typing
- good ergonomics with inference-first typing and a small explicit type surface
- small surface area
- predictable operational behavior
- enough runtime performance for scripting workloads
Complexity is deliberately pushed into:
- the host runtime
- the typechecker and resolver
- the internal frame/slot machinery
- builtin host functions
rather than into a large user-facing surface language.
2. High-level language model
Section titled “2. High-level language model”ChatML is:
- expression-oriented
- lexically scoped
- call-by-value
- statically typed with inference
- ML-flavored in syntax
- able to mix immutable and mutable programming styles
The surface language supports:
- first-class functions
- local and recursive bindings
- top-level named type declarations
- checked binding annotations
- structural records
- polymorphic variants
- arrays
- refs
- pattern matching
- simple modules
ChatML now has a deliberately small user-facing type surface:
- top-level
typedeclarations, - binding annotations on
let,let rec, andlet ... in, - explicit recursive types introduced through those declarations.
It still does not provide a full ML type language: there are no type parameters, no mutual recursive type declarations, and no general expression-level ascription syntax.
Function calls use explicit call syntax:
f(x)f(x, y)g()ChatML does not use whitespace application (f x) and does not
expose currying as the primary call model. Functions are internally modeled
as taking an explicit list of parameters and are called with exact arity.
3. Implementation architecture
Section titled “3. Implementation architecture”This section describes the actual pipeline used by the implementation.
3.1 Phases
Section titled “3.1 Phases”The implementation is split into four conceptual phases:
- Lexing/parsing
- Type checking
- Resolution/lowering
- Evaluation
3.2 Source AST vs resolved AST
Section titled “3.2 Source AST vs resolved AST”lib/chatml/chatml_lang.ml defines two different AST families:
- a source AST used by the parser and typechecker
- a resolved AST used by the evaluator
Source expressions include forms such as:
EVarELambdaELetInELetRecEMatch
Resolved expressions include:
REVarGlobalREVarLocRELambdaRELetBlockRELetRecREMatch
This phase split is deliberate:
- the typechecker works over source syntax
- the resolver lowers local variables to lexical addresses and binding layouts
- the evaluator only runs resolved programs
3.3 Program representation
Section titled “3.3 Program representation”Internally, a parsed program is represented as:
type program = { stmts : stmt_node list ; source_text : string }and a resolved program as:
type resolved_program = { stmts : resolved_stmt_node list ; source_text : string }The stored source_text is used for diagnostic formatting.
3.4 Type checking
Section titled “3.4 Type checking”lib/chatml/chatml_typechecker.ml implements:
- Hindley–Milner inference
- value restriction
- row-polymorphic records
- row-polymorphic variants
- match checking
- builtin type import
It also records inferred types by source span so the resolver can use that information later when choosing frame slots.
3.5 Resolution
Section titled “3.5 Resolution”lib/chatml/chatml_resolver.ml performs:
- lexical-address resolution for locals
- lowering to resolved AST
- slot selection for locals, parameters, and pattern binders
- non-recursive let-block coalescing
After this pass:
- local variable access is frame-based and indexed
- globals/modules/builtins remain name lookups
3.6 Evaluation
Section titled “3.6 Evaluation”lib/chatml/chatml_eval.ml evaluates only resolved AST.
The evaluator uses:
- a mutable hash-table environment for top-level/module/global bindings
- a stack of frames for local lexical bindings
Function calls use a trampoline. Tail calls are now tail-position-aware:
- closure applications in tail position produce
TailCall - closure applications outside tail position are forced immediately
This preserves proper tail-call behavior where it matters while avoiding unnecessary trampoline traffic in non-tail contexts.
3.7 Frames and slots
Section titled “3.7 Frames and slots”lib/chatml/frame_env.ml provides the low-level local storage runtime.
A frame now stores:
- the raw cells
- the slot layout used to allocate them
Each frame access is validated against its expected slot descriptor.
This means:
- resolver/evaluator slot mismatches fail fast
- out-of-bounds accesses fail fast
- internal frame corruption is less likely to go unnoticed
lib/chatml/chatml_slot_layout.ml centralizes the slot-selection policy so
the resolver and evaluator do not maintain duplicated logic.
4. Lexical structure
Section titled “4. Lexical structure”4.1 Identifiers
Section titled “4.1 Identifiers”ChatML has three main identifier classes:
- lowercase identifiers, e.g.
x,state,task_index - uppercase identifiers, e.g.
M,Flow,TaskHelpers - variant tags, e.g.
`Some,`Done
Lowercase and uppercase identifiers are tokenized separately. Uppercase identifiers are mostly intended for modules, but in expression position they still enter the AST as ordinary variable references.
4.2 Literals
Section titled “4.2 Literals”Supported literal forms:
- integers:
0,1,42 - floats:
1.0,3.14 - booleans:
true,false - strings:
"hello" - unit:
()
4.3 Comments
Section titled “4.3 Comments”Comments use OCaml-style block syntax:
(* this is a comment *)Nested comments are supported by the lexer.
4.4 Strings
Section titled “4.4 Strings”Strings support at least:
\n\t\\\"
Strings may span multiple lines.
4.5 Whitespace
Section titled “4.5 Whitespace”Whitespace is not significant except as a token separator.
ChatML is not indentation-sensitive.
5. Program structure
Section titled “5. Program structure”A ChatML program is a sequence of top-level statements.
Top-level statement forms:
type t = type_exprlet x = exprlet x : type_expr = exprlet f a b = exprlet f () = exprlet rec f : type_expr = expr and g : type_expr = exprlet rec f x = expr and g y = exprmodule M = struct ... endopen M- a bare expression statement
Evaluation proceeds top-to-bottom in source order.
The top level is mutable in the sense that each statement extends the current environment, but closures capture lexical bindings stably.
6. Statement forms
Section titled “6. Statement forms”6.1 Non-recursive top-level let
Section titled “6.1 Non-recursive top-level let”Examples:
let x = 1let name = "Alice"let inc n = n + 1let thunk () = 42Properties:
- the RHS is evaluated before the new binding is introduced
- the new binding is then added to the current environment
- later top-level lets may shadow earlier ones
- already-created closures still observe the lexical binding they captured
6.2 Recursive top-level let rec
Section titled “6.2 Recursive top-level let rec”Examples:
let rec fact n = if n == 0 then 1 else n * fact(n - 1)let rec even n = if n == 0 then true else odd(n - 1)and odd n = if n == 0 then false else even(n - 1)Restrictions:
- recursive bindings must be function-like
- non-function recursive bindings are rejected statically
Operationally:
- recursive names are allocated first
- placeholders are installed
- each RHS is evaluated in an environment where all recursive names are visible
- placeholders are updated with the final values
6.3 Top-level type
Section titled “6.3 Top-level type”Examples:
type expr = [ `Int(int) | `Add(expr, expr) ]type task = { name : string; attempts : int; status : status }Properties:
- type declarations are compile-time only
- they introduce names into a separate type namespace
- they are currently allowed only at the top level
- later statements may refer to earlier type declarations
- module bodies may refer to earlier top-level type declarations
opendoes not import type names- type declarations are alias-like, not nominal runtime entities
Recursive type declarations are allowed, but only in explicit checked form. The typechecker validates them for contractiveness:
- accepted:
type expr = [ \Int(int) | `Add(expr, expr) ]`type node = { value : int; next : node }
- rejected:
type bad = bad
Current intentional limitations:
- no
type ... and ... - no type parameters
- no module-local type declarations
- no forward references to later type declarations
6.4 Modules
Section titled “6.4 Modules”Example:
module Flow = struct let x = 1 let id y = yendModules are intentionally simple namespaces.
Properties:
- module bodies may reference outer bindings
- only names explicitly defined in the module body are exported
- names imported via
openinside a module are not re-exported - modules are represented as records by the typechecker and as
VModulevalues at runtime
Type declarations are not statements inside module bodies in the current surface grammar.
6.5 open
Section titled “6.5 open”Example:
open FlowSemantics:
- imports all exported names from the module into the current scope
- does not create a module alias
- is shallow; there is no selective import syntax
- now rejects shadowing of existing names
So this is rejected:
let x = 1module M = struct let x = 2endopen MBoth the typechecker and the runtime reject such shadowing.
7. Expression forms
Section titled “7. Expression forms”7.1 Unit
Section titled “7.1 Unit”()Type: unit
7.2 Variables
Section titled “7.2 Variables”xstateFlowVariables are lexically scoped.
After resolution:
- local variables become lexical-address lookups into frames
- globals/modules/builtins remain environment lookups
7.3 Functions
Section titled “7.3 Functions”Anonymous functions:
fun x -> xfun x y -> xfun () -> 42Named function syntax is sugar for a let binding of a lambda:
let add x y = x + yProperties:
- functions are first-class
- closures capture lexical environment plus local frame stack
- calls are strict
- exact arity is required
- tail calls are optimized through a trampoline
For annotated functions, the current surface syntax annotates the binding, not individual parameters:
let rec eval : expr -> int = fun e -> ...Zero-argument annotated functions use unit -> t:
let finish_action : unit -> string = fun () -> "done"7.4 Function application
Section titled “7.4 Function application”Examples:
f(x)f(x, y)g()Function position may itself be any expression:
(fun x -> x)(1)choose(true)(1)There is no whitespace application syntax such as f x.
7.5 Local let ... in
Section titled “7.5 Local let ... in”Examples:
let x = 1 in x + 1let x : int = 1 in x + 1let f y = y in f(3)let rec loop n = ... in loop(10)let* x = task1 in task2let+ x = task1 in value_exprProperties:
- non-recursive lets are lexical and sequential
- nested non-recursive lets are internally grouped into
RELetBlocklayouts by the resolver let recinside expressions follows the same recursive-function restriction as top-levellet rec- binding annotations are checked against the inferred RHS type
- there is currently no general
(expr : type)surface syntax; annotations are introduced through binding forms let*andlet+are task-composition forms that desugar toTask.bindandTask.map
7.6 Conditionals
Section titled “7.6 Conditionals”if cond then a else bRules:
- condition must have type
bool - both branches must have the same type for non-record results
- record-valued branches are combined using a conservative join
For records, ChatML keeps only the fields that are guaranteed on every branch. This avoids unsoundly concluding that a field exists just because one branch adds it with copy-update.
Example:
let maybe_set_running b st = if b then st else { st with running = true }The result of maybe_set_running is not treated as definitely having a
running field, because the then branch returns st unchanged.
By contrast:
let set_running st running = { st with running = running }
let ensure_running b st = if b then set_running(st, true) else set_running(st, false)does guarantee running on every path, so the joined result keeps that field.
7.7 Sequencing
Section titled “7.7 Sequencing”e1; e2Rules:
e1is evaluated fully first- its value is discarded
- the result is the value of
e2
7.8 While loops
Section titled “7.8 While loops”while cond do body doneRules:
- condition must have type
bool - loop result type is
unit - loop body may have side effects
7.9 Records
Section titled “7.9 Records”Record literal:
{ name = "Alice"; age = 30 }Field access:
person.nameRecord copy-update:
{ person with age = person.age + 1 }Properties:
- records are structural
- field names are unique within a literal
- duplicate field labels in a literal are rejected
- copy-update is immutable
- copy-update may overwrite fields
- copy-update may add fields to closed records
- copy-update may also add fields through open-row helper functions
- copy-update may change field types
7.10 Arrays
Section titled “7.10 Arrays”Array literal:
[1, 2, 3]Indexing:
arr[i]Update:
arr[i] <- vProperties:
- arrays are homogeneous
- arrays are mutable
- index type must be
int - out-of-bounds access is a runtime error
- update returns
unit
7.11 References
Section titled “7.11 References”Creation:
ref(0)Dereference:
!rAssignment:
r := 1Properties:
- refs are mutable cells
- dereference requires a ref
- assignment requires a ref value and a value of the stored type
- assignment returns
unit
7.12 Variants
Section titled “7.12 Variants”Examples:
`None`Some(1)`Pair(1, "x")Properties:
- variants are polymorphic variants
- constructors are identified by tag name
- constructors may carry zero, one, or multiple payload values
- multi-value payloads are typed using internal tuple types
8. Operators
Section titled “8. Operators”Operators are built into the core AST. They are not looked up from the runtime environment and cannot be overridden.
8.1 Integer arithmetic
Section titled “8.1 Integer arithmetic”- binary
+ - binary
- - binary
* - binary
/ - unary
-
Operands must be int; result is int.
Division by zero is a runtime error.
8.2 Float arithmetic
Section titled “8.2 Float arithmetic”- binary
+. - binary
-. - binary
*. - binary
/. - unary
-.
Operands must be float; result is float.
Division by zero is a runtime error when the divisor is 0.0.
8.3 String concatenation
Section titled “8.3 String concatenation”- binary
++
Operands must be string; result is string.
8.4 Integer comparisons
Section titled “8.4 Integer comparisons”<><=>=
Operands must be int; result is bool.
8.5 Float comparisons
Section titled “8.5 Float comparisons”<.>.<=.>=.
Operands must be float; result is bool.
8.6 Equality and inequality
Section titled “8.6 Equality and inequality”==!=
Both operands must have the same type.
However, equality is now restricted. It is accepted only for types that the typechecker considers equality-supporting.
Accepted:
intfloatboolstringunit- tuples of equality-supporting element types
- records whose known field types are equality-supporting
- variants whose known payload types are equality-supporting
Rejected:
- arrays
- refs
- functions
- tasks
Examples:
1 == 1 (* ok *)"a" != "b" (* ok *)[1, 2] == [1] (* type error *)Implementation note:
- runtime equality for records/variants is structural
- runtime equality for arrays/refs/closures/modules/builtins is by identity
- the typechecker now rejects the most problematic unsupported cases
8.7 Precedence and associativity
Section titled “8.7 Precedence and associativity”The current parser precedence is roughly:
- comparisons and equality
- additive operators
- multiplicative operators
- dereference handling
Concretely:
+,-,++,+.,-.share a precedence level*,/,*.,/.share a tighter precedence level- comparison/equality are looser than arithmetic
Use parentheses whenever readability matters.
9. Pattern matching
Section titled “9. Pattern matching”9.1 Supported patterns
Section titled “9.1 Supported patterns”ChatML supports:
- wildcard:
_ - variable binder:
x - unit:
() - integer literal patterns
- boolean literal patterns
- float literal patterns
- string literal patterns
- variant patterns:
`Tag`Tag(p1, ..., pn)
- record patterns:
{ field = pat; field2 = pat2 }{ field = pat; _ }
9.2 Runtime matching
Section titled “9.2 Runtime matching”Match arms are tried in source order.
The first matching arm is selected.
If no arm matches at runtime, evaluation raises a runtime error.
9.3 Pattern variable order
Section titled “9.3 Pattern variable order”Pattern variables are collected in deterministic left-to-right order. This matters for resolver slot layout, but not for user-visible semantics.
9.4 Record patterns
Section titled “9.4 Record patterns”Closed record pattern:
{ name = n }This requires the record to have exactly the named fields.
Open record pattern:
{ name = n; _ }This requires the record to have at least those fields.
9.5 Static match checks
Section titled “9.5 Static match checks”The typechecker performs:
- duplicate binder checks
- duplicate simple-arm checks
- some redundancy checks
- conservative exhaustiveness checks
The result type of a match follows the same rule as if:
- non-record arm results must unify to the same type
- record-valued arm results are combined using the same conservative join
used for
if
So if one arm adds a record field and another arm does not guarantee it, the
overall match result type does not retain that field.
Exhaustiveness is strongest for:
- booleans
- unit
- sufficiently closed variant matches
It is conservative for:
- ints
- floats
- strings
- records
- open variants
9.6 Variant narrowing by match
Section titled “9.6 Variant narrowing by match”Variant-using functions can become narrower after informative matches.
Example:
let f v = match v with | `Some(x) -> xThe parameter type inferred for v may be narrowed to compatible variants,
rather than remaining arbitrarily open.
This is intentional.
10. Type system
Section titled “10. Type system”ChatML uses Hindley–Milner style inference with extensions for:
- explicit recursive types
- mutation safety via the value restriction
- row-polymorphic records
- row-polymorphic variants
Unlike earlier versions, ChatML now has a small explicit type surface for:
- top-level named type declarations
- checked binding annotations
Ordinary HM inference variables are acyclic again. Recursive types are not inferred accidentally from ordinary unification; they are introduced only through explicit checked declarations.
10.1 Primitive types
Section titled “10.1 Primitive types”unitintfloatboolstring
10.2 Composite types
Section titled “10.2 Composite types”- function types
- array types
- ref types
- record types
- variant types
- tuple types
Tuple types currently exist in the type system and runtime representation, but tuple syntax is not exposed as a general user-facing surface feature. They are most visible as the internal typing of multi-argument variant payloads.
10.2.1 User-facing type syntax
Section titled “10.2.1 User-facing type syntax”The current user-facing type-expression syntax supports:
- primitive names:
intfloatboolstringunit
- previously declared type names
- function types:
expr -> intstate -> event -> stateunit -> string
- postfix unary type constructors:
task arraystate taskstate task array
- closed record types:
{ name : string; attempts : int }
- closed variant types:
[Pending |Done |Error(string) ]`
Examples:
type status = [ `Pending | `Running | `Done | `Error(string) ]type task = { name : string ; attempts : int ; status : status }
let step : task -> status = fun t -> t.statusCurrent user-facing omissions are intentional:
- no tuple type syntax
- no ref type syntax
- no open-row type syntax
- no type parameters
- no mutual recursive type declarations
The parser accepts generic postfix lowercase unary constructors in type expressions, but the current typechecker recognizes only the constructors implemented by the host/type environment. In the built-in surface those are currently:
arraytask
10.2.2 Contractiveness
Section titled “10.2.2 Contractiveness”Recursive type declarations must be contractive: self-reference must appear under a real constructor.
Accepted:
type expr = [ `Int(int) | `Add(expr, expr) ]type node = { value : int; next : node }Rejected:
type bad = badThis rule keeps recursive types sound while still supporting the recursive record and recursive variant use-cases ChatML scripts rely on.
10.3 Let-polymorphism
Section titled “10.3 Let-polymorphism”Non-expansive bindings may be generalized.
Example:
let id x = xid(1)id("s")10.4 Value restriction
Section titled “10.4 Value restriction”Expansive bindings are not generalized.
This is necessary for soundness with:
- refs
- arrays
- mutable aliasing
10.4.1 Recursive types remain monomorphic
Section titled “10.4.1 Recursive types remain monomorphic”Bindings whose type contains an explicit recursive type are kept monomorphic in the current design.
This applies even when the binding is otherwise non-expansive.
The implementation intentionally does not attempt polymorphic recursion.
10.5 Records and row polymorphism
Section titled “10.5 Records and row polymorphism”Record helpers usually infer open-row behavior.
Example:
let get_name p = p.nameThis can be used on:
{name = "A"}{name = "A"; age = 1}Important implementation detail:
- lambda parameters discovered to be record-shaped are reopened to open rows
- this heuristic is intentionally biased toward record-heavy scripting and state-machine helpers
- variants are not reopened by the same heuristic
10.5.1 Control-flow joins for records
Section titled “10.5.1 Control-flow joins for records”Record copy-update can widen a record result:
let with_timeout cfg ms = { cfg with timeout_ms = ms }This gives with_timeout the expected shape:
{ ...r } -> int -> { timeout_ms : int; ...r }However, if and match do not preserve fields that appear on only some
paths. Instead, they compute a conservative join that keeps only fields
guaranteed on every branch.
This means ChatML no longer relies on branch-shape heuristics. If a field should be available after control flow, make that field explicit on every returned branch.
Recommended patterns:
- ensure initialization helpers return the same shape on all paths
- make field updates explicit on every branch
Explicit field-update example
Section titled “Explicit field-update example”Instead of:
let step st ev = match ev with | `Start -> if st.idx >= length(st.tasks) then st else set_status({ st with running = true }, status_witness(1))
| `Tick -> if st.running == false then st else ...prefer:
let set_running st running = { st with running = running }
let step st ev = match ev with | `Start -> if st.idx >= length(st.tasks) then set_running(st, false) else set_status(set_running(st, true), status_witness(1))
| `Tick -> if st.running == false then set_running(st, false) else let st = set_running(st, true) in ...Why this works:
- every returned branch now explicitly produces a state with
running - the conservative join can therefore keep
running - no branch-shape heuristic is required
One-sentence summary:
if a record field must exist after
iformatch, make sure every branch returns a record that explicitly contains that field.
10.6 Variants and row polymorphism
Section titled “10.6 Variants and row polymorphism”Variant constructors are typed using row-based variant information.
Examples:
`None`Some(1)`Pair(1, "x")Variant row information interacts with pattern matching and may become narrower after informative matches.
10.7 Recursive bindings
Section titled “10.7 Recursive bindings”Recursive bindings must be functions.
This avoids unsound and difficult recursive value-inference cases.
10.8 Builtin type schemes
Section titled “10.8 Builtin type schemes”The builtin specification language now supports:
- type variables
- primitive types
- arrays
- refs
- tuples
- row-based records
- row-based variants
- function types
- explicit recursive types (mu-style binders) used internally by some builtin modules (not user-surface syntax)
Notes:
This builtin type language is richer than the current user-facing type language. Users still do not write builtin-only forms such as ref types, tuple types, open-row forms, or explicit recursive binders directly; they appear only through host-provided builtin schemes.
10.9 Module typing
Section titled “10.9 Module typing”Modules are typed as records of exports.
This is intentionally simple and matches the intended “modules are just structuring” design.
Implementation note:
- runtime modules are represented as
VModule - the typechecker models them as record types of exports
This is usually ergonomic, but it also means module values are not a fully separate static category.
Type declarations are not exported as module fields, because they do not
exist at runtime and open affects only value bindings.
11. Runtime model
Section titled “11. Runtime model”11.1 Values
Section titled “11.1 Values”The runtime supports:
- ints
- bools
- floats
- strings
- variants
- records
- arrays
- refs
- closures
- modules
- unit
- builtins
- tasks
11.2 Closures
Section titled “11.2 Closures”Closures capture:
- the lexical environment
- the local frame stack
- the parameter slot layout
Closures capture lexical bindings stably, so later rebinding does not change what an earlier closure sees.
11.3 Environments
Section titled “11.3 Environments”There are two main runtime storage mechanisms:
- a mutable hash-table environment for globals/modules/builtins
- a stack of local frames for resolved lexical locals
11.4 Resolution and lexical addresses
Section titled “11.4 Resolution and lexical addresses”The resolver rewrites local variables into lexical addresses carrying:
- frame depth
- slot index
- slot descriptor
This allows:
- O(1)-style local reads
- one-frame block allocation for grouped lets
- reduced runtime name lookup for locals
11.5 Frames
Section titled “11.5 Frames”Frames are heterogeneous storage blocks described by packed slot layouts.
The runtime currently distinguishes slots for:
intboolfloatstring- generic object slots
Each frame now stores its layout explicitly, and frame reads/writes validate that the requested slot matches the allocated layout.
11.6 Slot selection
Section titled “11.6 Slot selection”Slot selection is shared between resolver and evaluator through
chatml_slot_layout.ml.
This keeps:
- static slot selection
- runtime slot/value validation
- fallback expression-shape heuristics
consistent.
11.7 Tail calls
Section titled “11.7 Tail calls”Closure calls are executed through a trampoline.
Current behavior:
- calls in tail position use
TailCall - calls outside tail position are forced immediately
This gives tail recursion support without forcing every function call through the trampoline.
11.8 Runtime errors
Section titled “11.8 Runtime errors”Possible runtime failures include:
- division by zero
- array index out of bounds
- dereference of non-ref
- assignment to non-ref
- calling a non-function value
- function arity mismatch
- non-exhaustive runtime pattern match
- invalid field access
- invalid
open openshadowing collisions
Ill-typed programs are normally rejected before evaluation in the standard pipeline.
12. Modules
Section titled “12. Modules”Modules are intentionally simple namespace containers.
12.1 What modules are for
Section titled “12.1 What modules are for”Modules are for:
- grouping helper functions
- reducing naming clutter
- structuring scripts
Modules are not for:
- signatures
- functors
- generative module behavior
- abstraction-heavy namespace engineering
12.2 Export behavior
Section titled “12.2 Export behavior”Only names explicitly defined in the module body are exported.
Example:
let x = 1module M = struct let y = xendValid:
M.yInvalid:
M.x12.3 open behavior
Section titled “12.3 open behavior”open M copies module exports into the current environment for subsequent
lookup.
It does not:
- re-export opened names automatically
- support selective imports
- allow silent shadowing
The language now rejects open if it would overwrite an existing binding in
the current scope.
13. Standard library and builtin surfaces
Section titled “13. Standard library and builtin surfaces”ChatML now uses a composable builtin-surface model rather than a single hard-coded builtin universe.
A builtin surface may contribute:
- global builtin functions,
- builtin modules installed as
VModulevalues (typed as records of exports), - builtin type aliases injected into the initial type environment.
The current implementation exposes two standard assembled surfaces:
core_surfacemoderator_surface
Arithmetic, string concatenation, comparison, and equality operators remain language primitives rather than runtime-installed builtins.
13.1 Global builtins
Section titled “13.1 Global builtins”Installed global builtins in core_surface:
print : 'a -> unitto_string : 'a -> stringlength : 'a array -> intstring_length : string -> intstring_is_empty : string -> boolarray_copy : 'a array -> 'a arrayrecord_keys : { ...r } -> string arrayvariant_tag : [ ...r ] -> stringswap_ref : ref('a) -> 'a -> 'afail : string -> 'aNotes:
- print renders a stable human-readable representation of runtime values.
- to_string returns that representation.
- length works on arrays only.
- array_copy is a shallow copy of the array container.
- record_keys works on record values, and also on module values because modules are record-like at the type level.
- variant_tag returns only the constructor/tag name, not the payload.
- swap_ref r v stores v into r and returns the old contents.
- fail raises a runtime failure and is polymorphic in its result position.
13.2 Builtin surfaces
Section titled “13.2 Builtin surfaces”13.2.1 core_surface
Section titled “13.2.1 core_surface”core_surface currently provides:
- the global builtins listed above,
- builtin modules:
TaskStringArrayJsonOptionHashtbl
- builtin type aliases:
json
13.2.2 moderator_surface
Section titled “13.2.2 moderator_surface”moderator_surface extends core_surface with moderator-oriented modules
and structural type aliases.
Additional builtin modules:
LogItemTool_callContextTurnToolModelProcessScheduleRuntime
Additional builtin type aliases:
itemtool_desctool_calltool_resultcontext
Each builtin module is a VModule value at runtime and is typed as a
record of its exports by the typechecker.
13.2.2.1 ui_moderator_surface
Section titled “13.2.2.1 ui_moderator_surface”ui_moderator_surface extends moderator_surface with UI-only capability
modules:
UiApproval
This surface is intended for interactive hosts that support host-local
notifications and live approval pause/resume behavior. The default
moderator_surface remains non-UI.
13.2.3 Task module
Section titled “13.2.3 Task module”The Task builtin module provides the core task combinators used by the
moderator-runtime embedding:
Task.pure : 'a -> 'a taskTask.bind : 'a task -> ('a -> 'b task) -> 'b taskTask.map : 'a task -> ('a -> 'b) -> 'b taskTask.fail : string -> 'a taskTask.catch : 'a task -> (string -> 'a task) -> 'a taskThese functions construct and compose task values. They do not themselves perform host-side effects.
13.2.4 String module (updated)
Section titled “13.2.4 String module (updated)”The String builtin module provides common string utilities.
Exports:
Basic operations
Section titled “Basic operations”String.length : string -> intString.is_empty : string -> boolString.concat : string -> string -> string
Notes:
String.concat(a, b)is ordinary concatenation. The language also provides the++operator.
Comparison and queries
Section titled “Comparison and queries”String.equal : string -> string -> boolString.contains : string -> string -> bool
True if the second string is a substring of the first.String.starts_with : string -> string -> boolString.ends_with : string -> string -> bool
Transformations
Section titled “Transformations”String.trim : string -> string
Removes leading and trailing whitespace.String.to_upper : string -> stringString.to_lower : string -> string
Slicing, search, and split
Section titled “Slicing, search, and split”String.slice : string -> int -> int -> string
slice(s, start, len)returns the substring of lengthlenstarting atstart. Raises on invalid bounds.String.find : string -> string -> [ \None | `Some(int) ]`
Finds the first occurrence of the pattern and returns its starting index.String.split : string -> string -> string array
Splits on a non-empty separator string; raises if the separator is empty.String.replace_all : string -> string -> string -> string
replace_all(s, pattern, with_)replaces all non-overlapping occurrences. Raises ifpatternis empty.
13.2.5 Array module (updated)
Section titled “13.2.5 Array module (updated)”The Array builtin module provides array utilities. Arrays are homogeneous and mutable.
Exports:
Basic operations
Section titled “Basic operations”Array.length : 'a array -> intArray.copy : 'a array -> 'a arrayArray.get : 'a array -> int -> 'aArray.set : 'a array -> int -> 'a -> unit
Notes:
Array.getandArray.setraise a runtime error on out-of-bounds indices.Array.lengthoverlaps with the global builtinlength. Becauseopenrejects shadowing,open Arraymay be rejected in scopes wherelengthis already bound (including the default prelude).
Allocation / structural utilities (non-higher-order)
Section titled “Allocation / structural utilities (non-higher-order)”Array.make : int -> 'a -> 'a array
Creates an array of the given length filled with the provided value. Raises on negative length.Array.append : 'a array -> 'a array -> 'a array
Allocates a new array containing the concatenation of the two inputs.Array.sub : 'a array -> int -> int -> 'a array
sub(arr, start, len)returns a new array slice. Raises on invalid bounds.Array.reverse : 'a array -> 'a array
Returns a reversed copy.Array.reverse_in_place : 'a array -> unit
Mutates the array by reversing it.Array.swap : 'a array -> int -> int -> unit
Swaps two indices. Raises on invalid bounds.Array.fill : 'a array -> 'a -> unit
Mutates the array by filling every element with the provided value.
Higher-order utilities (call back into ChatML)
Section titled “Higher-order utilities (call back into ChatML)”These functions accept ChatML functions/closures as arguments. They execute those callbacks using the interpreter’s normal call semantics (strict, arity-checked, tail-call aware), and propagate runtime failures from inside the callback.
Array.init : int -> (int -> 'a) -> 'a array
Creates a new array by calling the function on indices0..n-1. Raises on negative length.Array.map : 'a array -> ('a -> 'b) -> 'b arrayArray.mapi : 'a array -> (int -> 'a -> 'b) -> 'b arrayArray.iter : 'a array -> ('a -> unit) -> unitArray.iteri : 'a array -> (int -> 'a -> unit) -> unitArray.fold : 'a array -> 'b -> ('b -> 'a -> 'b) -> 'b
Left fold in index order.Array.filter : 'a array -> ('a -> bool) -> 'a arrayArray.exists : 'a array -> ('a -> bool) -> boolArray.for_all : 'a array -> ('a -> bool) -> bool
Option-returning search helpers
Section titled “Option-returning search helpers”These use the standard option encoding as variants:
`None`Some(x)
Exports:
Array.find : 'a array -> ('a -> bool) -> [ \None | `Some(‘a) ]Returns the first element satisfying the predicate, or`None`.Array.find_map : 'a array -> ('a -> [ \None | `Some(‘b) ]) -> [ `None | `Some(‘b) ]Applies the mapping function left-to-right and returns the first`Some(…)result, or`None`.
13.2.6 Option module
Section titled “13.2.6 Option module”This module uses the convention that option values are represented as variants:
`None`Some(x)Exports:
Option.none : unit -> [ \None | `Some('a) ]`Option.some : 'a -> [ \None | `Some('a) ]`Option.is_none : [ \None | `Some('a) ] -> bool`Option.is_some : [ \None | `Some('a) ] -> bool`Option.get_or : [ \None | `Some('a) ] -> 'a -> 'a`Notes:
- This is a convenience module; users can also directly construct and match on
None andSome(…).
13.2.7 Hashtbl module (string-keyed)
Section titled “13.2.7 Hashtbl module (string-keyed)”This is a small builtin hashtable-like abstraction with string keys. It is implemented using existing runtime values (refs + arrays of entries) and is intended for scripting convenience, not high performance.
Exports (conceptual types):
Hashtbl.create : unit -> hashtbl('a)Hashtbl.set : hashtbl('a) -> string -> 'a -> unitHashtbl.get : hashtbl('a) -> string -> [ \None | `Some('a) ]`Hashtbl.mem : hashtbl('a) -> string -> boolHashtbl.remove : hashtbl('a) -> string -> unitNotes:
- The key type is always string.
- Hashtbl.get returns an option-like variant (\None/
Some). - Current representation is optimized for simplicity rather than asymptotic performance.
13.2.8 Json module (updated)
Section titled “13.2.8 Json module (updated)”The Json module provides:
- a real recursive JSON value type at the ChatML level (
jsonin the builtin type-alias surface), and - conversion to/from JSON text via the host-side Jsonaf library.
json representation
Section titled “json representation”The builtin alias json is represented as a recursive variant type
equivalent to:
json = [ `Null | `Bool(bool) | `Number(float) | `String(string) | `Array(json array) | `Object({ key : string; value : json } array) ](Internally this is introduced using an explicit recursive binder in the builtin type schemes; users do not write the binder directly.)Exports:
Text conversion:
Section titled “Text conversion:”-
Json.parse : string -> json Parses JSON text into a
jsonvalue. Raises a runtime failure on invalid JSON input. -
Json.parse_opt : string -> [ \None |
Some(json) ] Like parse, but returnsNone` instead of raising on parse errors. -
Json.stringify : json -> string Produces a compact JSON string representation.
-
Json.pretty : json -> string Produces a human-readable formatted JSON representation.
-
Json.validate : string -> bool Returns true iff the string parses as JSON. Introspection and shape-safe accessors:
-
Json.tag : json -> string Returns one of “Null”, “Bool”, “Number”, “String”, “Array”, “Object”.
-
Json.as_bool : json -> [ \None |
Some(bool) ] -
Json.as_number : json -> [ \None |
Some(float) ] -
Json.as_string : json -> [ \None |
Some(string) ] -
Json.as_array : json -> [ \None |
Some(json array) ] -
Json.as_object : json -> [ \None |
Some({ key : string; value : json } array) ]
Object helpers:
- Json.object_keys : json -> string array Returns an array of keys when given an object; returns an empty array on non-object values.
- Json.get_field : json -> string -> [ \None |
Some(json) ] If the first argument is an object and the key exists, returnsSome(value); otherwiseNone.
Path lookup:
-
Json.get_path : json -> string array -> [ \None |
Some(json) ]Traverses the JSON value using a path of string segments: when the current value is an object, segments are treated as field names; when the current value is an array, segments are interpreted as integer indices (in decimal). Returns \None` if traversal fails at any point. Pure object update helpers: -
Json.set_field : json -> string -> json -> json Returns an updated object with the given field set to the new value. Raises a runtime failure if the first argument is not an object.
-
Json.remove_field : json -> string -> json Returns an updated object with all entries for the given key removed. Raises a runtime failure if the first argument is not an object.
Notes:
- Json.parse/Json.stringify/Json.pretty are backed by Jsonaf.
- JSON numbers are surfaced as float in ChatML. Parsing converts Jsonaf’s numeric token text to float; stringifying renders the float back to JSON numeric text.
- Option results use the standard variant encoding:
None andSome(x).
13.3 Interaction with open and shadowing
Section titled “13.3 Interaction with open and shadowing”open imports module exports into the current scope and rejects any import that would shadow an existing binding.
Because global builtins exist in the initial environment, opening some builtin modules may be rejected due to name collisions. For example:
open Arrayis rejected by default because Array.length would shadow the global length.
Users can always access module exports through qualified access (Array.length(xs)) without using open.
13.4 Moderator capability modules
Section titled “13.4 Moderator capability modules”The following effectful modules are available in moderator_surface or, for
UI-only features, ui_moderator_surface.
Their exported functions return task values and are interpreted by the host runtime operation registry.
13.4.1 Log
Section titled “13.4.1 Log”Log.debug : string -> unit taskLog.info : string -> unit taskLog.warn : string -> unit taskLog.error : string -> unit taskThese are diagnostic operations observed by the host runtime.
13.4.2 Turn
Section titled “13.4.2 Turn”Turn.prepend_system : string -> unit taskTurn.append_item : item -> unit taskTurn.replace_item : string -> item -> unit taskTurn.delete_item : string -> unit taskTurn.replace_or_append : [ `None | `Some(string) ] -> item -> unit taskTurn.append_notice : string -> unit taskTurn.halt : string -> unit taskThese describe local turn-overlay style mutations. The current runtime records them as local transactional effects and leaves the concrete overlay semantics to host handlers.
The legacy append_message, replace_message, and delete_message builtin
names remain available as aliases.
13.4.3 Tool
Section titled “13.4.3 Tool”Tool.approve : unit -> unit taskTool.reject : string -> unit taskTool.rewrite_args : json -> unit taskTool.redirect : string -> json -> unit taskTool.call : string -> json -> [ `Ok(json) | `Error(string) ] taskTool.spawn : string -> json -> string taskTool.call is interpreted as an external synchronous operation.
Tool.spawn is interpreted as an external asynchronous operation.
13.4.4 Model
Section titled “13.4.4 Model”Model.call : string -> json -> [ `Ok(json) | `Refused(string) | `Error(string) ] taskModel.spawn : string -> json -> string taskModel.spawn starts a host-managed background job and returns a stable job id.
When that job completes (success or failure), the host reinjects a moderator internal
event so scripts can react. The v1 completion event tags are:
Model_job_succeeded(job_id, recipe_name, result_json)Model_job_failed(job_id, recipe_name, message)
The initial implementation may track spawned jobs in memory only; in that case, in-flight jobs are not durably persisted across process restarts.
The string argument is a host-defined recipe name, not an unrestricted raw provider/model identifier.
13.4.5 Process
Section titled “13.4.5 Process”Process.run : string -> string array -> string taskProcess.run is a host-managed external operation. Hosts may reject or omit
this capability entirely.
13.4.6 Schedule
Section titled “13.4.6 Schedule”Schedule.after_ms : int -> 'e -> string taskSchedule.cancel : string -> unit taskThe event payload of Schedule.after_ms remains a raw ChatML value.
13.4.7 Runtime
Section titled “13.4.7 Runtime”Runtime.emit : 'e -> unit taskRuntime.request_compaction : unit -> unit taskRuntime.request_turn : unit -> unit taskRuntime.end_session : string -> unit taskRuntime.emit buffers a raw ChatML event for later enqueueing on
successful task completion.
Runtime.request_turn() requests that the host run one more ordinary model turn
after the current turn completes. In v1 it is only valid in phases:
turn_endinternal_event
The host interprets this request after turn_end handling finishes; it does not
directly invoke a side model call.
Multiple request_turn effects emitted while handling one host event collapse to a
single continuation decision. Runtime.end_session(...) overrides request_turn.
13.4.8 Ui
Section titled “13.4.8 Ui”Ui is available only on ui_moderator_surface.
Ui.notify : string -> unit taskUi.notify emits a host-local notice. It does not mutate canonical history
and does not append transcript items automatically.
13.4.9 Approval
Section titled “13.4.9 Approval”Approval is available only on ui_moderator_surface.
Approval.ask_text : string -> string taskApproval.ask_choice : string -> string array -> string taskThese operations suspend the current live script execution and later resume that same execution with a validated response supplied by the host. They do not append fake canonical user items automatically.
14. Surface syntax summary
Section titled “14. Surface syntax summary”This is not a full formal grammar, but it summarizes the implemented surface syntax.
14.1 Statements
Section titled “14.1 Statements”type t = type_exprlet x = exprlet x : type_expr = exprlet f x y = exprlet f () = exprlet rec f : type_expr = exprlet rec f x = expr and g y = exprmodule M = struct stmts endopen Mexpr14.2 Expressions
Section titled “14.2 Expressions”()11.0true"x"xfun x -> exprfun () -> exprf(x)if c then t else ewhile c do body donelet x = e1 in e2let x : t = e1 in e2let rec f x = e1 in e2let* x = e1 in e2let+ x = e1 in e2match e with | pat -> e{ a = e; b = e }e.field{ e with field = e }[e1, e2, e3]arr[i]arr[i] <- vref(e)!rr := ve1; e2`Tag`Tag(e1, e2)14.3 Operators
Section titled “14.3 Operators”x + yx - yx * yx / y-x
x +. yx -. yx *. yx /. y-.x
x ++ y
x < yx > yx <= yx >= y
x <. yx >. yx <=. yx >=. y
x == yx != y14.4 Patterns
Section titled “14.4 Patterns”_x()11.0true"x"`Tag`Tag(p1, p2){ field = pat }{ field = pat; _ }14.5 Type expressions
Section titled “14.5 Type expressions”intfloatboolstringunitexprexpr -> intunit -> stringstate tasktask arraystate task array{ name : string; status : status }[ `Pending | `Done | `Error(string) ]15. Diagnostics
Section titled “15. Diagnostics”15.1 Type errors
Section titled “15.1 Type errors”Type errors are reported with:
- a message
- an optional source span
When a span is available, formatting uses source-text excerpts with caret markers.
15.2 Runtime errors
Section titled “15.2 Runtime errors”Runtime errors are also structured:
- message
- optional source span
and are formatted in the same general style as type errors.
15.3 Current strengths
Section titled “15.3 Current strengths”Diagnostics are now materially better for:
- row-typed records
- row-typed variants
- equality misuse
openshadowing
15.4 Current parser limitation
Section titled “15.4 Current parser limitation”Parse errors are still comparatively basic. Menhir failure reporting is not yet elevated to the same level of quality as type/runtime diagnostics.
16. Soundness-related notes
Section titled “16. Soundness-related notes”The current implementation intentionally enforces or relies on:
- lexical closure capture being stable
- ordinary inference variables being acyclic
- recursive types being explicit and checked
- recursive type declarations being contractive
- recursive types remaining monomorphic
- recursive bindings being function-only
- mutation interacting with polymorphism via a value restriction
- explicit separation of integer and float operators
- int-only array indexing
- explicit resolver lowering before evaluation
- runtime validation of frame slot layouts
- no silent
openshadowing - equality restrictions for unsupported runtime representations
These are central to the language’s current safety/ergonomics tradeoff.
Recursive builtin types and unification ChatML supports explicit recursive types internally (Mu / Rec_var) for both user-declared recursive types and some builtin module types (notably Json.t).
Implementation note:
Unification of recursive types uses an alpha-renaming strategy for Mu-vs-Mu unification to avoid non-termination from repeated unfolding. (This is an internal typechecker detail; surface programs observe only the usual contractiveness and monomorphism rules for recursive types.)
17. Known intentional limitations
Section titled “17. Known intentional limitations”ChatML currently does not provide:
- tuple syntax as a general user-facing feature
- type parameters
- mutual recursive type declarations
- module-local type declarations
- general expression ascription syntax
- tuple type syntax as a general user-facing feature
- ref type syntax
- open-row type syntax
- full user-facing algebraic datatype declarations beyond alias-style
structural
typedeclarations - selective imports
- signatures or functors
- layout-sensitive syntax
- Unicode identifiers
- full ML-style match usefulness analysis
- ad-hoc overloaded numeric operators
The language remains intentionally conservative and small.
18. Important implementation caveats
Section titled “18. Important implementation caveats”These are worth documenting because they shape current behavior.
18.1 Modules are statically record-like, but runtime-distinct
Section titled “18.1 Modules are statically record-like, but runtime-distinct”The typechecker models modules as records of exports, while the runtime
represents them as VModule.
This is deliberate and ergonomic, but it means “module values” are not a fully separate static category.
18.2 Records get stronger row ergonomics than variants
Section titled “18.2 Records get stronger row ergonomics than variants”Record-heavy scripting is a primary use case, so lambda parameters that become record-shaped are reopened to open rows. Variants do not get the same reopening heuristic.
18.3 Builtins remain a host-side facility
Section titled “18.3 Builtins remain a host-side facility”The richer builtin type language exists for host/runtime authors, not as a complete user-facing type-annotation mechanism.
19. Recommended style
Section titled “19. Recommended style”For the current language, the most ergonomic and robust style is:
- use records for script state
- declare explicit recursive record/variant types when a script’s data model is genuinely recursive
- annotate recursive helper functions against those declared types
- write small helpers over row-polymorphic state records
- use variants for finite event/state tags
- use modules only for grouping
- keep arithmetic explicit by numeric kind
- prefer
M.nameover aggressiveopenuse when readability matters - push complex host interaction into builtins/runtime services
20. Reference examples
Section titled “20. Reference examples”20.1 Record-heavy state helper
Section titled “20.1 Record-heavy state helper”let bump_attempts st = let t = st.tasks[st.task_index] in let t = { t with attempts = t.attempts + 1 } in st.tasks[st.task_index] <- t; st20.2 Variant-driven event handler
Section titled “20.2 Variant-driven event handler”let step st ev = match ev with | `Start -> { st with running = true } | `Stop -> { st with running = false }20.3 Float logic with explicit dotted operators
Section titled “20.3 Float logic with explicit dotted operators”let avg x y = (x +. y) /. 2.0if avg(1.0, 3.0) >=. 2.0 then true else false20.4 Simple module namespace
Section titled “20.4 Simple module namespace”module Flow = struct let one = 1 let inc x = x + 1end
Flow.inc(Flow.one)20.5 Shadow-safe module import
Section titled “20.5 Shadow-safe module import”module Math = struct let two = 2end
open Mathprint(two)But:
let two = 99open Mathis rejected because open Math would shadow two.
20.6 Explicit recursive type declaration
Section titled “20.6 Explicit recursive type declaration”type expr = [ `Int(int) | `Add(expr, expr) ]
let rec eval : expr -> int = fun e -> match e with | `Int(n) -> n | `Add(a, b) -> eval(a) + eval(b)This is the supported way to write recursive structural data in ChatML.
20.7 Tiny workflow engine
Section titled “20.7 Tiny workflow engine”(* A tiny workflow engine that processes events and mutates tasks in-place. *)
type status = [ `Pending | `Running | `Done | `Error(string) ]type task ={ name : string; attempts : int; status : status}type event = [ `Start | `Tick | `Fail(string) | `Stop ]type state ={ tasks : task array; idx : int; running : bool}
(* Witness: forces the status variant row to include all tags we will use. *)let status_witness : int -> status =fun n -> match n with | 0 -> `Pending | 1 -> `Running | 2 -> `Done | _ -> `Error("")
let mk_task : string -> task =fun name -> { name = name; attempts = 0; status = status_witness(0) }
let show_task : task -> string =fun t -> t.name ++ " attempts=" ++ to_string(t.attempts) ++ " status=" ++ variant_tag(t.status)
let set_status : state -> status -> state =fun st new_status -> let i = st.idx in let t : task = st.tasks[i] in st.tasks[i] <- { t with status = new_status }; st
let set_running : state -> bool -> state =fun st running -> { st with running = running }
let bump_attempts : state -> state =fun st -> let i = st.idx in let t : task = st.tasks[i] in st.tasks[i] <- { t with attempts = t.attempts + 1 }; st
let step : state -> event -> state =fun st ev -> match ev with | `Start -> if st.idx >= length(st.tasks) then set_running(st, false) else set_status(set_running(st, true), status_witness(1))
| `Tick -> if st.running == false then set_running(st, false) else let st = set_running(st, true) in let st = bump_attempts(st) in let t : task = st.tasks[st.idx] in if t.attempts >= 3 then let st = set_status(st, `Done) in let st = { st with idx = st.idx + 1 } in if st.idx < length(st.tasks) then set_status(set_running(st, true), status_witness(1)) else st else st
| `Fail(msg) -> let st = set_status(st, `Error(msg)) in set_running(st, false)
| `Stop -> set_running(st, false)
let run : event array -> unit =fun events ->let tasks = [ mk_task("fetch"), mk_task("transform"), mk_task("upload") ] inlet st0 = { tasks = tasks; idx = 0; running = false } in
let i = ref(0) inlet st_ref = ref(st0) in
while !i < length(events) do let ev = events[!i] in
st_ref := step(!st_ref, ev);
let st = !st_ref in print( "ev=" ++ variant_tag(ev) ++ " idx=" ++ to_string(st.idx) ++ " task=" ++ (if st.idx < length(st.tasks) then show_task(st.tasks[st.idx]) else "<none>") );
i := !i + 1done
let events =[ `Start, `Tick, `Tick, `Tick, `Tick, `Tick, `Tick, `Tick, `Fail("network"), `Stop]
run(events)20.8 Small Expression evaluation
Section titled “20.8 Small Expression evaluation”type expr = [ `Int(int) | `Add(expr, expr) | `Sub(expr, expr) | `Mul(expr, expr) | `Div(expr, expr) | `Let(string, expr, expr) | `Var(string) ]
let rec eval : expr -> int = fun e -> match e with | `Int(n) -> n | `Add(a, b) -> eval(a) + eval(b) | `Sub(a, b) -> eval(a) - eval(b) | `Mul(a, b) -> eval(a) * eval(b) | `Div(a, b) -> let x = eval(a) in let y = eval(b) in if y == 0 then fail("division by zero in AST") else x / y | `Let(name, rhs, body) -> (* An environment-less "let" by substitution for demo purposes: Let only supports binding `x` here. *) if name != "x" then fail("only name \"x\" is supported in this demo") else let v = eval(rhs) in eval(subst_x(body, v)) | `Var(name) -> fail("free variable in AST: " ++ name)
and subst_x : expr -> int -> expr = fun e v -> match e with | `Int(_) -> e | `Var(name) -> if name == "x" then `Int(v) else e | `Add(a, b) -> `Add(subst_x(a, v), subst_x(b, v)) | `Sub(a, b) -> `Sub(subst_x(a, v), subst_x(b, v)) | `Mul(a, b) -> `Mul(subst_x(a, v), subst_x(b, v)) | `Div(a, b) -> `Div(subst_x(a, v), subst_x(b, v)) | `Let(name, rhs, body) -> if name == "x" then (* shadowing: don't substitute into body *) `Let(name, subst_x(rhs, v), body) else `Let(name, subst_x(rhs, v), subst_x(body, v))
let program : expr = `Let("x", `Add(`Int(10), `Int(5)), `Div(`Mul(`Var("x"), `Int(2)), `Sub(`Int(9), `Int(7))) )
print("result=" ++ to_string(eval(program)))20.9 BFS program
Section titled “20.9 BFS program”let and_ a b = match `Tup(a, b) with | `Tup(true, true) -> true | _ -> false
module Graph = struct (* adjacency matrix: g[u][v] = 1 if edge *) let neighbors g u = g[u]
let bfs_distance g start goal = let n = length(g) in
(* distances initialized to -1 (unvisited) *) let dist = [-1, -1, -1, -1, -1, -1] in dist[start] <- 0;
(* simple fixed-size queue of nodes *) let q = [0, 0, 0, 0, 0, 0] in let head = ref(0) in let tail = ref(0) in
q[0] <- start; tail := 1;
while !head < !tail do let u = q[!head] in head := !head + 1;
let du = dist[u] in let row = neighbors(g, u) in
let v = ref(0) in while !v < n do if (and_(row[!v] == 1, dist[!v] == -1)) then dist[!v] <- du + 1; q[!tail] <- !v; tail := !tail + 1 else (); v := !v + 1 done done;
dist[goal]end
let g = [ [0,1,1,0,0,0] , [1,0,0,1,0,0] , [1,0,0,1,1,0] , [0,1,1,0,0,1] , [0,0,1,0,0,1] , [0,0,0,1,1,0] ]
print("dist 0->5 = " ++ to_string(Graph.bfs_distance(g, 0, 5)))20.10 JSON parse / transform / stringify (builtin Json module)
Section titled “20.10 JSON parse / transform / stringify (builtin Json module)”let rec map_numbers : json -> json = fun j -> match j with | `Null -> `Null | `Bool(b) -> `Bool(b) | `String(s) -> `String(s)
| `Number(n) -> (* Example transform: add 1.0 to every number *) `Number(n +. 1.0)
| `Array(xs) -> let ys = array_copy(xs) in let i = ref(0) in while !i < length(ys) do ys[!i] <- map_numbers(ys[!i]); i := !i + 1 done; `Array(ys)
| `Object(entries) -> let out = array_copy(entries) in let i = ref(0) in while !i < length(out) do let e = out[!i] in (* e : { key : string; value : Json.t } *) out[!i] <- { key = e.key; value = map_numbers(e.value) }; i := !i + 1 done; `Object(out)
let input = "{\"a\":1,\"b\":[2,3],\"c\":{\"d\":4}}"let j = Json.parse(input)let j2 = map_numbers(j)
print("in: " ++ Json.stringify(j))print("out: " ++ Json.stringify(j2))print("pretty:\n" ++ Json.pretty(j2))21. Task values and moderator-runtime embedding
Section titled “21. Task values and moderator-runtime embedding”This section documents the current implementation of ChatML task values and the host-side moderator runtime.
21.1 Task values in the core runtime
Section titled “21.1 Task values in the core runtime”Tasks are first-class runtime values. The core value space therefore includes:
VTask of taskwith the internal task representation:
type task = | TPure of value | TBind of task * value | TMap of task * value | TFail of string | TCatch of task * value | TPerform of eff | TSpawn of effThe ChatML evaluator does not interpret these task nodes directly. It constructs and propagates them as values. Interpretation happens only in host code, such as the moderator runtime.
21.2 Surface type convention
Section titled “21.2 Surface type convention”ChatML follows the existing postfix type-constructor style.
The type constructor is named:
taskin the type namespaceTaskin the module/value namespace
Examples:
state taskmessage arraystate task arrayThe builtin helper functions therefore have shapes such as:
Task.pure : 'a -> 'a taskTask.bind : 'a task -> ('a -> 'b task) -> 'b task21.3 Task module and surface syntax
Section titled “21.3 Task module and surface syntax”The builtin Task module currently exports:
Task.pure : 'a -> 'a taskTask.bind : 'a task -> ('a -> 'b task) -> 'b taskTask.map : 'a task -> ('a -> 'b) -> 'b taskTask.fail : string -> 'a taskTask.catch : 'a task -> (string -> 'a task) -> 'a taskChatML also supports monadic let sugar for tasks:
let* x = t1 in t2let+ x = t1 in t2desugaring to Task.bind and Task.map respectively.
21.4 Host-side function application
Section titled “21.4 Host-side function application”Because TBind, TMap, and TCatch store ChatML closures/builtins inside
task values, host runtimes need a way to apply those values.
The evaluator therefore exposes a public host-side API:
apply_value_result : value -> value list -> (value, runtime_error) resultapply_value_exn : value -> value list -> valueThese are used by the moderator runtime and may also be used by other host embeddings.
21.5 Moderator script contract
Section titled “21.5 Moderator script contract”The integrated moderator runtime uses a convention-based contract. This
self-contained example counts appended items and rejects every tool call.
The complete source is available as
moderator.chatml:
type state = { appended_count : int }type event = [ `Session_start | `Session_resume | `Turn_start | `Item_appended(item) | `Pre_tool_call(tool_call) | `Post_tool_response(tool_result) | `Turn_end ]
let initial_state = { appended_count = 0 }
let on_event : context -> state -> event -> state task = fun ctx st ev -> match ev with | `Item_appended(item) -> Task.pure({ st with appended_count = st.appended_count + 1 }) | `Pre_tool_call(call) -> let* () = Tool.reject("Tools are disabled by this moderator.") in Task.pure(st) | _ -> Task.pure(st)Item_appended is not limited to user messages. The standard host events are
Session_start, Session_resume, Turn_start, Item_appended(item),
Pre_tool_call(call), Post_tool_response(result), and Turn_end. Internal
events are host-defined variants, such as
Model_job_succeeded(job_id, recipe, result), not a universal
AsyncCompleted event. See the moderator event model.
Conceptually:
initial_state : stateon_event : context -> state -> event -> state taskAt present, the runtime validates entrypoints dynamically by name and callability when instantiating a session. It does not yet enforce the full entrypoint type contract statically.
21.6 Structural runtime aliases exposed to moderator scripts
Section titled “21.6 Structural runtime aliases exposed to moderator scripts”moderator_surface installs the following builtin type aliases:
type item = { id : string ; value : json }
type tool_desc = { name : string ; description : string ; input_schema : json }
type tool_call = { id : string ; name : string ; args : json }
type tool_result = { call_id : string ; name : string ; result : json }
type context = { session_id : string ; now_ms : int ; phase : string ; items : item array ; available_tools : tool_desc array ; session_meta : json }These aliases are compile-time conveniences layered on top of ordinary structural record/variant typing.
The moderator surface also installs an Item builtin module:
Item.create : string -> json -> itemItem.id : item -> stringItem.value : item -> jsonItem.kind : item -> string optionItem.role : item -> string optionItem.text_parts : item -> string arrayItem.input_text_message : string -> string -> string -> itemItem.output_text_message : string -> string -> itemItem.kind inspects the serialized OpenAI response item "type" field when
present. Item.role and Item.text_parts provide convenient access to common
message-like item shapes without forcing scripts to hand-author raw JSON.
The Turn module exposes item-oriented mutation helpers:
Turn.prepend_system : string -> unit taskTurn.append_item : item -> unit taskTurn.replace_item : string -> item -> unit taskTurn.delete_item : string -> unit taskTurn.halt : string -> unit taskLegacy Turn.append_message, Turn.replace_message, and
Turn.delete_message names remain available as aliases.
21.7 Integrated host lifecycle
Section titled “21.7 Integrated host lifecycle”When a ChatMD prompt declares:
<script language="chatml" kind="moderator" id="main">...</script>or:
<script language="chatml" kind="moderator" id="main" src="moderator.chatml" />the host treats that script as the prompt’s single v1 moderation program.
Integration rules:
- zero scripts means no moderation and preserves baseline driver behavior,
- one script enables the shared moderation manager,
- more than one script is a prompt validation error,
- the script is parsed and validated during ChatMD prompt loading,
- the script is compiled once per prompt load and instantiated per session,
- the script remains host-managed and is not converted into model-visible request history.
The shared moderation vocabulary defines these phase names:
session_startsession_resumeturn_startmessage_appendedpre_tool_callpost_tool_responseturn_endinternal_event
The script-visible event constructors are:
`Session_start`Session_resume`Turn_start`Item_appended(item)`Pre_tool_call(tool_call)`Post_tool_response(tool_result)`Turn_end- and user-defined internal variants delivered through the internal-event path
Current built-in host paths emit:
session_startfor fresh moderated sessions,session_resumewhen restoring a persisted moderator snapshot,turn_startbefore each model request,message_appendedafter a canonical transcript item is appended by the streamed runtime,pre_tool_callbefore tool execution,post_tool_responseafter a tool output item is produced,turn_endafter a streamed assistant turn finishes,internal_eventwhile replaying queued emitted events.
21.8 Effect requests
Section titled “21.8 Effect requests”Task effects are represented internally as:
type eff = { op : string ; args : value list }with two execution modes:
TPerform efffor host-performed operationsTSpawn efffor host-spawned asynchronous operations
The concrete set of supported operation names is host-defined.
21.9 Host-side moderator runtime
Section titled “21.9 Host-side moderator runtime”The current host runtime lives in chatml_moderator_runtime.ml. It
supports:
- compile once:
- parse
- typecheck against a selected surface
- resolve
- instantiate per session:
- fresh environment
- install chosen builtin surface
- evaluate the program
- load
initial_stateandon_event
- handle events:
- call
on_event - require a returned task
- interpret that task
- commit or discard local transactional outputs
- call
The public entrypoints are:
compile_scriptinstantiate_sessionhandle_eventpending_ui_requestresume_ui_requestThe repository’s shared moderation integration layers this runtime under
Chat_response.Moderation, Chat_response.Moderator_manager, and the shared
drivers used by chat_tui, file-backed prompt execution, nested run_agent
calls, and MCP prompt-agent wrappers.
21.10 Operation registry and defaults
Section titled “21.10 Operation registry and defaults”The moderator runtime interprets task effects through an operation registry:
type op_kind = | Local_transactional | External_sync | External_async | DiagnosticEach operation definition provides:
- an operation name,
- its runtime kind,
- a phase check,
- and a host callback.
The runtime also ships with a standard default registry constructor:
default_operationsdefault_runtime_configThis default registry understands the standard moderator modules
(Log/Turn/Tool/Model/Process/Schedule/Runtime) and, when the
UI surface is installed, the Ui and Approval operations.
Current default behavior is intentionally conservative:
- diagnostic/log and local turn/tool-moderation handlers default to no-op success,
Runtime.emitbuffers an internal event,Runtime.end_sessionmarks the session as ended on successful commit,- external integrations such as
Tool.call,Model.call, andSchedule.after_msfail with a clear"… is not configured"error unless the host supplies handlers. Runtime.request_turnis a local transactional operation and is phase-restricted in v1 (see above). It is surfaced to the host as a runtime request and interpreted by the host conversation loop afterturn_end.
Named model recipes are host-defined. Model.call("recipe", payload) and
Model.spawn("recipe", payload) do not select arbitrary provider model names;
they route through the host capability registry under a recipe name chosen by
the embedder.
The current embedding registers at least one recipe:
agent_prompt_v1
Recipe names are not provider model identifiers; they route to host-defined behavior.
21.11 Commit/rollback semantics
Section titled “21.11 Commit/rollback semantics”The moderator runtime currently buffers:
- the next script state,
- local transactional effect records,
- internal emitted events,
- pending end-session requests.
On successful completion of the returned task:
- the new script state is persisted,
- local transactional effects are committed in execution order,
- buffered emitted events are appended to the session queue,
- a pending end-session request halts the session.
If execution suspends on Approval.ask_text or Approval.ask_choice:
- the current handler does not commit,
- buffered local transactional effects remain uncommitted,
- buffered emitted events remain uncommitted,
- the session continues to expose the last committed state,
- a host-visible
pending_ui_requestbecomes available, - and ordinary
handle_eventprogression remains blocked until the host callsresume_ui_request.
On task failure:
- the previous script state is kept,
- buffered local transactional effects are discarded,
- buffered emitted events are discarded,
- buffered end-session requests are discarded.
TCatch restores the local transactional buffers before running the handler
task.
This rollback rule is what lets the host keep canonical history and persisted moderator state unchanged after a failed moderation task.
21.12 Overlay and tool moderation semantics
Section titled “21.12 Overlay and tool moderation semantics”The shipped host integration interprets committed local effects through a shared moderation layer rather than mutating canonical history directly.
Turn.* effects update a host-owned overlay that can:
- prepend synthetic developer messages,
- append synthetic messages,
- replace projected messages by stable host id,
- delete projected messages by stable host id,
- halt the session with a reason.
The host computes the effective moderated request history by applying that overlay to projected canonical history before the next model turn.
Tool moderation effects are interpreted as:
Tool.approve()– keep the tool call unchanged,Tool.reject(reason)– synthesize a denied tool output item and skip tool execution,Tool.rewrite_args(json)– keep the tool name but replace the payload,Tool.redirect(name, json)– replace both tool name and payload.
If one moderation event emits multiple conflicting tool moderation actions, the host treats that as an error instead of guessing precedence.
21.13 Internal event replay
Section titled “21.13 Internal event replay”Runtime.emit(event) buffers a raw ChatML value. After any successful host
event handling, the moderation manager drains queued events FIFO and re-feeds
them through phase internal_event.
Current behavior:
- replay happens only after successful task completion,
- replay stops once the queue is empty,
- a safety limit prevents unbounded loops,
- end-session requests stop further replay for that host event.
21.14 Persistence boundary
Section titled “21.14 Persistence boundary”The integrated host persists moderator state explicitly in Session.t.
The persisted snapshot contains:
script_idscript_source_hash- serializable durable script state
- serializable queued internal events
- halted flag
- host overlay snapshot
Pending UI approval state is intentionally not part of this snapshot. A partially suspended approval is live-session-only and cannot be restored from persisted moderator state.
Only data-shaped ChatML values cross this boundary. The snapshot codec rejects closures, refs, builtins, modules, and tasks.
When a session is exported to ChatMD, canonical history stays canonical while overlay-derived synthetic entries are materialized explicitly in the exported transcript so resume/export semantics stay auditable.
21.15 Phase restrictions
Section titled “21.15 Phase restrictions”The operation model includes runtime phase checks:
phase_check : string -> (unit, string) resultThe current default registry provides helper constructors such as:
allow_all_phasesrequire_phasesbut the default assembled operation set currently uses permissive
allow_all_phases checks unless the host overrides them.
21.16 No-script fallback
Section titled “21.16 No-script fallback”All public moderation integration is optional. If a ChatMD prompt declares no
moderator <script>, the shared drivers, chat_tui, export flow, and nested
agent execution all use the baseline non-moderated behavior.
21.17 Runtime requests and host continuation
Section titled “21.17 Runtime requests and host continuation”Moderator scripts may emit host-visible runtime requests:
Runtime.request_turn()→ request one more ordinary turn afterturn_endRuntime.request_compaction()→ request compaction (embedding-defined)Runtime.end_session(reason)→ request session termination
Host behavior is embedding-defined, but the shared in-memory conversation loop applies the following precedence after a streamed turn ends:
end_sessionterminates immediately and overrides continuation.- Tool-driven follow-up (pending tool calls) continues.
request_turncontinues.- Otherwise the loop stops.
Multiple request_turn requests emitted while handling one host event collapse to a
single continuation decision. request_compaction never forces another turn by itself.
22. Ochat shell-runtime embedding
Section titled “22. Ochat shell-runtime embedding”In an ochat moderator host, Process.run is not a generic spawn primitive. It
is available only when <moderator_runtime shell_runtime="..."/> binds the
moderator to a compiled ChatMD shell runtime. Calls use structured argv and the
same resolution, effects, capability, policy, approval, sandbox, output, and
audit path as shell tools.
Ochat also embeds ChatML through purpose-built shell surfaces for matchers, reviewers, before/after interceptors, effect analyzers, and audit filters. Each surface imports versioned typed values and only the actions required by that kind. It does not inherit moderator capability modules. Stateful instances use transactional commit/rollback, Eio serialization, resource budgets, and source/manifest-bound snapshots.
View source · Moderator contract2 files
Complete source files. Open a filename to read it here.
moderator.chatmlStart here
type state = { appended_count : int }
type event =
[ `Session_start
| `Session_resume
| `Turn_start
| `Item_appended(item)
| `Pre_tool_call(tool_call)
| `Post_tool_response(tool_result)
| `Turn_end
]
let initial_state = { appended_count = 0 }
let on_event : context -> state -> event -> state task =
fun ctx st ev ->
match ev with
| `Item_appended(item) ->
Task.pure({ st with appended_count = st.appended_count + 1 })
| `Pre_tool_call(call) ->
let* () = Tool.reject("Tools are disabled by this moderator.") in
Task.pure(st)
| _ ->
Task.pure(st)
LICENSE.txtnotice
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.