MHL META-HARNESS LANGUAGE
GitHub ↗
MHL · Language Specification · Draft

Meta-Harness Language

Language and Standard Library Specification

Document MHL-SPEC Edition Current runtime profile Status v1.4 release candidate Language English

Abstract

Status and scope

Meta-Harness Language (MHL) is a declarative language for defining controlled AI-assisted automation. A program declares agents, prompts, tools, memory stores, protocol extensions, and executable pipelines. The runtime owns sequencing, branching, retries, timeouts, persistence, concurrency, verification, and termination; an agent is a replaceable participant within that harness.

This specification is intentionally ECMA-like: it separates lexical and syntactic grammar from runtime semantics, defines the standard callable surface, and uses normative terms. It is a self-contained reference, not a tutorial.

Current profileBehavior implemented by the reference runtimeDraftMay evolve before the language contract is frozen
READING PATH

For a guided introduction, return to the MHL overview. For copyable examples, use the practical reference.

1. Conformance

A conforming implementation MUST parse valid source according to this specification, MUST reject malformed source, and MUST implement the observable semantics of expressions, statements, declarations, and standard-library operations. An implementation MAY provide additional extension kinds, CLI integrations, or host policies, but MUST NOT change the meaning of the core language in a conforming profile.

The reference implementation is the mhl command-line runtime. The current profile is versioned independently from this document and can be queried with mhl version. The profile is evolving; unsupported configuration is rejected rather than silently ignored where the runtime can validate it.

Conformance boundaryThe language core is deterministic for a given input and external-operation result. Agent responses, files, network services, clocks, and extension processes are host effects; their values are observable inputs to the program.

2. Terms and notation

TermMeaning
MUST / SHALLNormative requirement for a conforming implementation or program contract.
MAYPermitted behavior that is not required.
raisesProduces an execution error. Unless caught, it aborts the current step and run and results in a non-zero CLI status.
expressionA construct that evaluates to a value.
statementA construct that performs an action or controls execution.
declarationA top-level named capability or executable unit.
host effectProcess, filesystem, network, clock, memory, or extension interaction performed by the runtime.

Grammar productions use ::= for definition, square brackets for an optional item, braces for repetition, and quoted text for literal tokens. The notation T[] means an array whose elements have type T. A signature ending in → null returns no useful value; → never always raises.

3. Lexical grammar

Source files normally use the .mh extension. The lexer MUST ignore spaces, tabs, carriage returns, newlines, and comments beginning with // and continuing to the end of the line.

TokenFormSemantics
Identifier[A-Za-z_][A-Za-z0-9_]*Names declarations, variables, fields, methods, and enum variants. Keywords are recognized by position.
StringDouble quoted, with backslash escapesOne string value. A string is not re-tokenized when its content happens to be an operator.
Multiline string""" ... """String value with the delimiters removed and surrounding whitespace trimmed.
Number[0-9]+ or [0-9]+.[0-9]+A number represented by the runtime as a floating-point value. Scientific notation is not part of the grammar.
Duration[0-9]+(ms|s|m|h|d)A contextual duration literal, for example 250ms, 30s, 2h, or 7d. The numeric part is integral.
Operators== != < > <= >= + - * / % ! && || ?? +=Operator tokens. Multi-character operators take precedence over their prefixes.
Punctuation-> . ?. ?.[ ] ( ) { } : , .. ^ = ?Separates grammar constructs and forms access, calls, slices, lambdas, and assignment. A lone ? is valid only in an optional object-shape field (§5.2); it is not an operator.
Malformed numeric textA digit run directly followed by letters that is not a duration, such as 10.0d, 1e5, or 5days, MUST be rejected as a malformed number.

4. Syntactic grammar

The following is a compact grammar. The complete behavior of each production is specified in the subsequent clauses.

program        ::= declaration*
declaration    ::= ["export"] (import | agent | router | prompt | memory | tool | extension
                    | pipeline | workflow | workflow-alias | type | enum | schema | test)
schema         ::= "schema" Identifier "from" String
import         ::= "import" "{" import-item ("," import-item)* "}" "from" String
                 | "import" String
import-item    ::= Identifier ["as" Identifier]
type-alias     ::= "type" Identifier "=" type
type           ::= Identifier array-suffix* | object-shape array-suffix*
object-shape   ::= "{" [shape-field (","? shape-field)* ","?] "}"
shape-field    ::= Identifier ["?"] ":" type
expression     ::= coalesce
coalesce       ::= or-expression ("??" or-expression)*
or-expression  ::= and-expression ("||" and-expression)*
and-expression ::= equality ("&&" equality)*
equality       ::= comparison (("==" | "!=") comparison)*
comparison     ::= additive (("<" | ">" | "<=" | ">=") additive)*
additive       ::= multiplicative (("+" | "-") multiplicative)*
multiplicative ::= unary (("*" | "/" | "%") unary)*
unary          ::= ["!" | "-"] postfix
postfix        ::= primary trailer* with-update*
with-update    ::= "with" object
trailer        ::= "." Identifier | "?." Identifier | "?." "[" expression "]"
                 | call | "[" expression "]" | slice
ref-object     ::= "ref" (object | "(" expression ")")
call           ::= "(" [argument ("," argument)*] ")"
argument       ::= Identifier ":" [expression] | expression
object         ::= "{" [object-field (","? object-field)* ","?] "}"
object-field   ::= (String | Identifier) ":" expression | Identifier
slice          ::= "[" [slice-bound] ".." [slice-bound] "]"
slice-bound    ::= ["^"] expression
lambda         ::= "(" [parameter ("," parameter)*] ")" "->" (expression | block)
if-expression  ::= "if" "(" expression ")" expression "else" expression
match          ::= "match" expression "{" match-arm+ "}"
match-arm      ::= ("_" | expression) "->" expression
goto-match     ::= "goto" "match" expression "{" goto-match-arm+ "}"
goto-match-arm ::= ("_" | expression) "->" (Identifier | "fail" "(" expression ")")
workflow-route ::= "route" Identifier "(" parameter ")" "{" goto-match-arm+ "}"
route-goto     ::= "goto" Identifier "(" expression ")"
block          ::= "{" statement* "}"

A declaration body is a sequence of properties and/or declaration-specific members. Object fields may be separated by commas, line breaks, or both. MHL does not require semicolons.

5. Values and types

5.1 Runtime values

KindLiteral / exampleRules
string"hello"Text. String methods operate on the runtime string representation.
number42, 0.25One numeric kind; arithmetic results are numbers.
booltrue, falseLogical conditions MUST be boolean; MHL has no truthiness conversion.
nullnullAbsence value. It is accepted at typed boundaries and is observable with is_null.
array[1, "two", false]Ordered, zero-based collection. Elements MAY have different runtime kinds.
object{name: "MHL", year: 2026}String-keyed map. Keys may be identifiers or strings. A value (§5.3).
reference objectref {name: "MHL"}An object with identity, shared by every name that holds it (§5.3). Its kind is object.
enumStatus.ReadyTagged value; it is not equal to the string "Ready".
function(x) -> x + 1Closure created by a lambda expression.
taskresult of spawnOpaque asynchronous handle with the fields defined in §16.

5.2 Declared type expressions

Type annotations are accepted on pipeline inputs, a pipeline/workflow's typed signature (§16.9), prompt parameters, tool parameters, tool returns, and object-shape fields. The built-in vocabulary is any, string, number, bool, array, and object. The compatibility spellings int, integer, float, and boolean resolve to number or bool. Prompt parameters MAY use the pseudo-type prompt.

type Slug = string
type Issue = {code: string, severity: string}
type IssueList = Issue[]

tool Reports {
    add(slug: Slug, issues: IssueList): string[] -> issues.map((x) -> x.code)
}

An array suffix adds one nesting level: string[][]. An object shape requires the named fields and their types, but permits extra fields. A field written with a trailing ? ({diff: string, base?: string}) is optional: a value MAY omit it; when present it MUST satisfy its type. An optional field is excluded from the "required" set of any JSON Schema projected from the shape. A required field satisfies either a required or an optional field of the same type. An optional field satisfies only an optional field of the same type; it does not satisfy a required field. A type declares no default values. Aliases are purely syntactic names, MAY reference other aliases, and MUST NOT be cyclic, duplicated, unknown, or shadow a reserved built-in name. A value of null is assignable to any declared type. Other values are checked by exact runtime kind; there is no implicit number/string conversion.

Duration boundaryDuration tokens are consumed by operations and configuration fields that explicitly expect a duration, including time arithmetic, retry delays, cache TTLs, wait/step/group timeouts, and HTTP or command timeouts. A duration is not a general-purpose declared value type in the current profile.

5.3 Values and reference objects

Arrays and objects are values. Evaluating a variable, or a member or index of one, yields a structure independent of the stored one: a later change through one name MUST NOT be observable through another. This holds for assignment, argument passing, and nesting a variable in an array or object literal. An in-place change of a variable's own structure (a["k"] = v) changes only that variable.

A reference object, created by ref followed by an object literal or by a parenthesized expression that evaluates to an object (whose fields are copied), has identity. Copying a structure that contains one copies the reference, not the object, so every name holding it observes every change to it. Equality between two reference objects is identity; a reference object is never equal to a plain object. Member access, indexing, index assignment and value methods apply to its fields exactly as to a plain object's, and a declared object type is checked against those fields. Applying ref to a non-object, or to a value that already is a reference object, is an error.

Identity MUST survive a checkpoint: the persisted state records each reference object with a stable identifier, and restoring it MUST yield one object per identifier, so a resumed run is observably equivalent to an uninterrupted one — including a reference object that contains itself. When a value leaves the interpreter — JSON conversion, a native operation, an extension, memory, mem, or a run's result — every reference object in it is replaced by a plain copy of its fields at that moment; a reference object that contains itself has no such form and that conversion fails, except for a paused run's display-only partial state, which MAY render the cycle as a placeholder.

