Skip to content
ochat
Search documentation

Use quotes for an exact phrase.

Search by topic, command, or code identifier.

    GitHub ↗

    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.mll
    • lib/chatml/chatml_parser.mly
    • lib/chatml/chatml_lang.ml
    • lib/chatml/chatml_parse.ml
    • lib/chatml/chatml_eval.ml
    • lib/chatml/frame_env.ml
    • lib/chatml/chatml_slot_layout.ml
    • lib/chatml/chatml_typechecker.ml
    • lib/chatml/chatml_resolver.ml
    • lib/chatml/chatml_builtin_spec.ml
    • lib/chatml/chatml_builtin_modules.ml
    • lib/chatml/chatml_builtin_surface.ml
    • lib/chatml/chatml_value_codec.ml
    • lib/chatml/chatml_moderator_runtime.ml

    When this document and the implementation disagree, the implementation is authoritative.


    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:

    1. sound static typing
    2. good ergonomics with inference-first typing and a small explicit type surface
    3. small surface area
    4. predictable operational behavior
    5. 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.


    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 type declarations,
    • binding annotations on let, let rec, and let ... 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.


    This section describes the actual pipeline used by the implementation.

    The implementation is split into four conceptual phases:

    1. Lexing/parsing
    2. Type checking
    3. Resolution/lowering
    4. Evaluation

    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:

    • EVar
    • ELambda
    • ELetIn
    • ELetRec
    • EMatch

    Resolved expressions include:

    • REVarGlobal
    • REVarLoc
    • RELambda
    • RELetBlock
    • RELetRec
    • REMatch

    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

    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.

    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.

    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

    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.

    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.


    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.

    Supported literal forms:

    • integers: 0, 1, 42
    • floats: 1.0, 3.14
    • booleans: true, false
    • strings: "hello"
    • unit: ()

    Comments use OCaml-style block syntax:

    (* this is a comment *)

    Nested comments are supported by the lexer.

    Strings support at least:

    • \n
    • \t
    • \\
    • \"

    Strings may span multiple lines.

    Whitespace is not significant except as a token separator.

    ChatML is not indentation-sensitive.


    A ChatML program is a sequence of top-level statements.

    Top-level statement forms:

    • type t = type_expr
    • let x = expr
    • let x : type_expr = expr
    • let f a b = expr
    • let f () = expr
    • let rec f : type_expr = expr and g : type_expr = expr
    • let rec f x = expr and g y = expr
    • module M = struct ... end
    • open 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.


    Examples:

    let x = 1
    let name = "Alice"
    let inc n = n + 1
    let thunk () = 42

    Properties:

    • 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

    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

    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
    • open does 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

    Example:

    module Flow = struct
    let x = 1
    let id y = y
    end

    Modules are intentionally simple namespaces.

    Properties:

    • module bodies may reference outer bindings
    • only names explicitly defined in the module body are exported
    • names imported via open inside a module are not re-exported
    • modules are represented as records by the typechecker and as VModule values at runtime

    Type declarations are not statements inside module bodies in the current surface grammar.

    Example:

    open Flow

    Semantics:

    • 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 = 1
    module M = struct
    let x = 2
    end
    open M

    Both the typechecker and the runtime reject such shadowing.


    ()

    Type: unit

    x
    state
    Flow

    Variables are lexically scoped.

    After resolution:

    • local variables become lexical-address lookups into frames
    • globals/modules/builtins remain environment lookups

    Anonymous functions:

    fun x -> x
    fun x y -> x
    fun () -> 42

    Named function syntax is sugar for a let binding of a lambda:

    let add x y = x + y

    Properties:

    • 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"

    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.

    Examples:

    let x = 1 in x + 1
    let x : int = 1 in x + 1
    let f y = y in f(3)
    let rec loop n = ... in loop(10)
    let* x = task1 in task2
    let+ x = task1 in value_expr

    Properties:

    • non-recursive lets are lexical and sequential
    • nested non-recursive lets are internally grouped into RELetBlock layouts by the resolver
    • let rec inside expressions follows the same recursive-function restriction as top-level let 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* and let+ are task-composition forms that desugar to Task.bind and Task.map
    if cond then a else b

    Rules:

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

    e1; e2

    Rules:

    • e1 is evaluated fully first
    • its value is discarded
    • the result is the value of e2
    while cond do body done

    Rules:

    • condition must have type bool
    • loop result type is unit
    • loop body may have side effects

    Record literal:

    { name = "Alice"; age = 30 }

    Field access:

    person.name

    Record 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

    Array literal:

    [1, 2, 3]

    Indexing:

    arr[i]

    Update:

    arr[i] <- v

    Properties:

    • arrays are homogeneous
    • arrays are mutable
    • index type must be int
    • out-of-bounds access is a runtime error
    • update returns unit

    Creation:

    ref(0)

    Dereference:

    !r

    Assignment:

    r := 1

    Properties:

    • refs are mutable cells
    • dereference requires a ref
    • assignment requires a ref value and a value of the stored type
    • assignment returns unit

    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

    Operators are built into the core AST. They are not looked up from the runtime environment and cannot be overridden.

    • binary +
    • binary -
    • binary *
    • binary /
    • unary -

    Operands must be int; result is int.

    Division by zero is a runtime error.

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

    • binary ++

    Operands must be string; result is string.

    • <
    • >
    • <=
    • >=

    Operands must be int; result is bool.

    • <.
    • >.
    • <=.
    • >=.

    Operands must be float; result is bool.

    • ==
    • !=

    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:

    • int
    • float
    • bool
    • string
    • unit
    • 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

    The current parser precedence is roughly:

    1. comparisons and equality
    2. additive operators
    3. multiplicative operators
    4. dereference handling

    Concretely:

    • +, -, ++, +., -. share a precedence level
    • *, /, *., /. share a tighter precedence level
    • comparison/equality are looser than arithmetic

    Use parentheses whenever readability matters.


    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; _ }

    Match arms are tried in source order.

    The first matching arm is selected.

    If no arm matches at runtime, evaluation raises a runtime error.

    Pattern variables are collected in deterministic left-to-right order. This matters for resolver slot layout, but not for user-visible semantics.

    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.

    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

    Variant-using functions can become narrower after informative matches.

    Example:

    let f v =
    match v with
    | `Some(x) -> x

    The parameter type inferred for v may be narrowed to compatible variants, rather than remaining arbitrarily open.

    This is intentional.


    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.

    • unit
    • int
    • float
    • bool
    • string
    • 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.

    The current user-facing type-expression syntax supports:

    • primitive names:
      • int
      • float
      • bool
      • string
      • unit
    • previously declared type names
    • function types:
      • expr -> int
      • state -> event -> state
      • unit -> string
    • postfix unary type constructors:
      • task array
      • state task
      • state 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.status

    Current 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:

    • array
    • task

    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 = bad

    This rule keeps recursive types sound while still supporting the recursive record and recursive variant use-cases ChatML scripts rely on.

    Non-expansive bindings may be generalized.

    Example:

    let id x = x
    id(1)
    id("s")

    Expansive bindings are not generalized.

    This is necessary for soundness with:

    • refs
    • arrays
    • mutable aliasing

    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.

    Record helpers usually infer open-row behavior.

    Example:

    let get_name p = p.name

    This 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

    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

    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 if or match, make sure every branch returns a record that explicitly contains that field.

    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.

    Recursive bindings must be functions.

    This avoids unsound and difficult recursive value-inference cases.

    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.

    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.


    The runtime supports:

    • ints
    • bools
    • floats
    • strings
    • variants
    • records
    • arrays
    • refs
    • closures
    • modules
    • unit
    • builtins
    • tasks

    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.

    There are two main runtime storage mechanisms:

    1. a mutable hash-table environment for globals/modules/builtins
    2. a stack of local frames for resolved lexical locals

    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

    Frames are heterogeneous storage blocks described by packed slot layouts.

    The runtime currently distinguishes slots for:

    • int
    • bool
    • float
    • string
    • generic object slots

    Each frame now stores its layout explicitly, and frame reads/writes validate that the requested slot matches the allocated layout.

    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.

    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.

    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
    • open shadowing collisions

    Ill-typed programs are normally rejected before evaluation in the standard pipeline.


    Modules are intentionally simple namespace containers.

    Modules are for:

    • grouping helper functions
    • reducing naming clutter
    • structuring scripts

    Modules are not for:

    • signatures
    • functors
    • generative module behavior
    • abstraction-heavy namespace engineering

    Only names explicitly defined in the module body are exported.

    Example:

    let x = 1
    module M = struct
    let y = x
    end

    Valid:

    M.y

    Invalid:

    M.x

    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.


    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 VModule values (typed as records of exports),
    • builtin type aliases injected into the initial type environment.

    The current implementation exposes two standard assembled surfaces:

    • core_surface
    • moderator_surface

    Arithmetic, string concatenation, comparison, and equality operators remain language primitives rather than runtime-installed builtins.

    Installed global builtins in core_surface:

    print : 'a -> unit
    to_string : 'a -> string
    length : 'a array -> int
    string_length : string -> int
    string_is_empty : string -> bool
    array_copy : 'a array -> 'a array
    record_keys : { ...r } -> string array
    variant_tag : [ ...r ] -> string
    swap_ref : ref('a) -> 'a -> 'a
    fail : string -> 'a

    Notes:

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

    core_surface currently provides:

    • the global builtins listed above,
    • builtin modules:
      • Task
      • String
      • Array
      • Json
      • Option
      • Hashtbl
    • builtin type aliases:
      • json

    moderator_surface extends core_surface with moderator-oriented modules and structural type aliases.

    Additional builtin modules:

    • Log
    • Item
    • Tool_call
    • Context
    • Turn
    • Tool
    • Model
    • Process
    • Schedule
    • Runtime

    Additional builtin type aliases:

    • item
    • tool_desc
    • tool_call
    • tool_result
    • context

    Each builtin module is a VModule value at runtime and is typed as a record of its exports by the typechecker.

    ui_moderator_surface extends moderator_surface with UI-only capability modules:

    • Ui
    • Approval

    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.

    The Task builtin module provides the core task combinators used by the moderator-runtime embedding:

    Task.pure : 'a -> 'a task
    Task.bind : 'a task -> ('a -> 'b task) -> 'b task
    Task.map : 'a task -> ('a -> 'b) -> 'b task
    Task.fail : string -> 'a task
    Task.catch : 'a task -> (string -> 'a task) -> 'a task

    These functions construct and compose task values. They do not themselves perform host-side effects.

    The String builtin module provides common string utilities.

    Exports:

    • String.length : string -> int
    • String.is_empty : string -> bool
    • String.concat : string -> string -> string

    Notes:

    • String.concat(a, b) is ordinary concatenation. The language also provides the ++ operator.
    • String.equal : string -> string -> bool
    • String.contains : string -> string -> bool
      True if the second string is a substring of the first.
    • String.starts_with : string -> string -> bool
    • String.ends_with : string -> string -> bool
    • String.trim : string -> string
      Removes leading and trailing whitespace.
    • String.to_upper : string -> string
    • String.to_lower : string -> string
    • String.slice : string -> int -> int -> string
      slice(s, start, len) returns the substring of length len starting at start. 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 if pattern is empty.

    The Array builtin module provides array utilities. Arrays are homogeneous and mutable.

    Exports:

    • Array.length : 'a array -> int
    • Array.copy : 'a array -> 'a array
    • Array.get : 'a array -> int -> 'a
    • Array.set : 'a array -> int -> 'a -> unit

    Notes:

    • Array.get and Array.set raise a runtime error on out-of-bounds indices.
    • Array.length overlaps with the global builtin length. Because open rejects shadowing, open Array may be rejected in scopes where length is 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 indices 0..n-1. Raises on negative length.
    • Array.map : 'a array -> ('a -> 'b) -> 'b array
    • Array.mapi : 'a array -> (int -> 'a -> 'b) -> 'b array
    • Array.iter : 'a array -> ('a -> unit) -> unit
    • Array.iteri : 'a array -> (int -> 'a -> unit) -> unit
    • Array.fold : 'a array -> 'b -> ('b -> 'a -> 'b) -> 'b
      Left fold in index order.
    • Array.filter : 'a array -> ('a -> bool) -> 'a array
    • Array.exists : 'a array -> ('a -> bool) -> bool
    • Array.for_all : 'a array -> ('a -> bool) -> bool

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

    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 and Some(…).

    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 -> unit
    Hashtbl.get : hashtbl('a) -> string -> [ \None | `Some('a) ]`
    Hashtbl.mem : hashtbl('a) -> string -> bool
    Hashtbl.remove : hashtbl('a) -> string -> unit

    Notes:

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

    The Json module provides:

    • a real recursive JSON value type at the ChatML level (json in the builtin type-alias surface), and
    • conversion to/from JSON text via the host-side Jsonaf library.

    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:

    • Json.parse : string -> json Parses JSON text into a json value. Raises a runtime failure on invalid JSON input.

    • Json.parse_opt : string -> [ \None | Some(json) ] Like parse, but returns None` 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, returns Some(value); otherwise None.

    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 and Some(x).

    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 Array is 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.

    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.

    Log.debug : string -> unit task
    Log.info : string -> unit task
    Log.warn : string -> unit task
    Log.error : string -> unit task

    These are diagnostic operations observed by the host runtime.

    Turn.prepend_system : string -> unit task
    Turn.append_item : item -> unit task
    Turn.replace_item : string -> item -> unit task
    Turn.delete_item : string -> unit task
    Turn.replace_or_append : [ `None | `Some(string) ] -> item -> unit task
    Turn.append_notice : string -> unit task
    Turn.halt : string -> unit task

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

    Tool.approve : unit -> unit task
    Tool.reject : string -> unit task
    Tool.rewrite_args : json -> unit task
    Tool.redirect : string -> json -> unit task
    Tool.call : string -> json -> [ `Ok(json) | `Error(string) ] task
    Tool.spawn : string -> json -> string task

    Tool.call is interpreted as an external synchronous operation. Tool.spawn is interpreted as an external asynchronous operation.

    Model.call : string -> json -> [ `Ok(json) | `Refused(string) | `Error(string) ] task
    Model.spawn : string -> json -> string task

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

    Process.run : string -> string array -> string task

    Process.run is a host-managed external operation. Hosts may reject or omit this capability entirely.

    Schedule.after_ms : int -> 'e -> string task
    Schedule.cancel : string -> unit task

    The event payload of Schedule.after_ms remains a raw ChatML value.

    Runtime.emit : 'e -> unit task
    Runtime.request_compaction : unit -> unit task
    Runtime.request_turn : unit -> unit task
    Runtime.end_session : string -> unit task

    Runtime.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_end
    • internal_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.

    Ui is available only on ui_moderator_surface.

    Ui.notify : string -> unit task

    Ui.notify emits a host-local notice. It does not mutate canonical history and does not append transcript items automatically.

    Approval is available only on ui_moderator_surface.

    Approval.ask_text : string -> string task
    Approval.ask_choice : string -> string array -> string task

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


    This is not a full formal grammar, but it summarizes the implemented surface syntax.

    type t = type_expr
    let x = expr
    let x : type_expr = expr
    let f x y = expr
    let f () = expr
    let rec f : type_expr = expr
    let rec f x = expr and g y = expr
    module M = struct stmts end
    open M
    expr
    ()
    1
    1.0
    true
    "x"
    x
    fun x -> expr
    fun () -> expr
    f(x)
    if c then t else e
    while c do body done
    let x = e1 in e2
    let x : t = e1 in e2
    let rec f x = e1 in e2
    let* x = e1 in e2
    let+ x = e1 in e2
    match e with | pat -> e
    { a = e; b = e }
    e.field
    { e with field = e }
    [e1, e2, e3]
    arr[i]
    arr[i] <- v
    ref(e)
    !r
    r := v
    e1; e2
    `Tag
    `Tag(e1, e2)
    x + y
    x - y
    x * y
    x / y
    -x
    x +. y
    x -. y
    x *. y
    x /. y
    -.x
    x ++ y
    x < y
    x > y
    x <= y
    x >= y
    x <. y
    x >. y
    x <=. y
    x >=. y
    x == y
    x != y
    _
    x
    ()
    1
    1.0
    true
    "x"
    `Tag
    `Tag(p1, p2)
    { field = pat }
    { field = pat; _ }
    int
    float
    bool
    string
    unit
    expr
    expr -> int
    unit -> string
    state task
    task array
    state task array
    { name : string; status : status }
    [ `Pending | `Done | `Error(string) ]

    Type errors are reported with:

    • a message
    • an optional source span

    When a span is available, formatting uses source-text excerpts with caret markers.

    Runtime errors are also structured:

    • message
    • optional source span

    and are formatted in the same general style as type errors.

    Diagnostics are now materially better for:

    • row-typed records
    • row-typed variants
    • equality misuse
    • open shadowing

    Parse errors are still comparatively basic. Menhir failure reporting is not yet elevated to the same level of quality as type/runtime diagnostics.


    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 open shadowing
    • 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.)


    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 type declarations
    • 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.


    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.

    The richer builtin type language exists for host/runtime authors, not as a complete user-facing type-annotation mechanism.


    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.name over aggressive open use when readability matters
    • push complex host interaction into builtins/runtime services

    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;
    st
    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.0
    if avg(1.0, 3.0) >=. 2.0 then true else false
    module Flow = struct
    let one = 1
    let inc x = x + 1
    end
    Flow.inc(Flow.one)
    module Math = struct
    let two = 2
    end
    open Math
    print(two)

    But:

    let two = 99
    open Math

    is rejected because open Math would shadow two.

    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.

    (* 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") ] in
    let st0 = { tasks = tasks; idx = 0; running = false } in
    let i = ref(0) in
    let 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 + 1
    done
    let events =
    [ `Start
    , `Tick, `Tick, `Tick
    , `Tick, `Tick, `Tick
    , `Tick, `Fail("network")
    , `Stop
    ]
    run(events)
    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)))
    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.

    Tasks are first-class runtime values. The core value space therefore includes:

    VTask of task

    with 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 eff

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

    ChatML follows the existing postfix type-constructor style.

    The type constructor is named:

    • task in the type namespace
    • Task in the module/value namespace

    Examples:

    state task
    message array
    state task array

    The builtin helper functions therefore have shapes such as:

    Task.pure : 'a -> 'a task
    Task.bind : 'a task -> ('a -> 'b task) -> 'b task

    The builtin Task module currently exports:

    Task.pure : 'a -> 'a task
    Task.bind : 'a task -> ('a -> 'b task) -> 'b task
    Task.map : 'a task -> ('a -> 'b) -> 'b task
    Task.fail : string -> 'a task
    Task.catch : 'a task -> (string -> 'a task) -> 'a task

    ChatML also supports monadic let sugar for tasks:

    let* x = t1 in t2
    let+ x = t1 in t2

    desugaring to Task.bind and Task.map respectively.

    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) result
    apply_value_exn : value -> value list -> value

    These are used by the moderator runtime and may also be used by other host embeddings.

    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 : state
    on_event : context -> state -> event -> state task

    At 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 -> item
    Item.id : item -> string
    Item.value : item -> json
    Item.kind : item -> string option
    Item.role : item -> string option
    Item.text_parts : item -> string array
    Item.input_text_message : string -> string -> string -> item
    Item.output_text_message : string -> string -> item

    Item.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 task
    Turn.append_item : item -> unit task
    Turn.replace_item : string -> item -> unit task
    Turn.delete_item : string -> unit task
    Turn.halt : string -> unit task

    Legacy Turn.append_message, Turn.replace_message, and Turn.delete_message names remain available as aliases.

    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_start
    • session_resume
    • turn_start
    • message_appended
    • pre_tool_call
    • post_tool_response
    • turn_end
    • internal_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_start for fresh moderated sessions,
    • session_resume when restoring a persisted moderator snapshot,
    • turn_start before each model request,
    • message_appended after a canonical transcript item is appended by the streamed runtime,
    • pre_tool_call before tool execution,
    • post_tool_response after a tool output item is produced,
    • turn_end after a streamed assistant turn finishes,
    • internal_event while replaying queued emitted events.

    Task effects are represented internally as:

    type eff =
    { op : string
    ; args : value list
    }

    with two execution modes:

    • TPerform eff for host-performed operations
    • TSpawn eff for host-spawned asynchronous operations

    The concrete set of supported operation names is host-defined.

    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_state and on_event
    • handle events:
      • call on_event
      • require a returned task
      • interpret that task
      • commit or discard local transactional outputs

    The public entrypoints are:

    compile_script
    instantiate_session
    handle_event
    pending_ui_request
    resume_ui_request

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

    The moderator runtime interprets task effects through an operation registry:

    type op_kind =
    | Local_transactional
    | External_sync
    | External_async
    | Diagnostic

    Each 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_operations
    default_runtime_config

    This 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.emit buffers an internal event,
    • Runtime.end_session marks the session as ended on successful commit,
    • external integrations such as Tool.call, Model.call, and Schedule.after_ms fail with a clear "… is not configured" error unless the host supplies handlers.
    • Runtime.request_turn is 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 after turn_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.

    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_request becomes available,
    • and ordinary handle_event progression remains blocked until the host calls resume_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.

    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.

    The integrated host persists moderator state explicitly in Session.t.

    The persisted snapshot contains:

    • script_id
    • script_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.

    The operation model includes runtime phase checks:

    phase_check : string -> (unit, string) result

    The current default registry provides helper constructors such as:

    allow_all_phases
    require_phases

    but the default assembled operation set currently uses permissive allow_all_phases checks unless the host overrides them.

    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 after turn_end
    • Runtime.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:

    1. end_session terminates immediately and overrides continuation.
    2. Tool-driven follow-up (pending tool calls) continues.
    3. request_turn continues.
    4. 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.

    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.

    See ChatMD shell extensions.

    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)
    

    Link to this fileDownload this file

    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.

    Link to this fileDownload this file