ref is contextual: only ref { and ref ( begin a reference expression, and ref otherwise remains an ordinary identifier.

5.4 Copy-with-override (with)

expression "with" object evaluates expression, which MUST be an object (a plain object or a reference object; any other kind is an error), and produces a new plain object: every field of expression's value not named in object is copied from it, and every field named in object is set to object's evaluated value instead, overriding any same-named field expression had. expression itself is left unchanged. A field whose value is a reference object keeps its identity in the copy, exactly as an ordinary object literal's field would; every other field is copied the same way a plain object read already copies (§5.3). Applying with to a reference object's value yields a plain object, never a second name for the same identity.

Two or more with continuations on the same expression (a with {x: 1} with {y: 2}) apply left to right, each on the result of the one before it. A member or index access on a with expression's result requires parentheses ((expr with {...}).field); with is not itself followed by further postfix trailers.

with is contextual: only with { continues an expression this way, and with otherwise remains an ordinary identifier — there is no bare-object-following-an-expression construct elsewhere in the grammar for this to collide with.

6. Expressions

6.1 Evaluation and precedence

Expressions are evaluated from lowest to highest precedence in this order:

?? → || → && → == != → < > <= >= → + - → * / % → unary ! - → member, optional member, call, index, and slice → with (§5.4).

FormSemantics
Literal / identifierProduces the literal or resolves a value in the current scope. Unresolved identifiers raise.
a + bNumeric addition, string concatenation, or array concatenation. A string and a number, bool, or enum value, in either order, concatenate with the non-string operand formatted as interpolation formats it ("n=" + 3 is "n=3"). Other combinations — including a string with null, an object, or an array — raise.
a - b, a * b, a / b, a % bNumeric arithmetic. Division by zero raises.
a == b, a != bType-aware deep equality; arrays and objects are compared recursively and in order.
a < b etc.Numeric comparison. String ordering is provided by time.compare, not these operators.
!x, -xBoolean negation or numeric negation. The operand kind is checked.
a && b, a || bShort-circuit boolean conjunction/disjunction. Both operands MUST be boolean when evaluated.
a ?? bReturns a unless it is null; evaluates b only for null.

6.2 Access, indexing, calls, and interpolation

var title = record.title
var item = items[0]
var last = items[^1..]
var safe = record?.metadata?.owner ?? "unknown"
var dynamic = record?.[field_name]
var message = "${title}: ${items.size()} items"
var next = Transform(value: 3)

Plain member access requires an object field and raises when absent. Plain array indexing is zero-based and raises outside the array. Object indexing requires a string key. Slices use an inclusive lower bound and exclusive upper bound; omitted bounds mean the beginning or end, and ^n counts from the end. Slice results are new arrays.

Optional member access (?.name) and optional dynamic indexing (?.[expr]) yield null for null, non-object receivers, missing object fields, or out-of-range array indexes; the remainder of the trailer chain is skipped. Calls accept positional or named arguments. A named argument has the form name: expression; name: with no expression is shorthand for name: name. Likewise an object literal field written as a bare identifier name is shorthand for name: name; a string key or a reserved word has no shorthand form. A shorthand is equivalent to its long form in every respect, including the definition digest (§16.1).

String interpolation evaluates the expression between ${...} in the current environment. It is available in ordinary strings, multiline strings, prompt bodies, agent argument values, log paths, and memory paths. Values are formatted as strings, with structured values represented as JSON-like text. The expression MAY itself contain an ordinary, unescaped double-quoted string literal — "${time.format(x, "yyyy-MM-dd")}" — nested to any depth (a further interpolation, a nested object literal, a string containing {/}); escaping the inner quotes (\") is accepted for backward compatibility but is no longer required.

Static analysis (mhl lint) MUST report an interpolation span that does not parse, and an identifier in a span — or the receiver of a member access anywhere in a step, lifecycle hook, tool method, or test body — that resolves to no local, pipeline input/var/mem, builtin, or declared/imported name. The arguments of an agent run or router delegate call are exempt, since their interpolation also sees the agent before hook's bindings.

6.3 Conditionals, lambdas, and match

The expression form if (condition) when_true else when_false evaluates exactly one branch. A lambda has the form (parameters) -> expression or (parameters) -> { statements }. Tool methods and lambdas bind positional parameters left-to-right; defaulted parameters MUST be trailing for these callables. A lambda closes over the scope it is written in by value — a snapshot taken once, when the lambda expression itself is evaluated — not by live reference: the body reads a captured name's value as it stood at that moment, but assigning to a captured name inside the body is accepted without error and never propagates back to the defining scope, however many calls deep the lambda is eventually invoked from. A lambda's only channels back to its caller are its own return value and any external effect it performs itself (a tool/extension call, mem).

var even = values.filter((n) -> n % 2 == 0)
var label = if (ready) "ready" else "blocked"
var decision = match status {
    Status.Draft     -> "hold"
    Status.Published -> "ship"
    _                -> "skip"
}

match evaluates its subject once, tries arms top-to-bottom, and selects the first pattern deeply equal to the subject. The underscore is a wildcard. For an enum or boolean subject, a match without a wildcard MUST be exhaustive under static analysis. A match with no matching arm raises at runtime.

7. Statements

Statements occur in pipeline steps, tool method blocks, lambda blocks, and test descriptions. An inline statement is permitted where a braced block would otherwise occur.

StatementFormBehavior
Variablevar x = expressionCreates or rebinds a step-local value.
Constantconst x = expressionCreates a binding that cannot be the target of = or +=.
Assignmenttarget = expressionUpdates a variable, object field, or array element.
Destructuringvar {f, g: n} = expression, {f} = expression, self.{f} = expressionEvaluates expression exactly once, then, for each entry in order, binds expression.f to f (or to n for f: n) as a var declaration, a plain assignment, or a self. assignment respectively. Equivalent in every other respect to those individual statements; a missing field raises as member access does.
Add-assignmenttarget += expressionEquivalent to target = target + expression.
Conditionalif (condition) statement [else statement]Executes the selected body.
Whilewhile (condition) statementRepeats while true; the implementation caps interpreter iterations at 10,000.
For-infor (var item in array) statementEvaluates an array and visits each element in order.
Trytry { ... } catch (err) { ... } finally { ... }Catches raised errors and executes finally when present.
Returnreturn [expression]Exits the current tool method or callable block. In a step it returns from the step. An optional value MUST start on the same source line as return itself — see the note below the table.
Breakbreak [expression]Stops the current pipeline run and, for a loop pipeline, stops further iterations. It is not a nearest-while/for exit. An optional value MUST start on the same source line as break itself — see the note below the table.
Pausepause([expression])Suspends the run at this step for a human-in-the-loop hand-off — neither a failure nor a completion. The checkpoint is kept; mhl run --resume (or the server's run/resume) re-enters this step with any merged inputs. It is a control signal, so try/catch does not catch it, and it needs no checkpoint block.
Completecomplete()Ends the run in the normal "completed" state at this exact point — the only other way a run reaches "completed" is exhausting the fully merged step list. It is a control signal like pause/break, so try/catch does not catch it. The primitive a convergence/terminal step should call instead of relying on physical last-declared position, particularly in a partial pipeline (§16.6) where that position depends on cross-file import order.
Gotogoto StepName or goto nameof(StepName)Transfers to a named step. Legal only in a workflow. The nameof(...)-wrapped spelling is pure grammar sugar for the same bare step name — it is not evaluated as the nameof(...) expression-position builtin (§10) — so a step it names is still checked purely as a step reference, not as a top-level declaration name.
Goto matchgoto match value { pattern -> StepName _ -> fail(reason) }Evaluates the subject once and tests arms in order using the same equality as expression match. A selected step name is a static identifier in the current workflow, checked by lint. An unmatched value without a wildcard fails at runtime; a selected fail(reason) arm fails with the formatted reason. Lint reports duplicate patterns and arms after _.
Route gotogoto RouteName(value)Invokes a reusable route declared in the current workflow. The route has exactly one typed parameter and the same arms as goto match. Route names, arity, patterns, and step destinations are checked statically.
Expressioncall(...) or any expressionEvaluates for its side effect or discards its value.

fail(...values) raises an error whose message is the formatted, space-joined values. A non-zero process exit code, an HTTP non-2xx response, and a failed assertion are values or test outcomes according to their APIs; they do not automatically become language raises unless documented otherwise.

return/break's optional value MUST begin on the same line as the keyword. Newlines are not statement terminators in mhl — a value expression starting on a later line than the keyword would otherwise be read as that value, silently absorbing what the author intended as the next, separate statement (its side effects included), rather than being parsed as two statements. This is rejected as a parse error rather than resolved either way, since there is no reading of it a caller could have intended:

if (flag) return
log.info("unreachable")   // parse error: read as `return log.info(...)`, not two statements

Wrap the inline branch in braces instead — if (flag) { return } — or restructure so nothing follows the bare return/break directly. A value that starts on the same line, even one that itself continues onto further lines (e.g. an object literal opened with { right after return), is unaffected.

8. Programs and modules

A program is a sequence of top-level declarations. There is no implicit namespace object. A declaration marked export is eligible for import by another file.

// shared.mh
export prompt Greeting(name: string) { "Hello, ${name}!" }
export tool Text {
    upper(value: string): string -> value.to_upper()
}

// main.mh
import {Greeting, Text as TextUtil} from "shared.mh"
pipeline Welcome {
    input name: string
    step Say {
        log(Greeting(name: name))
        log(TextUtil.upper("ready"))
    }
}

import imports an explicit list of exported names, optionally assigning a local alias. Imports are resolved transitively and flattened into the current program namespace; there is no namespace-qualified import syntax. Duplicate names, missing files, non-exported names, and alias collisions are static errors.

9. Declarations

The declaration keywords are agent, router, prompt, skill, memory, tool, extension, pipeline, workflow, type, enum, and test. Names are unique within the flattened program namespace.

9.1 Type aliases and enumerations

type UserId = string
type User = {id: UserId, active: bool}
enum State { Draft, Ready, Complete }

An enum declares a closed set of no-payload variants. Values are constructed through qualified access such as State.Ready, through State.parse(text) — which yields the variant named by the string text, returns an argument that already is a value of State unchanged, and raises for any other argument — and from pipeline inputs: a string supplied for an input whose declared type is an enum (at any depth through object fields and array elements) MUST be converted to that enum's value before the first step, and a string that names no variant MUST be rejected as an invalid input. State.values() yields every variant in declaration order. Enum values support equality, interpolation, JSON formatting (the bare variant name), and exhaustive match; an enum value never equals a string, and static analysis SHOULD report an equality comparison between an enum-typed name and a string literal. A checkpoint MUST preserve an enum value as an enum value.

Three object shapes — SessionContext, StepContext, FailureContext — are builtin: declared by the runtime itself, not by any program, and resolved by a bare : Type name exactly as a primitive keyword (string, object, ...) already is. A program MUST NOT redeclare one of these three names with its own type/enum declaration — the same static error a primitive keyword's redeclaration already is. §16.8 defines the pipeline/workflow lifecycle hooks whose single lambda parameter is bound to one of these three shapes; that binding is informational only (it constrains nothing the runtime enforces — a hook's parameter is never itself type-annotated) and exists so a bare : SessionContext/: StepContext/: FailureContext annotation is available anywhere else a declared type already is (an input, a tool method parameter or return), and so a development tool can resolve a hook parameter's member access to that type's declared fields.

9.2 Tool declarations

tool Files {
    const extension = ".go"
    var suffix = extension
    read(path: string): string -> fs.read(path)
    count(paths: string[]): number -> {
        var n = 0
        for (var path in paths) { if (path.ends_with(suffix)) n += 1 }
        return n
    }
}

A tool is a named method namespace. Its body may declare const name = expression and var name = expression alongside methods. These declarations initialize in order for each method call; methods can read them, and a method can assign to a tool var during that call. A method has positional parameters, optional type annotations, optional defaults, and either an expression body or a statement block. An expression body follows -> (read(p: string): string -> fs.read(p)); a block body MAY omit it (count(items): number { ... }), since the { already opens the body. -> { key: value } is an object-literal expression body, never a block. A method invocation creates a fresh call environment containing tool declarations and its parameters and does not expose caller-local variables. A declared return type is checked after evaluation. A block without return yields null. Inside a method, self.method(...) calls another method of the same tool (§16.7 documents self's second, unrelated meaning inside a pipeline/workflow step) — it always resolves to this tool, regardless of what else is declared elsewhere. self used any other way inside a tool method (a bare property access, a call naming a method the tool doesn't have) MUST be rejected by mhl lint and MUST raise at runtime if reached anyway.

A method declared internal is private to its tool. It MAY be called as self.name(...) from the tool's own methods (including closures created in them) and from a test declared in the same file as the tool; any other call MUST raise, and static analysis MUST report it. A method named internal remains valid: the word is a modifier only when a method name follows it.

9.3 Prompt declarations

prompt Review(file: string, language: string = "English") {
    "Review ${file}. Write the findings in ${language}."
}
prompt External(file: string) from "review.prompt.md"

A prompt is a named template. Prompt calls bind by parameter name, and missing parameters MUST have defaults or the call raises. Extra and unknown arguments raise. A default expression is evaluated in the caller scope for prompt calls; a prompt default MAY appear in any parameter position. An external Markdown body uses the same ${name} interpolation syntax; write \${name} when a literal placeholder must remain.

A file-sourced prompt's body MAY open with a frontmatter block — the same flat, ----delimited key: value format §9.4 defines for a skill's SKILL.md, but with no required keys. It is parsed and stripped from the rendered body, and is readable as PromptName.frontmatter, an object (empty when the file declares no such block). An inline prompt Name() { ... } body never has frontmatter.

9.4 Skill declarations

skill CodeReview from "code-review.md"

A skill is a named, reusable instruction package, always file-sourced — there is no inline skill Name { ... } form. Unlike every other declaration, its MHL name (CodeReview, used in skills: lists, run(skills: ...), and nameof(CodeReview)) is distinct from any name given in the file's own frontmatter, which identifies the package in text sent to a model, not in the program. A skill is consumed only through an agent's skills/system_prompt properties (§12.3); a skill's Markdown content is never executed.

The source file MUST open with a frontmatter block: a line containing only ---, zero or more flat key: value lines (blank lines permitted; no nesting, lists, or multi-line values), and a closing --- line. The keys name and description MUST both be present and non-empty; any other key is preserved unvalidated. Everything after the closing --- is the skill's content, with leading/trailing whitespace trimmed. A missing file, a malformed or incomplete frontmatter block, or a file exceeding the implementation's configured size limit MUST fail at import resolution — before any pipeline that references the skill runs.

9.4.1 Schema declarations

schema Brief from "schemas/brief.schema.json"

A schema declaration names a JSON Schema file, resolved relative to the declaring file like a prompt or skill source. The file MUST contain a single JSON object and MUST NOT exceed 1 MiB; otherwise import resolution fails. Evaluated as an expression, the declared name yields the object {content, path}: content is the file's text and path its absolute filesystem path. A binding of the same name in scope shadows the declaration. The definition digest (§16.1) MUST include the content and MUST NOT include the path.

9.5 Extension declarations

extension mcp Issues {
    transport: "http"
    url: "https://mcp.example.test"
    headers: {"Authorization": "Bearer " + env("ISSUES_TOKEN")}
}

The generic form is extension kind Name { properties }. The kind selects a built-in or installed adapter. Properties are evaluated and validated by the adapter, which also defines the callable methods.

An optional from "id" clause, extension kind Name from "id" { properties }, names the installed adapter by its manifest id. The kind remains the contract and the id selects the implementation, so several installed adapters MAY serve the same kind side by side. Resolution MUST proceed as follows: with from, the adapter with that id is used, and it is an error if no such adapter is installed or if it does not serve the kind; without from, the kind MUST be served by exactly one adapter, and it is an error if it is served by none or by more than one. Built-in kinds (mcp, a2a) are reserved: an installed adapter MUST NOT serve them. An absent from clause MUST NOT change the definition digest (§16.1).

extension data Cache from "mhl.data.redis"    { url: env("REDIS_URL") }
extension data Db    from "mhl.data.postgres" { dsn: env("DATABASE_URL") }

10. Standard library

The standard library is available without an import. It consists of global functions, methods on values, declared-capability methods, and the native namespaces in §11. The signatures below use any for unconstrained values and ... for variadic arguments.

10.1 Global functions

SignatureSemantics
log(...values: any) → nullWrites one space-joined line to stdout. Strings are raw, numbers are formatted compactly, and structured values are rendered as JSON.
fail(...values: any) → neverRaises with the same formatting used by log. Catchable with try.
pause(reason?: any) → neverSuspends the run at this step for a human-in-the-loop hand-off (state paused, resumable) rather than failing or completing. Not catchable with try; re-entered on --resume / run/resume with merged inputs.
complete() → neverEnds the run in the normal completed state at this exact point, regardless of what step (if any) is declared after it — the only other way a run reaches completed is exhausting the fully merged step list. Not catchable with try. Valid only inside a pipeline/workflow step; takes no arguments.
env(name: string, default?: string) → stringReads an operating-system environment variable. An unset variable returns default when given, otherwise the empty string. Sensitive names and values are registered for redaction. In a command/args property or an extension property, only the 1-argument form is a credential reference (§19): it MUST resolve before the declaration is used (fail closed) and its value is masked. The 2-argument form is exempt from that requirement — reachable through any branch of an if/match expression or not — since supplying default is itself a declaration that the reference is optional.
nameof(Name) → stringReturns the name of a top-level declaration — agent, router, memory, tool, prompt, skill, pipeline/workflow, extension, type, or enum — as a string. Name MUST be a bare identifier read directly from the call site, not an evaluated expression; it is validated to name an actual declaration, and an unresolved or non-identifier argument is a static error at mhl lint time and a runtime error otherwise.
type_of(value: any) → stringReturns string, number, bool, array, object, null, enum, function, or task.
is_string(value: any) → boolTrue exactly for strings.
is_number(value: any) → boolTrue exactly for numbers.
is_bool(value: any) → boolTrue exactly for booleans.
is_array(value: any) → boolTrue exactly for arrays.
is_object(value: any) → boolTrue exactly for objects.
is_null(value: any) → boolTrue exactly for null.
is_enum(value: any) → boolTrue exactly for enum values.

10.2 Common value methods

ReceiverSignatureResult
string, array, objectsize() → numberString byte length, array element count, or object entry count.
string, array, objectis_empty() → boolEquivalent to size() == 0.
anyequals(other: any) → boolType-aware deep equality. deep_equal is an alias.

10.3 String methods

SignatureSemantics
split(separator: string) → string[]Splits at every occurrence. An empty separator splits into individual runtime string units.
replace(old: string, new: string) → stringReplaces all occurrences.
contains(sub: string) → boolTests substring presence.
starts_with(prefix: string) → boolTests prefix.
ends_with(suffix: string) → boolTests suffix.
matches(pattern: string) → boolWhole-string match against an RE2 regular expression (Go regexp syntax) — the entire string MUST match, not merely contain a match. An invalid pattern raises.
trim() → stringRemoves leading and trailing whitespace.
to_upper() → stringReturns an upper-case string.
to_lower() → stringReturns a lower-case string.
substring(start: number, end: number) → stringReturns the half-open byte range [start,end); bounds MUST be integral and within [0,size()].
remove(from: number, to: number) → stringReturns the string with the half-open byte range [from,to) removed — the complement of substring. Bounds MUST be integral and within [0,size()].
remove_content(from: string, to: string) → stringReturns the string with the first occurrence of from, through the end of the first to found after it, removed — both markers included. Raises if either marker is not found.
extract_content(from: string, to: string) → stringReturns only the first occurrence of from, through the end of the first to found after it — both markers included; the complement of remove_content. Raises if either marker is not found.

10.4 Array methods

SignatureSemantics
get_index(index: number) → anyReturns a zero-based element; out of range raises.
index_of(value: any) → numberFirst deep-equal index, or -1.
contains(value: any) → boolDeep-equal membership test.
filter(predicate: (item: any) → bool) → any[]New array containing elements for which the predicate is true.
find(predicate: (item: any) → bool) → anyFirst matching element, or null.
sort_by(key: (item: any) → any) → any[]New array sorted ascending by a number or string key.
map(transform: (item: any) → any) → any[]New array containing the transform result for each element.
reduce(combine: (acc: any, item: any) → any, initial: any) → anyLeft-to-right fold from a required initial value.
any(predicate: (item: any) → bool) → boolTrue if at least one element matches; false for empty arrays.
all(predicate: (item: any) → bool) → boolTrue if every element matches; true for empty arrays.
append(value: any) → any[]New array with one value appended; receiver is not mutated.
join(separator: string) → stringJoins formatted elements using the separator.
unique() → any[]New array with deep-equal duplicates removed, retaining first-occurrence order.
enumerate() → {index: number, value: any}[]New array pairing each element with its zero-based position, for use with a plain for-in loop when the index is needed alongside the value.

10.5 Object methods

SignatureSemantics
keys() → string[]Returns keys in stable sorted order.
values() → any[]Returns values in the same order as keys().
get(key: string, default?: any) → anyReturns a field or default/null without raising for a missing key.
Immutability and mutationCollection transforms, concatenation, and slices return new arrays. Plain indexed assignment such as items[0] = value and object-field assignment mutate the addressed collection.

11. Native operations

Native namespaces are reserved and can be called from pipeline statements, tool methods, hooks, and other expression positions. They execute host effects and therefore MUST be treated as privileged operations.

11.1 Command execution: cmd

SignatureSemantics
cmd.exec(command: string | string[], timeout?: duration, encoding?: string) → {stdout: string, stderr: string, exit_code: number}A string is split on whitespace without shell quoting or expansion. An argv array passes every element verbatim. Non-zero exit is returned as exit_code; launch failure or timeout raises. encoding (§11.2.1) decodes stdout/stderr to UTF-8.
cmd.exec_all(commands: (string | string[])[], timeout?: duration, encoding?: string) → object[]Runs commands concurrently, applies timeout per command, and returns results in input order. A launch failure raises; non-zero exits remain values. encoding applies to every result.

11.2 Filesystem: fs

SignatureSemantics
fs.read(path: string, encoding?: string) → stringReads the entire file; unreadable paths raise. With encoding the bytes are decoded to UTF-8 (§11.2.1); without it they pass through unchanged.
fs.exists(path: string) → boolFalse only for not-found; other stat errors raise.
fs.write(path: string, content: string, encoding?: string) → boolCreates parent directories, truncates, writes, and returns true. With encoding the content is transcoded from UTF-8 first; a character the charset cannot represent raises.
fs.append(path: string, content: string, encoding?: string) → boolCreates parent directories and appends. encoding as for fs.write; a byte-order mark is written only into a new or empty file.
fs.delete(path: string) → boolRemoves a file or empty directory. Missing targets raise.
fs.join(...segments: string) → stringOS-appropriate cleaned path join. At least one segment is required.
fs.list(dir: string) → string[]Lists immediate entries as joined paths; it is not recursive.

11.2.1 Text encodings

Strings are UTF-8 inside the runtime. The optional encoding argument of fs.read, fs.write, fs.append, cmd.exec and cmd.exec_all (positional after the required arguments for fs.*, or named encoding:) names the charset of the bytes outside the runtime — on disk or on a subprocess's output — and transcodes at that boundary. Names are case-insensitive: "utf-8" (strict: a leading BOM is dropped and invalid UTF-8 raises), "utf-8-bom" (reads like "utf-8", writes a BOM), "utf-16" (BOM-driven, little-endian when absent; writes little-endian with a BOM), "utf-16le", "utf-16be", "latin1"/"iso-8859-1", and any WHATWG label ("windows-1252", "shift_jis", "gbk", …) or IANA name ("ibm850", …). "auto" is read-only: it honors a UTF-8/UTF-16 BOM, else keeps valid UTF-8, else decodes as Windows-1252. Writing a character the target charset cannot represent raises. An unknown name raises before any I/O. Omitting encoding keeps the bytes unchanged, valid UTF-8 or not — so text bound for an agent's stdin: from a legacy file SHOULD be read with its real encoding.

11.3 Directories: dir

SignatureSemantics
dir.create(path: string) → boolCreates the directory and every missing parent with host-default directory permissions, and returns true. The operation is idempotent when the directory already exists. A conflicting file or another creation failure raises.
dir.exists(path: string) → boolReturns true only when the path exists and is a directory. A missing path or an existing non-directory path returns false; other stat errors raise.
dir.list(path: string) → string[]Returns immediate entries as paths joined to path, sorted by filename. The operation is not recursive. A missing or non-directory path raises.
dir.clear(path: string) → boolEnsures path is an existing, empty directory and returns true: an absent path is created with its parents; every entry of an existing directory is removed, recursively, without following symbolic links. It MUST raise, removing nothing, for an empty path, a filesystem root, the working directory or any ancestor of it, a path that is itself a symbolic link, or an existing non-directory.
dir.delete(path: string) → boolRemoves an empty directory and returns true. A file, missing path, non-empty directory, or removal failure raises. The operation MUST NOT recursively delete contents.

11.4 Git: git

SignatureSemantics
git.diff(target?: string, dir?: string) → stringRuns git [-C dir] diff [target]. Non-zero exit raises.
git.add(paths: string[], dir?: string) → {stdout, stderr, exit_code}Stages paths with git add -A --. At least one path is required; exit status is inspectable.
git.commit(message: string, paths?: string[], dir?: string) → {stdout, stderr, exit_code}Passes the message as one argv element. A non-empty message is required.
git.status(paths?: string[], dir?: string) → {stdout, stderr, exit_code}Runs short status. A non-zero exit is inspectable.
git.rev_parse(ref: string, dir?: string) → stringReturns a trimmed short ref; invalid refs raise.
git.log(n: number, dir?: string) → stringReturns git log -n n --oneline; n MUST be positive.

11.5 HTTP: http

The operations http.get, http.post, http.put, http.patch, http.delete, http.head, and http.options share the following surface. The URL may be the first positional argument or the named url: argument; all other options are named.

Signature / optionSemantics
http.<verb>(url: string, ...) → {status: number, body: string, headers: {string: string}, ok: bool, json: any}Returns the response. ok is true for status 200–299. A transport failure raises; non-2xx status is a value unless raise_for_status: true.
headers: {string: string}Request headers. An explicit Authorization wins over auth:; sensitive authorization/cookie values are redacted.
query: {string: string}URL-encodes and merges query parameters with the URL's existing query.
body: anyJSON-encodes the value and sets Content-Type: application/json.
text: stringSends a raw body. It is mutually exclusive with body and form.
form: {string: string}Sends an application/x-www-form-urlencoded body.
timeout: durationOverrides the 30-second default request timeout.
follow_redirects: boolDefaults to true. False returns the redirect response.
raise_for_status: boolDefaults to false. True raises on a non-2xx response.
auth: {bearer: string} | {basic: {user: string, password: string}}Creates an Authorization header unless an explicit one already exists.
tls: {cert: string, key: string, ca: string, insecure: bool}Configures PEM client certificates, an extra CA bundle, or development-only server verification bypass.
proxy: stringUses an explicit HTTP, HTTPS, or SOCKS5 proxy; otherwise standard proxy environment variables are honored.
http.download(url: string, path: string, ...) → {status: number, path: string, bytes: number, ok: bool, headers: {string: string}}Streams a GET response to an atomic file. A non-2xx response writes no file unless raised by raise_for_status; it accepts the request options above except a request body.

For JSON responses, json contains the parsed body when the response content type is JSON, otherwise null. Response headers with multiple values are joined with ", ".

11.6 JSON: json

SignatureSemantics
json.parse(text: string) → anyParses one JSON document; invalid JSON raises.
json.parse_lines(text: string) → any[]Parses one JSON value per non-empty line.
json.stringify(value: any) → stringSerializes a JSON-compatible value with stable object keys.

11.7 Logging: log

SignatureSemantics
log.info(...values: any) → nullWrites an [INFO] line.
log.warn(...values: any) → nullWrites a [WARN] line.
log.error(...values: any) → nullWrites an [ERROR] line; it does not raise or change exit status.

11.8 Time: time

SignatureSemantics
time.now() → stringCurrent instant as RFC3339 UTC.
time.parse(text: string, layout?: string) → stringParses and normalizes to RFC3339 UTC; default input layout is RFC3339.
time.format(value: string, layout?: string) → stringFormats an RFC3339 value using a Go layout or tokens such as dd/MM/yyyy.
time.add(value: string, duration: duration) → stringAdds a duration and returns RFC3339 UTC.
time.diff(a: string, b: string) → numberReturns seconds in a - b.
time.compare(a: string, b: string) → numberReturns -1, 0, or 1 for temporal ordering.
time.sleep(duration: duration) → nullBlocks for duration (e.g. 500ms, 2s). Cancellation-aware — a run-level cancel or the step's timeout firing interrupts it and fails the step; a non-positive value is a no-op.

11.9 Identifiers: uuid

SignatureSemantics
uuid.v4() → stringRandom (version 4) UUID as a canonical 36-character lower-case string. An entropy failure raises.
uuid.v7() → stringTime-ordered (version 7) UUID — a 48-bit millisecond timestamp followed by random bits — as a canonical 36-character lower-case string. Values sort in creation order. An entropy failure raises.

11.10 HTML: html

Parses and queries HTML built on golang.org/x/net/html. There is no separate "element" value type: every node is a plain object with the same four keys — tag (the lower-cased tag name, or "#text"/"#fragment"), attrs ({string: string}), text (a text node's content, "" otherwise), and children (an array of nodes) — the same "parsed structure as plain values" contract json.parse follows (§11.6).

SignatureSemantics
html.parse(text: string) → objectParses text as an HTML fragment — as if inserted into a <body>, never wrapped in an implicit <html>/<head>/<body>. The root is always a synthetic {tag: "#fragment", ...} node, regardless of whether text parses to one top-level element or several. Comments never appear as a node; neither does a text node that is only whitespace between tags. The underlying HTML5 algorithm recovers from malformed markup rather than rejecting it, so this does not raise in practice.
html.get_element(node: object, tag?: string, attrs?: {string: string}) → object | nullThe first node in node's subtree — node itself included — matching tag (when given) and every entry of attrs (when given), visited depth-first/pre-order. null when nothing matches. Every attrs key matches by exact string equality, except class, which matches when every space-separated token of the given value is present among the node's own space-separated class tokens (in any order, alongside others) — mirroring how HTML/CSS itself treats class as a set, not an opaque string.
html.get_elements(node: object, tag?: string, attrs?: {string: string}) → object[]Every node in node's subtree — node itself included — matching tag/attrs, in the same depth-first/pre-order get_element searches. An empty array when nothing matches.
html.get_element_by_id(node: object, id: string) → object | nullShorthand for get_element(node, attrs: {id: id}).
html.get_attribute(element: object, name: string, default?: any) → anyThe value of element's attrs[name], or default (null when omitted) if absent. Never raises — not even when element is null (e.g. a get_element miss passed straight through) or otherwise not an element node.
html.get_text(node: object) → stringConcatenates the content of every "#text" descendant of node — node itself included, if it is one — in document order, with no separator inserted between sibling text runs (the DOM textContent equivalent).
html.to_html(node: object) → stringSerializes node back to an HTML string — the inverse of html.parse. A "#fragment" node serializes each of its children in turn, since a fragment itself is not one real tag. Attribute order is not preserved across a parse→serialize round trip, since attrs is stored as an object keyed by name, not an ordered list.

11.11 Host information: os

Reports facts about the host running the mhl process — not about whoever called it. In mhl serve mcp/mhl serve a2a, that is the server's own host, the same distinction env() (§10) already draws between an operator-supplied environment variable and caller-supplied input. A before hook that folds os.user() or os.hostname() into a prompt sent to a third-party agent MUST be treated as leaking host/process details, not caller identity.

SignatureSemantics
os.user() → stringOS account name running the mhl process. Raises if the account can't be looked up.
os.home_dir() → stringThe current user's home directory. Raises if it can't be determined.
os.hostname() → stringThe host's name as reported by the kernel. Raises if it can't be read.
os.platform() → stringThe target OS the running binary was built for: "darwin", "linux", or "windows".
os.arch() → stringThe target architecture the running binary was built for, e.g. "amd64" or "arm64".
os.cwd() → stringThe interpreter process's current working directory — what relative fs.*/dir.* paths resolve against. Raises if it can't be read.
os.pid() → numberThe mhl process's OS process id.
os.executable() → stringPath of the running mhl binary itself. Not guaranteed to be resolved through symlinks, and not guaranteed to still exist if the binary was moved or removed after the process started. Raises if it can't be determined.

12. Agents and traffic control

An agent declaration creates a callable single-shot backend. The call form is Name.run(prompt: ...); the prompt MUST be non-empty. An optional schema: argument is a string and is passed to backends that support structured output.

12.1 CLI and Ollama profiles

agent Reviewer {
    engine: "cli/claude-code"
    command: "claude"
    args: ["-p", "${prompt}", "--output-format", "json"]
    log: "logs/reviewer.${context.session_id}.log"
}
agent LocalModel {
    engine: "ollama/qwen2.5-coder"
    endpoint: "http://localhost:11434"
    temperature: 0.1
}

If engine is absent or begins with cli/, the runtime uses command and optional args. Each array element is one process argument; ${prompt} and ${schema} placeholders are injected without shell splitting — recognized only as a whole args: element exactly equal to "${prompt}"/"${schema}"; command: never recognizes either, and neither is substituted when embedded inside a longer string. mhl lint flags any other "${...}"-shaped span in a literal command/args value, since it would otherwise reach the real subprocess argv unsubstituted with no diagnostic. If engine begins with ollama/, the suffix is the model name and endpoint is required.

command and each args element MAY be any expression, not only a string literal — command: env("MODEL_CMD") selects a backend per environment from a single declaration instead of a duplicated .mh file per environment. A bare string literal (including the ${prompt}/${schema} placeholder tokens themselves) is used exactly as written, with no ${...} interpolation, exactly as before this MAY existed; any other expression (env(...), string concatenation, a variable) is evaluated at call time, and a credential reference it contains (env("KEY")) MUST resolve or the call fails closed (§19).

An optional stdin property is text written to the child process's standard input instead of argv, resolved the same way as command (any expression, credentials fail closed). It exists so a cli/* agent can hand the process large content — a big ${prompt}, a big ${schema} — without it ever becoming part of the command line: Windows's CreateProcess caps the whole command line (the executable plus every argument, concatenated) at 32,767 characters, and a prompt built from unbounded source content (e.g. a whole document concatenated into one string) MAY exceed that with no warning until the run fails on that platform specifically, surfacing only a raw OS error ("The filename or extension is too long" / ERROR_FILENAME_EXCED_RANGE) that gives no indication the real cause is argv size. macOS/Linux have no comparable limit (execve's ARG_MAX is on the order of 1-2MB there), so this is a Windows-specific failure mode that a macOS/Linux development environment will not reproduce. Unlike an args: element, "${prompt}"/"${schema}" in a literal stdin value are substituted wherever they occur in the string, not only when the entire value is the placeholder, so an agent MAY compose the stdin payload around them (e.g. a JSON envelope a backend's own stdin protocol expects). When stdin claims "${prompt}", the runtime MUST NOT also append promptText to args — the append-when-no-placeholder default described above applies only when nothing already routed the prompt elsewhere. A stdin value containing "${schema}" with no schema: argument supplied MUST fail the call, mirroring the same case in args.

12.2 Hooks, fallbacks, retry, cache, and rate limits

PropertyContract
beforeA zero-parameter lambda. Its returned object contributes bindings to prompt and schema interpolation. It may call tools, extensions, agents, native operations, and memory.
afterA zero-parameter lambda with the final response bound as result. A returned string replaces the response; no return (or an explicit null) passes it through unchanged; any other return type — an object included — raises at run time. after cannot return a structured value.
fallbackArray of declared agent names or inline agent objects. Each is tried after the previous leg fails; a successful fallback still invokes the primary agent's after hook.
skillsArray of declared skill (§9.4) names — the allow-list a run(skills: [...]) call MAY select a subset of, in call order. A non-empty skills without a declared system_prompt is a static error (§12.3).
system_promptA two-parameter lambda, (skills, prompt) -> ... (§12.3). Composes the outgoing prompt when a call selects at least one skill.
retry{max_attempts, delay, retry_on, backoff}. The only backoff value currently supported is "exponential". Absent retry runs once.
cache{ttl, storage, strategy}. Cache keys include engine, prompt, and schema. The only strategy currently supported is exact matching; storage: "disk" persists it.
rate_limit{requests_per_minute, concurrency, on_exceeded}. Exceeded work queues; the supported value is "queue".
traceWhen true, emits call/response trace lines. Sensitive values remain subject to redaction.
descriptionOptional string. Carries no runtime behavior for the agent itself; a router's decision cascade (§13) appends it after the agent's name in the decision prompt when declared.

Agent errors are eligible for retry according to retry_on, then fallbacks are attempted. A cache hit bypasses the external call and retry path. Agent log paths interpolate like strings and append raw CLI stdout; the log property is ignored for spawned calls, whose output is buffered and merged by the step.

A before/after body is an ordinary lambda (§6.3): it reads the scope that directly issued the .run(...) call — a step's, or an intermediary tool method's — as a snapshot taken when the hook starts, not a live view. Writing to a name from that scope inside the hook body is accepted without error but never propagates back to the caller, at any call depth, including a call made directly from a step with no tool in between. A hook's only channels back to its caller are its own return value — before's returned object as "${...}" bindings, after's returned string as the response — and any external effect it performs itself (a tool/extension call, mem).

12.3 Skills

skill CodeReview from "code-review.md"

agent Reviewer {
    command: "claude"
    args: ["-p", "${prompt}"]
    skills: [CodeReview]
    system_prompt: (skills, prompt) -> skills[0].content + "\n\n" + prompt
}

Reviewer.run(skills: [CodeReview], prompt: "Review this diff.")

skills declares an allow-list of already-declared skill names. A call's skills: [...] argument MUST name only entries of that allow-list, each a bare identifier — never a string or other expression — and MUST NOT repeat one; violating either is a static error where the selection is a literal array, and a run-time error otherwise. A call that omits skills: entirely, or supplies skills: [], selects nothing.

When a call selects at least one skill, system_prompt MUST be declared — its absence on an agent whose skills is non-empty is a static error, since it is the only channel through which a selection reaches the outgoing request. system_prompt is invoked exactly once per .run(...) call that selects at least one skill, after before and after prompt:/schema: resolve, and before the first attempt; a call that selects no skills never invokes it, and the request sent is then identical to one from an agent declaring no skills/system_prompt at all. Its two parameters are, in order:

  • skills — an array of objects, one per selected skill, in call order. Each carries name (the skill's MHL declaration name — the value nameof(CodeReview) returns, distinct from any name the skill's own frontmatter carries), frontmatter (the skill's parsed frontmatter, an object), and content (the skill's Markdown body).
  • prompt — the string prompt: resolved to for this call, before composition.

system_prompt MUST return a string; that return replaces prompt as the text carried by every subsequent attempt of this call, including a retried primary and any fallback leg — it is composed once, not once per attempt.

13. Routers

A router declaration selects one of a fixed set of already-declared agents to handle a given prompt. The call form is Name.delegate(prompt: ...); the prompt MUST be non-empty. The chosen agent then runs with the same call arguments and its response is returned unchanged. A second call form, Name.select(prompt: ...), resolves the same decision — via the select hook only, never the decider cascade — without running the chosen agent: it returns the chosen agent's name as a string, or null if select declined. .select(...) requires the router to declare a select hook; calling it on a router that declares only decider MUST be rejected as a static error, since such a router has nothing deterministic to report ahead of a real decision call.

13.1 Declaration and decision order

agent Billing { command: "claude" args: ["-p", "${prompt}"] }
agent Support { command: "claude" args: ["-p", "${prompt}"] }
agent Judge { engine: "cli/claude-code" command: "claude" args: ["-p", "${prompt}"] }

router Frontdesk {
    agents: [Billing, Support]
    select: (prompt) -> {
        if (prompt.contains("invoice")) { return nameof(Billing) }
        return null
    }
    decider: Judge
}

Resolution is hybrid: if select is declared, it is invoked first with the prompt bound as its one parameter. Its return is one of three shapes: null or absent (no decision, cascade below); a string naming an entry of agents (used directly, decision call skipped); or a string naming no entry of agents — always a runtime error, listing the value returned and the declared agent names, never treated the same as a null return. This distinction exists because a non-null, non-matching return is almost always a bug (a typo, a stale rename), not a deliberate "I couldn't decide" — silently cascading past it would risk masking that bug behind an unrelated decision-call answer. nameof(Billing) (§10.1) is RECOMMENDED over the string literal "Billing" for this return specifically: it is validated at mhl lint time, wherever the hook is written as an inline lambda literal.

When select returns null (or is absent), the runtime falls back to a decision call through decider, if one is declared: decider names an ordinary, separately declared agent (or carries an inline agent {...} literal, resolved the same way an agent's own fallback: [...] entries are) — none of its engine/command/args/endpoint/temperature/retry/cache/log/trace configuration is duplicated onto the router itself. The runtime builds a decision prompt from the agent names — plus each agent's own optional description property (§12.2), appended after its name when declared — and the original prompt, and runs it through the decider exactly as it would any other Name.run(...) call. The decision response is matched against the declared names; no match is an error.

The decision call is entirely optional. A router declares one only by declaring decider. A router declaring no decider is a purely deterministic router: select alone MUST resolve every prompt it receives. If select returns null (or is absent) and no decider is declared, .delegate() fails with a dedicated error rather than attempting to run an unconfigured decider. A router declaring neither select nor decider can never resolve an agent and MUST be rejected as a static error.

router Frontdesk {
    agents: [Billing, Support]

    // No decider declared: select alone decides.
    select: (prompt) -> {
        if (prompt.contains("invoice")) { return nameof(Billing) }
        return nameof(Support)
    }
}

13.2 Properties

PropertyContract
agentsRequired array of declared agent names. Any name that does not resolve to a declared agent is a static error.
selectOne-parameter lambda, (prompt) -> .... A returned string naming a declared agent resolves the call; null (or no return) cascades to the decision call, if one is configured; a non-null return naming no declared agent is always an error. Optional — but required, and MUST be exhaustive (never return null), when the router declares no decider. When written as an inline lambda literal, its body is statically checked by mhl lint the same way a pipeline step's statements are, including any nameof(...) call inside it.
deciderA declared agent name, or an inline agent {...} literal — the same two shapes as an agent's own fallback: [...] entries. The decision call runs through it via the agent's normal execution path (§12), so its own engine/command/args/endpoint/temperature/retry/cache/log/trace/before/after all apply. Optional — declaring no decider opts the router out of the LLM cascade entirely. A bare name that does not resolve to a declared agent is a static error.

The chosen agent's own retry, cache, rate_limit, fallback, before, and after all apply exactly as they would for a direct Name.run(...) call, since delegate ultimately runs that agent through the same execution path.

14. Memory and state

14.1 Named memory declarations

memory Session {
    type: "json"
    path: ".mhl/session.json"
}

memory Audit {
    type: "append_log"
    path: ".mhl/audit.log"
}

memory Events {
    type: "jsonl"
    path: ".mhl/events.jsonl"
}

Session.set("attempt", 1)
var attempt = Session.get("attempt", 0)
Session.remove("attempt")
Audit.append("pipeline started")
Events.append({event: "started", at: time.now()})
TypeRequired propertiesMethods
kvstore: "memory"set(key,value), get(key,default?). Lives for one run.
jsonpath: stringset(key,value), set(object), get(key,default?), remove(key). Persists a JSON object.
append_logpath: stringappend(text); one text line per call.
jsonlpath: stringappend(value); writes {value, ts} as one JSON line.
Memory signatureReturns and behavior
set(key: string, value: any) → anyStores and returns value. JSON memory also accepts set(values: object) → object as a bulk form.
get(key: string, default?: any) → anyReturns the value or the supplied default; absent values otherwise yield null. JSON and KV keys support :: navigation.
remove(key: string) → boolSupported by JSON memory; removes a top-level or ::-nested key and reports whether it existed.
append(text: string) → stringAppend-log operation. Writes one line and returns the text.
append(value: any) → anyJSONL operation. Writes {value, ts} and returns the original value.

A declaration body permits properties on the same line or on separate lines, without commas between them — type: and path: are properties of the memory declaration, not fields of an object literal. Memory methods are positional. JSON memory keys MAY navigate nested objects or arrays with ::, for example Session.get("config::retries", 3). A missing key returns the default when supplied, otherwise null. Path-backed memory interpolates its path, so use context.session_id to isolate concurrent runs. Interpolation performs no sanitization by default: an interpolated segment such as an input-supplied tenant/project id MAY contain .. or an absolute-path shape and write for real outside the directory the template author intended. An optional path_guard: "no_traversal" property closes this: each ${...} span's own value (not the fully concatenated path — the surrounding template legitimately starts from an absolute base directory) MUST NOT contain .. or itself be absolute, or the operation raises before touching disk. Not the default (existing path-backed declarations with a deliberately dynamic or already-sanitized path remain unaffected); path_guard naming any other value is a build-time error.

14.2 Persistent pipeline variables

loop pipeline Poll {
    mem attempts = 0
    mem status = "pending"
    repeat: { stop_when: status == "done", max_iterations: 10 }
    step Check {
        attempts = attempts + 1
        status = if (attempts >= 3) "done" else "pending"
    }
}

mem name = expression is get-or-initialize. Its initializer runs only when no value exists. A mem variable survives loop iterations and is visible to repeat.stop_when. name.reset() deletes it. Pipeline-level var values are initialized once per ordinary run but reset between iterations of a loop pipeline.

15. Extensions

15.1 MCP client

extension mcp Name { ... } connects to a Model Context Protocol server over stdio or HTTP. The methods are:

SignatureSemantics
Name.call(tool: string, arguments?: object) → anyIssues tools/call and returns the decoded result. An input-required response raises because this client cannot elicit and resubmit input.
Name.list_tools() → anyIssues tools/list.
Name.discover() → anyIssues server/discover, or returns initialize identity for handshake-only servers.

The protocol property accepts auto (default), 2026-07-28 stateless, 2025-11-25, or 2025-06-18 handshake mode. In auto mode the stateless attempt is tried first and protocol incompatibility may trigger handshake fallback. Stdio calls create a fresh process; HTTP calls are independent requests. Credentials in headers SHOULD be obtained through env.

15.2 A2A client

extension a2a Name { url, headers, poll_interval?, poll_timeout? } connects to a remote Agent2Agent endpoint.

SignatureSemantics
Name.send(message: string, context?: string) → objectIssues message/send. A non-terminal task is polled with tasks/get. Message results are normalized to {kind, text, parts, message_id, context_id}; tasks include id, state, artifacts, history, and status.
Name.agent_card() → objectGETs /.well-known/agent-card.json from the endpoint origin.
Name.get_task(id: string, history_length?: number) → objectIssues tasks/get.
Name.cancel(id: string) → objectIssues tasks/cancel.

Streaming, push notifications, and input-required task continuation are not implemented in the current client profile.

15.3 External adapters

An unknown extension kind resolves through an installed external adapter. The adapter is an executable described by a manifest, selected by a locked identifier and SHA-256 digest, and spoken to over newline-delimited JSON-RPC. The host sends initialize, call, and shutdown; the adapter MAY send log and secret.resolve requests. Protocol messages belong on stdout; diagnostics belong on stderr.

The manifest is extension.json: id, api_version, executable, and one or more declarations (kind, and optionally tooling metadata — properties, methods) are required; permissions gates secret and network access. declarations MAY instead live in a declarations_file sidecar — a JSON array/object, or the same shape in native mhl object/array syntax when the sidecar's extension is .mh — so tooling metadata can be regenerated without touching the manifest.

{
  "id": "com.acme.crm",
  "api_version": "1",
  "executable": "bin/crm",
  "declarations": [{"kind": "crm", "methods": [{"name": "lookup"}]}]
}

An extensible declaration is a third, mhl-native manifest form: one extension.mh file folds the manifest and its declarations together, in place of extension.json and any sidecar. manifest: { ... } supplies the same fields as extension.json's top level; a properties: { name: type } block and bare name(params): type method signatures replace the JSON declarations array — the return type follows :, exactly as on a tool method. The older name(params) -> type form is still accepted when an extension is loaded, but mhl lint MUST reject it: -> introduces a body, never a type. A leading /** ... **/ block documents the kind, and a trailing /// ... on a property or method line documents that entry — both read from the source text itself, not evaluated as expressions.

/**
Fetch a customer by id from the Acme CRM.
**/
extensible crm {
    manifest: { id: "com.acme.crm", api_version: "1", executable: "bin/crm" }
    properties: {
        endpoint: string /// The Acme API base URL.
    }
    lookup(id: string): object /// Fetch a customer by id.
}

mhl extension looks for extension.json first, then extension.mh, in every directory it scans; the two forms are otherwise interchangeable — never both in the same directory. Use mhl extension init, test, package, install, list, and doctor to create, probe, lock, and inspect adapters. See Docs-Extensions.dc.html for the full manifest schema and wire protocol.

16. Pipelines and workflows

A pipeline is the executable unit. Inputs are supplied by the CLI or a server adapter and are validated against their declared types. An input is required unless it carries an = expression default (input item_type: string = ""), in which case a caller MAY omit it: the expression is evaluated once per run — the same timing as a pipeline-level var initializer — and a supplied value always overrides the default. A defaulted input is excluded from the "required" set of the inputSchema a server adapter advertises (§18); when the default is itself a literal, the schema also carries it as a JSON default. An input typed with a declared enum (e.g. input action: ActionType) projects onto inputSchema as {"type": "string", "enum": [...]}, the variant list in declaration order — the standard JSON Schema idiom for a closed value set. Pipeline-level variables are shared across steps; step-local variables are not visible to later steps or repeat.stop_when. A pipeline MAY instead declare its inputs, and its result type, through a typed signature (§16.9).

16.1 Ordered execution and checkpoints

workflow Release {
    input environment: string
    var approved = false
    description: "Validate and publish a release"
    checkpoint: { enabled: true, strategy: "per_step", storage: "file", ttl: 7d }

    step Validate timeout 2m {
        var test = cmd.exec(["go", "test", "./..."], timeout: 90s)
        if (test.exit_code != 0) fail(test.stderr)
        approved = true
    }
    step Publish {
        if (!approved) goto Validate
        log.info("publishing to", environment)
    }
}

A plain pipeline runs steps in declaration order and rejects goto. A workflow has the same model but permits forward or backward jumps to its own named steps. A step timeout creates a deadline for blocking calls and fails the step when it expires. A checkpoint is saved after each completed step when per-step checkpointing is enabled; mhl run ... --resume re-enters after the last completed unit with a fresh timeout budget.

Inputs are read-only. An input is bound to the value supplied with the invocation — the original call, or that call's values with the arguments merged in by --resume/run/resume — before every step. An assignment to an input (directly, through self., by +=, or as a destructuring target) and a var redeclaring one MUST raise, and static analysis MUST report both. A pipeline-level var or mem keeps whatever a step last assigned it across steps, goto, and a pause()/resume; state that changes during a run belongs there.

For a recurring dispatch, write goto match rather than a chain of if (artifact == ...) checks. Each arm names a declared step, so a typo is caught by lint. A review gate may pause and, on resume, choose generation or commit:

step Gate {
    if (!approved) pause("review " + artifact)
    goto match artifact {
        "brief" -> BriefCommit
        "adr"   -> AdrCommit
        _ -> fail("unknown artifact: " + artifact)
    }
}

The full feedback and approval flow is in sample/syntax/pipeline_vs_workflow/goto_match_artifact_review.mh. A paused run re-enters Gate with updated inputs; the route is selected then.

route Generate(artifact: string) {
    "brief" -> BriefGenerate
    "adr"   -> AdrGenerate
    _ -> fail("unknown artifact: " + artifact)
}

step Retry { goto Generate(artifact) }

A route belongs to its workflow and may be invoked by multiple steps. It performs control transfer directly; it is not a tool and does not produce a computed step-name string.

Each checkpoint carries a digest of the pipeline it was written for — that pipeline's own declaration plus the shared declarations it may reference (agents, tools, prompts, memory, extensions, type aliases, enums, imports); positions, comments, sibling pipelines and test blocks are excluded — together with the runtime build version and a state-schema version. A --resume whose current digest differs MUST be refused rather than run stale variable state through changed control flow, unless the operator passes --force (mismatch becomes a warning). A newer state schema is refused regardless of --force; a checkpoint predating the digest field resumes unconditionally. State schema 2 adds reference-object identity (§5.3); a schema-1 checkpoint loads unchanged.

Execution is at-least-once per step. A checkpoint records inter-step progress, not the external effects a step produced: a step that fails (or a process that dies) after performing an effect but before its checkpoint is written re-runs that effect on --resume; a pause re-enters its step; a failed parallel branch re-runs beside completed siblings. The runtime does not deduplicate external effects — a step that mutates an outside system MUST be made idempotent by the program (an idempotency key, check-then-act, or a prepare/apply split).

Checkpoint state is JSON. A pipeline-scoped variable is checkpointable only if its value is JSON-representable — a string, number, bool, null, or an array/object of those; resolved credentials are persisted as their reference and re-resolved on --resume. A pipeline-scoped variable holding a value JSON cannot represent (a closure, an opaque handle) MUST fail the run at the next checkpoint rather than be silently persisted as an empty object.

Explicit credential resolution MUST register the exact nonempty string independently of length, numeric/keyword appearance, or surrounding whitespace. Heuristic registration of ordinary environment reads remains separate. A whole secret value with an unambiguous reference is re-resolved on resume; embedded secrets and secrets without references are masked. Distinct references resolving to the same value in a runtime process MUST NOT silently overwrite provenance: the checkpoint records env()-independent ambiguous() in its secret-reference placeholder, and resume MUST fail with an explicit ambiguity error. This ambiguity remains for the lifetime of the process. Registration is process-wide; even a short explicitly resolved secret masks matching text in other output from that process.

16.2 Loop pipelines

Prefix either form with loop and add repeat: {stop_when, max_iterations}. A max <N> header clause — loop workflow Refine max 3 { ... } — is shorthand for repeat: {max_iterations: N} when a fixed iteration ceiling is the whole repeat policy; declaring both the clause and an explicit repeat max_iterations is a lint error and the repeat block wins. An iteration runs to completion before stop_when is evaluated. break wins over the stop condition and maximum. An error is not a soft stop: it aborts the run. A loop checkpoint records the next iteration and terminal reason (stop_when, max_iterations, break, or pause); a paused loop is re-entered by --resume, the others are not.

16.3 Context

pipeline Build {
    checkpoint: {enabled: true, strategy: "per_step"}
    context: {source: "latest", require: false}
    step Report {
        log(context.session_id, context.resumed)
        if (context.vars.keys().contains("build")) log(context.vars["build"])
    }
}

Every pipeline and workflow step MAY read the read-only object context = {session_id, started_at, resumed, principal, vars}; no declaration is required for session_id, started_at, resumed, or principal. principal is the verified server caller identity when available (otherwise the empty string) and is not an authorization mechanism. The context: {source, require} block governs vars only — the previous completed run's final variable state. Without the block vars is the empty object; with it, vars stays empty until a run of that pipeline has completed once, so a program SHOULD guard with context.vars.keys().contains(...).

16.4 Output projection

workflow PublishRelease {
    input environment: string
    var artifact_url = ""
    output: { url: artifact_url, environment: environment }
    step Ship { artifact_url = "https://releases.example.com/" + environment }
}

The optional output: {name: expr, ...} block is a pipeline/workflow's result contract. When declared, a run returns only these keys — to a mhl run invocation and to every mhl serve mcp / serve a2a caller — each evaluated against the final variable state (keys MAY be renamed, computed, or omitted). Without the block, a run returns every non-internal var. The projection does not affect what a later run's context: reads back, which is always the full final state. The value MUST be an object mapping; any other type aborts the run. When the pipeline declares a result type (§16.9), the block is REQUIRED and its value MUST also satisfy that type.

The mapping is evaluated exactly once, when the run reaches a terminal state — a normal completion or a break. A run suspended by pause(...) does not evaluate it (its expressions may read a var the run has not set yet): the suspension response carries the partial variable state instead, and the --resume / run/resume that completes the run evaluates the real projection. The mapping's result is not checkpointed, so a resumed run re-evaluates it. Mapping expressions observe the run's cancellation and deadline like a step body; side effects there are discouraged — they run once, are not persisted, and re-run on resume.

16.5 Parallel groups and spawned agents

pipeline Review {
    input diff: string
    input changed_files: string[]
    spawn: {max_concurrency: 4}
    var docs = ""
    var issues = ""
    step Analyze {
        spawn code = Code.run(prompt: diff)
        spawn security = Security.run(prompt: diff)
        wait code, security timeout: 90s
        log(code.result, security.result)

        spawn reviews = Reviewer.run(prompt: "review ${file}") for file in changed_files
        wait reviews timeout: 90s
        for (var r in reviews) log(r.status, r.result)
    }
    parallel Gather timeout 2m {
        step Docs { docs = DocsAgent.run(prompt: diff) }
        step Issues { issues = IssuesAgent.run(prompt: diff) }
    }
}

spawn is valid directly inside a step and only accepts an Agent.run(...) call. It returns a task handle with result, ok, error, status, and duration_ms. wait names waits for all and fails fast; wait any names returns on the first success; wait N of names returns after N successes. The optional timeout: bounds the wait, and on_error: "collect" keeps errors inspectable rather than failing the step. Unjoined handles are drained at step end and cannot outlive a checkpoint.

A trailing for <var> in <expr> clause makes spawn a fan-out: <expr> is evaluated once and must be an array; the Agent.run(...) call is started once per element with <var> bound to that element while its argument expressions are evaluated, so each call may carry a distinct prompt. The bound name then holds an array of task handles in element order. It indexes and iterates as an ordinary array (xs[0].result, for (var h in xs) …, xs.size()), and a wait naming it — wait xs, wait any xs, wait N of xs — expands it to its elements. A non-array <expr> is a runtime error; an empty array yields an empty handle array on which a plain wait is a no-op. max_concurrency continues to bound the number of concurrent calls.

A named parallel group is a barrier: all branches run concurrently and the pipeline advances only after all complete. A branch that fails does not cancel its siblings — the barrier still waits for every branch, then the run fails with the first branch error; on --resume the whole group re-runs, so branch steps MUST stay idempotent. Each branch receives a copy of shared pipeline variables; conflicting writes fail. Reference objects (§5.3) are merged by identity and field: changes by different branches to different fields of the same reference object are all applied to it; the same field set to different values by two branches is a conflict and fails the run. Output is merged in declaration order. A group timeout cancels unfinished branches. goto cannot target or appear within a group, and break is not permitted in one.

16.6 Splitting a declaration across files (partial)

// discovery.mh
import "discovery.brief.mh"

partial workflow Discovery {
    entry step Dispatch { goto BriefGenerate }
}

// discovery.brief.mh
partial workflow Discovery {
    step BriefGenerate { log("brief") }
}

A leading partial keyword marks a pipeline/workflow declaration as one fragment of a larger one whose members MAY be split across files. Every fragment sharing a declaration name is combined, after import resolution, into a single declaration with one concatenated member list before any further static checking or execution occurs — as if the fragments had been written in one file, in the order their files were merged in. A fragment reaches its sibling(s) through the brace-less whole-file form of import (§4): import "path" merges every declaration that file resolves to, not only exported or explicitly named ones, which is what lets a fragment join without an individual export. Two fragments of the same declaration name MUST use the same pipeline/workflow keyword; loop, a max <N> header clause, and a typed signature (§16.9) MAY each be declared on at most one fragment.

A pipeline otherwise begins at its first declared step. Once a declaration's steps may arrive from more than one file, "first declared" no longer identifies a stable step, so a partial declaration's merged member list MUST carry exactly one step marked with a leading entry keyword (entry step Name { ... }), which is the step execution begins from regardless of merge order. entry MUST NOT appear on a step of a declaration that is not partial — there the first declared step is unambiguous already — nor on a step inside a parallel group. This requirement is enforced where a partial declaration is resolved for actual execution (a run, a test's .run(), a server adapter registering it), not merely wherever its source happens to be parsed: a lone fragment file, resolved without the sibling(s) that complete it, is expected to be missing its entry step, and that alone MUST NOT be treated as an error by a tool that scans a directory of files independently (mhl serve mcp <dir>, mhl test <dir>) — such a tool skips registering an incomplete partial declaration rather than failing unrelated declarations over it. Static analysis (mhl lint) follows the same reasoning, and applies it beyond just entry: a missing entry, a goto/route target that does not resolve, and a step assigning to a pipeline-scoped variable declared only in another fragment are each only reported once a whole-file import has actually merged more than one fragment together and the problem is still present — never against a single fragment resolved on its own, which cannot distinguish "the rest is elsewhere" from "genuinely wrong." Checks that do not depend on the rest of the declaration (a route's parameter shape, route/goto requiring a workflow, an agent call naming an undeclared agent) are unaffected and still run against a lone fragment. A duplicated entry (more than one found) has no such ambiguity and MUST be reported regardless of how many fragments are visible.

A step with no unconditional control transfer at the end of its body — no trailing goto, goto match, break, bare fail(...), bare pause(...), or bare complete() (nor a trailing if/else where both branches recursively satisfy this) — falls through into whatever is physically next once it finishes: running off the very end of the fully merged step list is the *only other* way a pipeline reaches normal completion (a bare return inside a step does not halt anything either, unlike inside a `tool` method — it is discarded and the step falls through exactly as if nothing had been written). complete() (§10.1, statement table) is the primitive a convergence/terminal step SHOULD call to end the run right there, independent of its position in the merged step list — the recommended fix for the fragment-boundary case below, in preference to relying on which fragment happens to be merged last. This is not by itself an error: it is mhl's default execution model, in a plain pipeline as much as a workflow, and MUST NOT be flagged when a step's declared successor is simply the next step in the same file. It becomes a defect specifically at a partial `workflow`'s fragment boundaries: a fragment's own last declared step, when at least one more fragment is merged in after it, MUST end in one of the constructs above, because "this step has nothing after it" — true when it was written — silently stops being true once another fragment's steps are appended. mhl lint MUST report a fragment's last step failing this rule when it is not the merged declaration's actual last step, and MUST NOT report it for a step that is not its own fragment's last member (reordering steps within one fragment file remains exactly as visible, and exactly as valid to leave as implicit fallthrough, as within any non-partial declaration) nor for a step in the declaration's truly last-merged fragment (nothing follows it) nor for a plain pipeline fragment (which has no goto at all, so every boundary already falls through by construction).

16.7 Self-referencing pipeline-scoped state

workflow Demo {
    input name: string
    var greeting = ""
    step Build {
        self.greeting = "hello " + self.name
    }
}

Inside a step, self.name reads the pipeline's own declared input, var, or mem named name, and writes a var or mem of that name (an input is read-only, §16.1) — the property form of self, distinct from self.method(...) inside a tool method (§9.2), which it is a static and runtime error to use outside a tool method. It is exactly equivalent to reading/assigning the bare name, with one difference: a step-local var sharing that name shadows a bare reference to it (§7), but self.name MUST always resolve to the pipeline's own binding regardless of any step-local shadow — it exists specifically to name that binding unambiguously, which matters most for a partial declaration (§16.6), where the binding may be declared in a different file than the step reading it. self.name used outside any pipeline/workflow step (a tool method, a test's describe block outside a running .run()) MUST raise; naming something not declared as an input/var/mem of that pipeline MUST also raise, on both the read and the write side. Only a bare self.name target is an assignable form — a further trailer on top of it (self.name.field = ..., self.name[0] = ...) is rejected the same as any other nested-field assignment target (§7).

16.8 Lifecycle hooks

workflow Discovery {
    session_start: (session) -> { log("start " + session.session_id) }
    session_end:   (session) -> { log("end broke=" + json.stringify(session.broke)) }
    step_start:    (step) -> { log("-> " + step.step) }
    step_end:      (step) -> { log("<- " + step.step + " " + json.stringify(step.error)) }
    stop_failure:  (failure) -> { log("FAILED " + failure.reason) }

    step Dispatch {
        // Add workflow operations here.
    }
}

A pipeline/workflow declaration MAY declare up to five optional body properties — session_start, session_end, step_start, step_end, stop_failure — each an ordinary lambda literal, the same name: expression body property every other pipeline-level property (checkpoint, output, ...) already is; no new grammar is introduced. Each lambda MUST declare exactly one parameter; a lambda with zero or more than one parameter is a runtime error at the point the hook would fire. A hook's declared parameter is bound, on each call, to a fresh object built by the runtime, structurally satisfying one of the three builtin types §9.1 declares — SessionContext for session_start/session_end, StepContext for step_start/step_end, FailureContext for stop_failure — with every field of that type present on every call (a field a particular firing has nothing to say about is null, never absent); the hook body MAY also read the pipeline's own var/mem state by bare identifier, exactly as a step body does. A hook's return value is not consumed for anything: returning any value other than nothing at all (an implicit or bare return) MUST raise a runtime error. Hooks exist purely to observe a run — logging, external notification, writing to mem — and MUST NOT be used as a means to alter, retry, or suppress what the run does; the runtime provides no channel for a hook to do so. A hook's parameter MAY be annotated; when it is annotated with one of the three builtin context types, it MUST be the type that hook is bound to, and static analysis MUST report any other (the runtime does not check the annotation, so a mismatch would otherwise only mislead a development tool's completion).

session_start fires exactly once per invocation of the run — before its first step executes, and again, with session.resumed set to true, on every --resume / run/resume of that same run, including a resumed loop pipeline/loop workflow (once per whole run, not once per iteration). It fires only after every admission check that precedes session creation (input validation, memory initialization) has already succeeded — a rejected input MUST NOT trigger session_start. Its SessionContext parameter has pipeline (the declaration name), kind ("pipeline" or "workflow"), session_id, resumed, and inputs (the coerced input map) meaningfully set; vars, broke, break_reason, and iterations are present but null, since none of them has a value yet.

session_end fires exactly once, when the run reaches a terminal, non-error state that is not a suspension: a normal completion (running off the merged step list, or complete()) or a break. It MUST NOT fire when a step calls pause(...) — the run is suspended, not finished, and a later --resume that goes on to complete or break fires the applicable terminal hook then — nor when the run ends in a genuine failure (stop_failure below fires instead). For a loop pipeline/loop workflow, exhausting the loop's max_iterations ceiling on schedule is one such normal termination and MUST fire session_end, never stop_failure — it is a soft, non-error stop, unrelated to the max_step_visits failure below despite the similar name. Its SessionContext parameter has pipeline, kind, session_id, vars (the same value the run returns to its caller), broke, and break_reason meaningfully set (resumed and inputs are present but null); iterations is meaningfully set only for a loop, null otherwise.

step_start fires immediately before each step actually executes — once per step of a plain run, once per branch of a parallel group (its parameter's index/total reflect the flat, run-wide step count, the same numbering a host-level step-progress callback already uses), and once per iteration of a loop pipeline/loop workflow (unlike session_start/session_end, which fire once for the whole run). step_end fires immediately after that same step execution concludes, whatever the outcome — success, a caught control-flow exit (break, pause, complete(), goto), or a genuine failure. Both parameters are a StepContext carrying pipeline, step (the step's declared name), index, total, and error; error is null for step_start (the step hasn't run yet) and for step_end whenever the step succeeded or exited via a caught control-flow signal, otherwise the failure's message. Because a parallel group's branches execute concurrently, their step_start/step_end calls MAY interleave; an implementation MUST NOT serialize them beyond what is already required for a shared resource (memory, output).

stop_failure fires exactly once, when the run ends in a genuine terminal failure — never for a suspension or any of the soft stops above. Its parameter carries at least pipeline, kind, step (the failing step's name, or the empty string for a failure not attributable to one step), error (the underlying failure's message), and reason, drawn from a closed set: "failed" (an ordinary fail(...) or uncaught runtime error), "timeout" (an unrecovered step timeout), "cancelled" (the run's context ended — a caller cancellation or server-side drain), "max_step_visits" (a goto cycle with no break to escape it, tripping the runtime's revisit safety cap), or "runtime_error" for any other failure not attributable to one of the above. An error raised by evaluating a caller-facing projection after the run otherwise finished (e.g. an output: mapping error) MUST also be reported through stop_failure — from the caller's perspective the run still ended in failure, regardless of exactly which step of processing raised it. A failure inside stop_failure's own body MUST NOT replace or suppress the failure that triggered it: an implementation MUST still surface the original failure to the run's caller, and MAY additionally report the hook's own failure (e.g. as a diagnostic), but MUST NOT let it mask the original. A failure inside any of the other four hooks, by contrast, MUST abort the run exactly as any other runtime error would — including, per the previous paragraph, being subsequently reported through stop_failure itself.

16.9 Typed signature

type ReviewInput  = {diff: string, base?: string}
type ReviewOutput = {approved: bool, summary: string}

workflow ReviewPullRequest(req: ReviewInput): ReviewOutput {
    var summary = ""
    step Review { summary = req.diff + " vs " + (req.base ?? "main") }
    output: { approved: true, summary: summary }
}

A pipeline/workflow declaration MAY carry a signature between its name and its max clause or body: an optional parameter (name: Type) and an optional result type : Type, each independent of the other. A declaration with neither is unaffected by this clause.

Parameter. The parameter's type MUST resolve to an object shape. Its fields are the pipeline's inputs: a required field is a required input, an optional field (§5.2) an optional one; they are validated, coerced, and advertised (inputSchema, §18) exactly as input members are. Within the pipeline — every step, output: projection and repeat.stop_when — the inputs are bound as a single object under the parameter's name, never as individual variables; every declared field is present on it, an omitted optional field with the value null. A pipeline declaring a parameter MUST NOT also declare input members, nor a pipeline-level var, const or mem with the parameter's name. The parameter is read-only: an assignment to it (directly, through self., or by index) and a step-local declaration of its name MUST be rejected, statically where possible and otherwise at run time. The inputs field a lifecycle hook receives (§16.8) carries the supplied arguments keyed by field, not the bound object. On a resume that supplies arguments, a supplied declared field replaces that field of the checkpointed object and the others are kept; a supplied key that is not a declared field binds as a top-level variable, as it does for a pipeline with no parameter.

Result type. The result type MUST resolve to an object shape, and the pipeline MUST declare an output: projection (§16.4) — a result is never derived implicitly from variables. When the run reaches a terminal state that evaluates the projection (a normal completion or a break), the projected object MUST satisfy the result type; if it does not, the run fails with an output-contract error and returns no result. Static analysis SHOULD report, for a projection written as an object literal, a result-type field that is required but absent and a key that the result type does not declare. A server adapter MUST advertise the result type as the tool's JSON Schema outputSchema (§18.2). In both the input and the output schema, a field whose type is a declared enum — at any depth — carries that enum's variants as a JSON Schema "enum" list.

A pipeline that declares no signature has the same definition digest (§16.1) it had before signatures existed, so its checkpoints resume across the upgrade; adding a signature to a pipeline changes its digest.

16.10 Workflow aliases

workflow Delivery = ArtifactFlow with { level: Level.delivery } {
    description: "Artifacts for a Feature or a standalone Story."
}

A workflow alias declares a new pipeline or workflow name whose execution is that of its target, a pipeline or workflow declaration of the same kind that is not itself an alias. The alias is a distinct entry point: it is selected, published by a server adapter, and keyed for sessions, checkpoints, and memory state under its own name. Its description is its own description property when present, else the target's; no other property is permitted. Each field of the with object binds one input of the target — a declared input, or a field of its typed parameter — to a value that MUST be a literal or an enum value (a variant name string converts as for inputs, §9.1) of the input's type; binding an undeclared input or the same input twice is a static error. The alias's inputs are the target's minus the bound ones; a bound value is supplied on every run exactly as if the caller had passed it, and a caller-supplied value for a bound input MUST be rejected as an undeclared input. An alias's definition digest (§16.1) covers the alias declaration and its target's.

Steps are resolved within the declaration being run: two declarations in one program MAY declare steps of the same name. A server adapter publishes every pipeline, workflow and alias declared by a file in its directory, attributing each to the file that declares it — not to a file that merely imports it.

16.11 Internal declarations

A pipeline, workflow, or workflow alias declared internal is not an entry point. A server adapter MUST NOT publish it, and selecting a file's pipeline without naming one (mhl run file.mh) MUST skip it, raising when the file declares no other. It otherwise behaves as any declaration: it can be the target of a workflow alias (the usual case — a shared flow published only through its aliases), and a test can run it by name. In a partial declaration, a fragment carrying internal makes the whole declaration internal.

17. Tests

A test declaration contains named describe blocks. Each description gets a fresh environment and supports the same statement grammar as a pipeline step. Assertion calls are recognized only inside descriptions.

test TextExamples {
    describe normalization {
        are_equal(" Feature ".trim().to_lower(), "feature")
        is_true([1, 2].contains(2))
        is_false([1, 2].contains(3))
    }
    describe future_case {
        incomplete("implementation pending")
    }
}
AssertionSignature / pass condition
are_equalare_equal(actual: any, expected: any)
are_not_equal / not_equalare_not_equal(a: any, b: any)
is_true, is_false(value: bool)
is_null, not_null(value: any)
greater_than, less_than(a: number, b: number)
greater_than_or_equal, less_than_or_equal(a: number, b: number)
includesincludes(array: any[], value: any)
incompleteincomplete(reason?: any)

Equality is deep and structural. Wrong assertion arity or type is a hard error. A failed assertion marks the description as failed; incomplete marks it pending and does not count as a failure.

17.1 Running a pipeline/workflow from a test

Inside a describe block only, Name.run(inputs?: object) — where Name is a declared pipeline/workflow — runs its steps to completion (or to a break/pause/failure) inside an isolated, throwaway sandbox (a fresh temp directory and an in-memory checkpoint store, both discarded when the call returns) and returns the outcome as an object, rather than the pipeline's raw variable state:

workflow Recascade {
    step Start { goto Target }
    step Skip { var unreachable = true }
    step Target { var reached = true }
}

test RecascadeTest {
    describe follows_goto {
        var result = Recascade.run()
        is_true(result.ok)
        are_equal(result.state, "completed")
        are_equal(result.executed, ["Start", "Target"])
    }
}
FieldTypeMeaning
okboolfalse only when state is "failed".
statestringOne of "completed", "paused", "broke", "failed".
executedstring[]Step names that ran, in order.
varsobjectFinal variable state; {} when state is "failed" (nothing was captured to report), except for an output-contract failure, where every step ran and the final state is kept.
outputobjectThe result a real caller receives (§16.4): the output: projection when declared — checked against a declared result type (§16.9) — otherwise every non-internal variable. null unless state is "completed" or "broke". A projection that does not satisfy the result type reports state "failed", ok false, and the contract error in error.
errorstring"" unless state is "failed".
stepstringThe failing step's name; "" unless state is "failed" from a step error.
break_reasonanynull unless state is "broke".
pause_reasonanynull unless state is "paused".

.run() never raises for an outcome the pipeline's own steps produced — fail(), break, pause(...) all land in the returned object, so a test asserting a workflow should fail needs no try/catch. It raises only for a problem with the call itself: a missing or mistyped required input (the same InputSchema contract mhl run/an MCP tool call enforces), or a loop pipeline/loop workflow target, which is not supported yet. Calling .run() on a pipeline/workflow name outside a describe block is a static-shaped but runtime-rejected call — one run executing another synchronously mid-step has no defined interaction with checkpointing or context yet, so it is refused with a clear error rather than allowed.

18. CLI and serving

18.1 Commands

CommandPurpose
mhl initCreates a starter program without overwriting existing files.
mhl run file.mh [--input key=value] [--resume [--force]] [--session id] [--format text|json] [--dry-run]Runs a pipeline/workflow with typed inputs and optional checkpoint resume. --force (with --resume) resumes through a changed pipeline definition, warning instead of refusing. --format json emits one object — the result on success, or a typed error (kind, step, hint, and for bad inputs missing/unknown; output_contract when a result breaks a declared result type, §16.9) on failure — with the run's own output moved to a log field; paused and broken runs include pause_reason / break_reason, and the process still exits non-zero on failure. Registered secrets are masked recursively in vars, object keys and stop reasons, both for legacy results and explicit output: projections; non-sensitive values and JSON types are preserved. Log and error text are also masked. --dry-run validates parse, imports, lint and the input contract — required inputs present, no undeclared keys, and each supplied value fitting its declared input name: Type (the same coercion a real run applies) — and prints the static step plan (order, parallel groups, checkpoint policy) without executing any step or calling any agent; it exits non-zero on a lint finding or an unsatisfied input contract, and cannot be combined with --resume.
mhl test file.mh|directoryRuns test declarations and exits non-zero for failed assertions.
mhl lint [directory|file]Checks syntax, imports, names, configuration, types, parameters, and control-flow legality.
mhl lspStarts the language server used by editor integrations.
mhl serve mcp directoryExposes workflows as MCP tools over stdio.
mhl serve mcp --http directoryExposes workflows over Streamable HTTP with synchronous tools and asynchronous run/*.
mhl serve mcp --http --workflows-pack file directoryLoads the program and prompts from an encrypted DMPK v2 pack, decrypted only in memory (key in MHL_SERVE_WORKFLOWS_KEY); directory is then only the root that subprocess paths are composed under.
mhl pack directory -o file [--new-key]Seals every non-hidden file under a directory into such a pack, sorted by path, after verifying the workflows load from it.
mhl serve a2a directoryExposes workflows as A2A skills over HTTP.
mhl extension ...Initializes, tests, packages, installs, lists, and diagnoses external adapters.
mhl version, mhl --version, mhl -vPrints the runtime version.

For --input, strings remain strings; declared numbers and booleans are coerced from command-line text; arrays and objects are parsed as JSON. Inputs not declared by the selected pipeline are rejected by server adapters and diagnosed by the CLI profile.

18.2 Serving workflows

mhl serve mcp publishes one MCP tool per pipeline/workflow. Stdio uses newline-delimited JSON-RPC 2.0. HTTP defaults to 127.0.0.1:8711 and accepts one JSON-RPC message per POST /mcp. The A2A server defaults to 127.0.0.1:8710 and publishes /.well-known/agent-card.json.

Async methodPurpose
run/startStarts a long workflow and returns a run id immediately.
run/statusReturns state, current step, reached steps, final variables, errors, and resumability.
run/resumeContinues a failed checkpointed workflow, optionally merging new arguments such as an approval.
run/cancelCancels an active run.
run/listLists runs owned by the current caller.
run/logsReturns the run's retained step and log() output, incrementally via a since cursor.

The run/* family is HTTP-only. Because a stock MCP client can call only tools/call, the HTTP tools/list also publishes six control tools — mhl_run_start, mhl_run_status, mhl_run_resume, mhl_run_cancel, mhl_run_list, mhl_run_logs — each a thin bridge onto the same-named method, returning the status object as structuredContent. Read-only detail is served as MCP resources: mhl://workflow/<name> (a manifest projected from the loaded pipeline) on both transports — the .mh source itself is never served — plus the owner-gated mhl://run/<id>/logs and mhl://run/<id>/result over HTTP.

A pipeline with a declared result type (§16.9) publishes it as the tool's outputSchema in tools/list, titled with the type's name as written, and its manifest resource carries the same outputSchema plus input: {param, type} / output: {type} naming the declared types. A successful tools/call's structuredContent is the checked projection. Because structured results of such a tool MUST conform to its outputSchema, an error or a paused run of it is reported in the text content only, without structuredContent; a tool with no result type keeps its structured error object. An A2A skill carries the same schema as the non-standard hint metadata.outputSchema.

HTTP serving supports --token, --principal-header, --state-dir, --drain-timeout, and --max-concurrent-runs. Operational endpoints include /healthz, /readyz, and /metrics. Each run is owned by its caller — the --principal-header principal when one is configured, otherwise the Mcp-Session-Id — and run/status, run/resume, run/cancel, run/list, and run/logs act only for that owner.

19. Security and operational semantics

MHL programs can execute processes, modify files, access networks, call remote tools, and persist state. An implementation and operator SHOULD treat every .mh file as executable code.

  • Credentials SHOULD be referenced with env("NAME") and MUST NOT be embedded in source, command arguments, or committed state.
  • Arguments passed as argv arrays preserve boundaries and avoid accidental shell interpolation. Command strings SHOULD be reserved for deliberate shell-like behavior.
  • Authorization, Cookie, bearer, basic-auth, and recognized secret values are registered and redacted as [REDACTED] in logs, diagnostics, and checkpoints.
  • External adapters MUST be present in the extension lock and their executable digest MUST match before execution.
  • Timeouts, retry limits, concurrency limits, and loop ceilings SHOULD be configured for every external or potentially unbounded operation.
  • Checkpoint and memory paths SHOULD be scoped by session when concurrent runs must not share state.
  • Server mode SHOULD bind to loopback unless a token and an appropriate network boundary are configured. A token is a shared trust-domain secret, not per-user authorization.
Secrets and source controlDo not commit API keys, tokens, resolved credentials, log files, cache contents, or checkpoint state. Review extension permissions and the capabilities available to a process before running untrusted automation.

Annex A — Complete grammar summary

This annex collects the declaration and statement productions omitted from the compact expression grammar.

import         ::= "import" "{" import-item ("," import-item)* "}" "from" String
                 | "import" String
import-item    ::= Identifier ["as" Identifier]
enum           ::= "enum" Identifier "{" [Identifier (","? Identifier)* ","?] "}"
agent          ::= "agent" Identifier "{" property* "}"
router         ::= "router" Identifier "{" property* "}"
prompt         ::= "prompt" Identifier "(" [parameter ("," parameter)*] ")"
                   ("from" String | "{" expression "}")
skill          ::= "skill" Identifier "from" String
memory         ::= "memory" Identifier "{" property* "}"
tool           ::= "tool" Identifier "{" tool-method* "}"
tool-method    ::= ["internal"] Identifier "(" [parameter ("," parameter)*] ")"
                   [":" type] ("->" expression | ["->"] block)
parameter      ::= Identifier [":" type] ["=" expression]
extension      ::= "extension" Identifier Identifier ( "from" String )? "{" property* "}"
extensible     ::= "extensible" Identifier "{" extensible-member* "}"
extensible-member ::= "manifest" ":" object
                   | "properties" ":" "{" extensible-property* "}"
                   | extensible-method
extensible-property ::= Identifier ":" type
extensible-method ::= Identifier "(" [parameter ("," parameter)*] ")" ":" type
workflow-alias ::= ["internal"] ("pipeline" | "workflow") Identifier "=" Identifier ["with" object]
                    ["{" property* "}"]
pipeline       ::= ["internal"] ["partial"] ["loop"] ("pipeline" | "workflow") Identifier
                    ["(" Identifier ":" type ")"] [":" type] ["max" Number]
                    "{" pipeline-member* "}"
pipeline-member ::= input | const-declaration | var-declaration | mem-declaration
                   | parallel | step | workflow-route | property
input          ::= "input" Identifier ":" type ["=" expression]
step           ::= ["entry"] "step" Identifier ["timeout" duration] block
parallel       ::= "parallel" Identifier ["timeout" duration] "{" step+ "}"
repeat         ::= "repeat" ":" object
context        ::= "context" ":" object
const-declaration ::= "const" Identifier "=" expression
var-declaration ::= "var" Identifier "=" expression
mem-declaration ::= "mem" Identifier "=" expression
assignment     ::= lvalue ("=" | "+=") expression
destructuring  ::= ["var" | "self" "."] "{" destructure-entry ("," destructure-entry)* [","] "}" "=" expression
destructure-entry ::= Identifier [":" Identifier]
lvalue         ::= Identifier trailer*
spawn          ::= "spawn" Identifier "=" expression ["for" Identifier "in" expression]
wait           ::= "wait" ["any" | Number "of"] Identifier ("," Identifier)* wait-option*
wait-option    ::= ("timeout" | "on_error") ":" expression
try-statement  ::= "try" block "catch" ["(" Identifier ")"] block ["finally" block]
return         ::= "return" [expression]
break          ::= "break" [expression]
goto           ::= "goto" (Identifier | "nameof" "(" Identifier ")")
                 | route-goto | goto-match
test           ::= "test" Identifier "{" describe* "}"
describe       ::= "describe" Identifier block
property       ::= Identifier ":" expression

A declaration body (agent, router, memory, extension, and the property members of a pipeline) is a sequence of property productions. Whitespace, including line breaks, is ignored between tokens; multiple properties MAY appear on the same line. Commas separate the fields of an object literal — the value on the right of a property — never the properties of a declaration body itself.

Implementations MAY expose richer diagnostic positions, syntax highlighting, completion, or generated schemas. Those facilities do not alter the source grammar.

Annex B — Complete example

The following program demonstrates declarations, a prompt, typed input, persistent loop state, tools, an agent, native operations, and self-verification.

enum Outcome { Pending, Passed, Failed }
type Finding = {code: string, severity: string}

agent Reviewer {
    engine: "cli/reviewer"
    command: "reviewer"
    args: ["--prompt", "${prompt}"]
    retry: {max_attempts: 2, delay: 1s, backoff: "exponential"}
    rate_limit: {requests_per_minute: 10, concurrency: 2}
}

prompt Audit(repo: string, diff: string) {
    "Review ${repo}. Return JSON findings for this diff:\n${diff}"
}

tool Project {
    diff(dir: string = "."): string -> git.diff(dir: dir)
    verify(dir: string = ".") -> cmd.exec(["go", "test", "./..."], timeout: 2m)
}

loop workflow AuditLoop {
    input repo: string
    mem status = Outcome.Pending
    mem attempts = 0
    repeat: {stop_when: status == Outcome.Passed, max_iterations: 3}

    step Inspect {
        attempts = attempts + 1
        var diff = Project.diff(repo)
        var result = Reviewer.run(prompt: Audit(repo: repo, diff: diff))
        fs.write("audit.json", result)
        status = Outcome.Passed
    }
}

test AuditExamples {
    describe values {
        are_equal(type_of(Outcome.Passed), "enum")
        is_true([1, 2, 3].contains(2))
    }
}

Run it with mhl lint audit.mh, mhl test audit.mh, and mhl run audit.mh --input repo=.. The external reviewer executable must be available in the host environment.